Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f62fbcfc17 | ||
|
|
55bc4d0bb1 | ||
|
|
194410d467 | ||
|
|
e5e740765d | ||
|
|
e92dcaff12 | ||
|
|
e1ac4e365f | ||
|
|
301f3fcb93 | ||
|
|
a5ede6fbf4 | ||
|
|
337f31ddab | ||
|
|
fca1105ed9 | ||
|
|
df5a1493bb | ||
|
|
fd41320e3c | ||
|
|
f914ab616e | ||
|
|
13463c0ce0 | ||
|
|
5a3d01efb1 | ||
|
|
a2812abc90 | ||
|
|
2a5a370185 | ||
|
|
61f958c17e | ||
|
|
f6cbe34b61 | ||
|
|
91cb59154d | ||
|
|
dc5c1d23fc | ||
|
|
beb048dadf | ||
|
|
8d775b4bd2 | ||
|
|
13983b614a | ||
|
|
f460cd577e | ||
|
|
90157ed29e | ||
|
|
2217b96054 | ||
|
|
bd57573764 | ||
|
|
8d2dcdcc77 | ||
|
|
3d66e8397b | ||
|
|
9e988a63b3 | ||
|
|
88e99efe4c | ||
|
|
aea90da5c6 | ||
|
|
4363f9824a | ||
|
|
d252403d37 | ||
|
|
b51ac87eb7 | ||
|
|
1d3094acee | ||
|
|
5b7fa2d4ad | ||
|
|
f832a558cd | ||
|
|
a777251ab7 | ||
|
|
1794c8f954 | ||
|
|
7e9c0cfca7 | ||
|
|
ec6e966137 | ||
|
|
fbf102c636 | ||
|
|
be8f87d6fb | ||
|
|
97b884bef7 | ||
|
|
d41ecd9877 | ||
|
|
bf301bc231 | ||
|
|
9af4c8ac05 | ||
|
|
0096aef1d2 | ||
|
|
882da7cdfc | ||
|
|
57767cc2a9 | ||
|
|
4d4106505f | ||
|
|
4af5b8bf63 | ||
|
|
70cf31eb3e | ||
|
|
451c8a0ee9 | ||
|
|
c1dba78d31 | ||
|
|
d8d2c4f32d | ||
|
|
40602702ef | ||
|
|
c771a22771 | ||
|
|
feecd263eb | ||
|
|
03c95e6881 |
@@ -3,6 +3,11 @@
|
||||
"hooks_enabled": true,
|
||||
|
||||
"UserPromptSubmit": {
|
||||
"presence_gate": {
|
||||
"enabled": true,
|
||||
"handler": "aipass.hooks.apps.handlers.security.presence_gate.handle",
|
||||
"matcher": ""
|
||||
},
|
||||
"identity_injector": {
|
||||
"enabled": true,
|
||||
"handler": "aipass.hooks.apps.handlers.prompt.identity.handle",
|
||||
@@ -98,6 +103,11 @@
|
||||
"handler": "aipass.hooks.apps.handlers.notification.telegram_response.handle",
|
||||
"matcher": "",
|
||||
"timeout": 30
|
||||
},
|
||||
"presence_release": {
|
||||
"enabled": true,
|
||||
"handler": "aipass.hooks.apps.handlers.security.presence_gate.handle_stop",
|
||||
"matcher": ""
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
@@ -55,7 +55,7 @@ src/aipass/<name>/
|
||||
- @skills — capability framework. Discoverable, self-contained skill units any agent can run; consume AIPass services as opt-in imports (e.g. the Telegram skill).
|
||||
- @daemon — task scheduler. Cron-triggered firing; each branch owns its `.daemon/schedule.json`, the daemon discovers and fires.
|
||||
- @commons — the social space. Where branches post, comment, vote, and gather as a community.
|
||||
- @backup — local-first backups. Project-owned snapshots and restore for any directory; no external service.
|
||||
- @backup — local-first backups. Snapshots + versioning + restore for any directory; optional Google Drive sync (planned). `.backup/` is a shared runtime namespace — @memory rollover and @flow (plan archive) also write there.
|
||||
|
||||
# Daily commands
|
||||
|
||||
@@ -96,6 +96,7 @@ Your continuity across sessions. Save proactively — after milestones, decision
|
||||
- `local.json` — session log, key learnings, todos.
|
||||
- `observations.json` — what you learn about the user.
|
||||
- Overflow rolls to vectors automatically — never trim by hand. Two ChromaDB stores: your branch's `.chroma` (local) + a global one across all branches. `drone @memory search "query"` recalls them. Search before assuming you're cold.
|
||||
- Entry caps are hook-enforced (over-limit edit = rejected whole). The live cap is rendered in each file's `*_meta` line — read it before writing, draft to ~80% of it; if rejected, rewrite hard in one pass.
|
||||
|
||||
# House rules
|
||||
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
# Lines starting with # are comments. Blank lines are ignored.
|
||||
# Edit this file to customize. Source defaults: handlers/ignore/patterns.py
|
||||
|
||||
.backup_system/
|
||||
.backup/
|
||||
.git/
|
||||
.svn/
|
||||
@@ -27,4 +26,3 @@ dist/
|
||||
*.log
|
||||
.ruff_cache/
|
||||
.coverage
|
||||
*logs
|
||||
@@ -25,7 +25,18 @@ Each memory file plays a distinct role. Update based on what actually changed th
|
||||
- **`date`** — ISO date/datetime.
|
||||
- Plus its text field + extras: key_learnings `{number, date, key, value}` · sessions `{number, date, summary, status, tags}` · todos `{number, date, task, priority, status}` · observations `{number, date, note, tags}`.
|
||||
|
||||
**When adding:** stamp `number` + `date`, then **prepend** (newest on top). **Don't hand-trim** — rollover archives the oldest *by number* to @memory automatically.
|
||||
**When adding:** stamp `number` + `date`, then **prepend** (newest on top). **Don't hand-trim** sessions/key_learnings/observations — rollover archives the oldest *by number* to @memory automatically. **Todos are the exception** — rollover never touches them, so you prune done ones by hand (see Reconcile below).
|
||||
|
||||
### Reconcile todos — verify against reality, don't trust the label
|
||||
|
||||
Stored status drifts: a todo finished in a past session often never gets closed. Before writing the session entry, **audit every open todo against the actual system** — check the real state, not the stored `status`:
|
||||
|
||||
- Does the file/dir still exist (or is it gone)? Is the code path in or out? Does the README/doc actually say what the todo claims? Does the audit pass?
|
||||
- **Close what's verifiably done** → note it in the session entry, then **DELETE the todo from the array**. Rollover never trims todos (they're operational — only sessions/key_learnings/observations roll), so done items left as `status: done` pile up and go stale across chats. Fail honestly — remove only on evidence, never just to tidy the list.
|
||||
- **Re-scope what's partially done** → record which sub-items landed, keep the rest open.
|
||||
- **Leave deferred / pending-decision todos open** — but confirm they're still real.
|
||||
|
||||
Quick checks beat assumptions: `ls`/`find` for files, `git ls-files`/`grep` for code/docs, `drone @seedgo audit` for standards. This step is the whole point of "close whats done."
|
||||
|
||||
## 2. Active Plans
|
||||
|
||||
@@ -55,6 +66,7 @@ List everything updated. Format:
|
||||
```
|
||||
Prep complete:
|
||||
- local.json: [what was added]
|
||||
- Todos: [reconciled vs reality — N done & removed, M re-scoped, K still open]
|
||||
- observations.json: [updated / skipped]
|
||||
- Plans: [which ones updated]
|
||||
- Git: [branch, uncommitted count, suggestion]
|
||||
|
||||
@@ -14,7 +14,7 @@ Purpose: Update branch memory files after completing work this session.
|
||||
Each memory file plays a distinct role. Update based on what actually changed this session.
|
||||
|
||||
- **`.trinity/passport.json`** — IDENTITY. Who you are: role, capabilities, principles. Only update if identity genuinely evolved this session. Don't touch it just to touch it.
|
||||
- **`.trinity/local.json`** — YOUR MEMORY. Session history, key_learnings, and todos[]. Add a session entry for significant work. Add key_learnings for facts you'd need next time. Update todos[] with open items. Trim oldest sessions if over 20.
|
||||
- **`.trinity/local.json`** — YOUR MEMORY. Add a session entry for significant work; add key_learnings for facts you'd need next time. **Todos: add what you parked, and DELETE every todo you finished this session** — the proof goes in the session entry, not the todo. Rollover never trims todos (they're operational), so done ones you leave behind resurface as "open" next load and you waste time re-confirming them. (Sessions/key_learnings DO auto-roll by number — don't hand-trim those.)
|
||||
- **`.trinity/observations.json`** — YOUR MEMORY OF THE USER. Collaboration insights, preferences, friction points, flow states. Skip entirely if nothing new about the user this session.
|
||||
|
||||
## If Relevant
|
||||
|
||||
@@ -18,14 +18,14 @@ Purpose: Update branch memory files after completing work this session.
|
||||
|
||||
### Always
|
||||
|
||||
- **.trinity/local.json** — Add new session entry to `sessions` if significant work was done. Add new `key_learnings` for facts you'd need next time.
|
||||
- **.trinity/local.json** — Add a session entry to `sessions` if significant work was done; add `key_learnings` for facts you'd need next time. **Todos: add what you parked, and DELETE every todo you finished this session** — the proof goes in the session entry, not the todo. Rollover never trims todos (they're operational), so done ones you leave behind resurface as "open" next load and you waste time re-confirming them.
|
||||
- **.trinity/observations.json** — Add notable collaboration insights: breakthrough moments, pattern corrections, flow states, friction points, preference discoveries. Skip if nothing notable this session.
|
||||
|
||||
### Entry shape — one rule for all four types
|
||||
|
||||
`key_learnings`, `sessions`, `todos` (local.json) and `observations` (observations.json) all share ONE shape: a **list of objects, newest at the top (index 0)**. Every entry carries a **`number`** (monotonic int per type — highest = newest, never reused; new = current max + 1) and a **`date`** (ISO), plus its text field + extras: key_learnings `{number, date, key, value}` · sessions `{number, date, summary, status, tags}` · todos `{number, date, task, priority, status}` · observations `{number, date, note, tags}`.
|
||||
|
||||
**When adding:** stamp `number` + `date`, then **prepend** (newest on top). **Don't hand-trim** — rollover archives the oldest *by number* to @memory automatically.
|
||||
**When adding:** stamp `number` + `date`, then **prepend** (newest on top). **Don't hand-trim** sessions/key_learnings/observations — rollover archives the oldest *by number* to @memory automatically. **Todos are the exception** — rollover never touches them, so you prune done ones by hand (delete finished todos, see above).
|
||||
|
||||
### If Relevant
|
||||
|
||||
|
||||
@@ -18,7 +18,9 @@ Purpose: Button up everything at the end of a session — or before a /compact.
|
||||
- **.trinity/observations.json** — Add collaboration insights if anything notable happened. Skip if nothing new.
|
||||
- **.trinity/passport.json** — Only update if role/purpose/principles genuinely changed this session.
|
||||
|
||||
**Entry shape — one rule for all four types:** `key_learnings`, `sessions`, `todos` (local.json) and `observations` (observations.json) are all **lists, newest at top (index 0)**. Every entry carries a **`number`** (monotonic int per type — highest = newest, never reused; new = current max + 1) and a **`date`** (ISO), plus its text field + extras: key_learnings `{number, date, key, value}` · sessions `{number, date, summary, status, tags}` · todos `{number, date, task, priority, status}` · observations `{number, date, note, tags}`. Stamp `number` + `date` and **prepend**; **don't hand-trim** — rollover archives the oldest *by number* automatically.
|
||||
**Entry shape — one rule for all four types:** `key_learnings`, `sessions`, `todos` (local.json) and `observations` (observations.json) are all **lists, newest at top (index 0)**. Every entry carries a **`number`** (monotonic int per type — highest = newest, never reused; new = current max + 1) and a **`date`** (ISO), plus its text field + extras: key_learnings `{number, date, key, value}` · sessions `{number, date, summary, status, tags}` · todos `{number, date, task, priority, status}` · observations `{number, date, note, tags}`. Stamp `number` + `date` and **prepend**; **don't hand-trim** sessions/key_learnings/observations — rollover archives the oldest *by number* automatically. **Todos are the exception** — rollover never touches them, so you prune done ones by hand (see Reconcile).
|
||||
|
||||
**Reconcile todos — verify against reality, don't trust the label.** Stored status drifts (a todo finished a past session often never got closed). Audit every **open** todo against the actual system: file/dir still there? code path in or out? README says what it claims? audit passes? **Close what's verifiably done** → note it in the session entry, then **DELETE the todo from the array** (rollover never trims todos — they're operational — so done items left as `status: done` pile up and go stale across chats), **re-scope** partials, **leave** deferred/pending-decision ones open. Fail honestly — remove only on evidence, never to tidy the list. Use `ls`/`find`/`git ls-files`/`grep`/`drone @seedgo audit`, not assumptions.
|
||||
|
||||
## 2. Active Plans
|
||||
|
||||
@@ -48,6 +50,7 @@ List everything updated. Format:
|
||||
```
|
||||
Prep complete:
|
||||
- local.json: [what was added]
|
||||
- Todos: [reconciled vs reality — N done & removed, M re-scoped, K still open]
|
||||
- observations.json: [updated / skipped]
|
||||
- Plans: [which ones updated]
|
||||
- Git: [branch, uncommitted count, suggestion]
|
||||
|
||||
@@ -17,7 +17,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
- uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
|
||||
with:
|
||||
python-version: "3.13"
|
||||
- run: pip install ruff
|
||||
@@ -32,7 +32,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
- uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
- run: |
|
||||
@@ -53,7 +53,7 @@ jobs:
|
||||
# makes every file look born at HEAD, so every README false-fails as
|
||||
# "stale". Full history makes CI match a local audit exactly.
|
||||
fetch-depth: 0
|
||||
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
- uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
|
||||
with:
|
||||
python-version: "3.13"
|
||||
- run: |
|
||||
@@ -75,7 +75,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
- uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
|
||||
with:
|
||||
python-version: "3.13"
|
||||
- run: |
|
||||
|
||||
@@ -42,7 +42,7 @@ jobs:
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
|
||||
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
- uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
|
||||
with:
|
||||
python-version: '3.12'
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
- uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
|
||||
with:
|
||||
python-version: "3.13"
|
||||
- run: pip install build
|
||||
|
||||
@@ -19,7 +19,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
- uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
|
||||
with:
|
||||
python-version: "3.13"
|
||||
# Upgrade pip first: pip-audit scans the whole environment, and the
|
||||
|
||||
@@ -20,7 +20,7 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
|
||||
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
- uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
|
||||
with:
|
||||
python-version: '3.12'
|
||||
|
||||
|
||||
+23
-23
@@ -87,29 +87,29 @@ src/aipass/*/apps/integrations/**
|
||||
!src/aipass/*/apps/integrations/README.md
|
||||
|
||||
# Spawn template exceptions (template files must be tracked for public repo)
|
||||
!src/aipass/spawn/templates/builder/.trinity/
|
||||
!src/aipass/spawn/templates/builder/.trinity/**
|
||||
!src/aipass/spawn/templates/builder/.ai_mail.local/
|
||||
!src/aipass/spawn/templates/builder/.ai_mail.local/**
|
||||
!src/aipass/spawn/templates/builder/.archive/
|
||||
!src/aipass/spawn/templates/builder/.archive/**
|
||||
!src/aipass/spawn/templates/builder/.spawn/
|
||||
!src/aipass/spawn/templates/builder/.spawn/**
|
||||
!src/aipass/spawn/templates/builder/.claude/
|
||||
!src/aipass/spawn/templates/builder/.claude/**
|
||||
!src/aipass/spawn/templates/builder/*_json/
|
||||
!src/aipass/spawn/templates/builder/*_json/**
|
||||
!src/aipass/spawn/templates/builder/logs/
|
||||
!src/aipass/spawn/templates/builder/logs/**
|
||||
!src/aipass/spawn/templates/builder/artifacts/
|
||||
!src/aipass/spawn/templates/builder/artifacts/**
|
||||
!src/aipass/spawn/templates/builder/dropbox/
|
||||
!src/aipass/spawn/templates/builder/dropbox/**
|
||||
!src/aipass/spawn/templates/builder/tools/
|
||||
!src/aipass/spawn/templates/builder/tools/**
|
||||
!src/aipass/spawn/templates/builder/docs.local/
|
||||
!src/aipass/spawn/templates/builder/docs.local/**
|
||||
!src/aipass/spawn/templates/builder/DASHBOARD.local.json
|
||||
!src/aipass/spawn/templates/aipass_framework/.trinity/
|
||||
!src/aipass/spawn/templates/aipass_framework/.trinity/**
|
||||
!src/aipass/spawn/templates/aipass_framework/.ai_mail.local/
|
||||
!src/aipass/spawn/templates/aipass_framework/.ai_mail.local/**
|
||||
!src/aipass/spawn/templates/aipass_framework/.archive/
|
||||
!src/aipass/spawn/templates/aipass_framework/.archive/**
|
||||
!src/aipass/spawn/templates/aipass_framework/.spawn/
|
||||
!src/aipass/spawn/templates/aipass_framework/.spawn/**
|
||||
!src/aipass/spawn/templates/aipass_framework/.claude/
|
||||
!src/aipass/spawn/templates/aipass_framework/.claude/**
|
||||
!src/aipass/spawn/templates/aipass_framework/*_json/
|
||||
!src/aipass/spawn/templates/aipass_framework/*_json/**
|
||||
!src/aipass/spawn/templates/aipass_framework/logs/
|
||||
!src/aipass/spawn/templates/aipass_framework/logs/**
|
||||
!src/aipass/spawn/templates/aipass_framework/artifacts/
|
||||
!src/aipass/spawn/templates/aipass_framework/artifacts/**
|
||||
!src/aipass/spawn/templates/aipass_framework/dropbox/
|
||||
!src/aipass/spawn/templates/aipass_framework/dropbox/**
|
||||
!src/aipass/spawn/templates/aipass_framework/tools/
|
||||
!src/aipass/spawn/templates/aipass_framework/tools/**
|
||||
!src/aipass/spawn/templates/aipass_framework/docs.local/
|
||||
!src/aipass/spawn/templates/aipass_framework/docs.local/**
|
||||
!src/aipass/spawn/templates/aipass_framework/DASHBOARD.local.json
|
||||
|
||||
# Commons artifacts subsystem — real source code (craft/trade/capsule), NOT a
|
||||
# runtime dir. Collides with the blanket `artifacts/` ignore (line 49); *.py-only
|
||||
|
||||
+445
@@ -9,6 +9,451 @@ PyPI version — not the changelog header.
|
||||
|
||||
---
|
||||
|
||||
## [2026-07-02]
|
||||
|
||||
Released as **2.6.1**. Rolls up the DPLAN-0226 / FPLAN-0289 / TDPLAN-0010 /
|
||||
FPLAN-0298 batch (unified Telegram↔Claude Code bridge, single-session presence
|
||||
gate, live Telegram streaming, `aipass init` template selector + portability,
|
||||
`@backup share`) — all documented under `[2026-07-01]` — plus the CI
|
||||
stabilization below.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **CI green — six regressions from the DPLAN-0226 / FPLAN-0289 / TDPLAN-0010
|
||||
batch (PR #646).** The dev branch had gone red across `seedgo-audit`, the
|
||||
`test` matrix, and Windows; root-caused and fixed at source:
|
||||
- **seedgo** — the new `template_check` advisory checker was gating CI.
|
||||
`branch_audit.py` averaged *all* checker scores into the branch total, so
|
||||
`template_check`'s `ADVISORY=True` was never honored and it dragged 7
|
||||
branches below the 100% floor on legitimate README brace-examples. Added a
|
||||
`gating_scores` filter that excludes `ADVISORY is True` checkers before
|
||||
computing the average (strict `is True` to avoid MagicMock false-positives)
|
||||
and exposed `advisory_standards` in the audit output. Also refreshed the
|
||||
provider hooks snapshot fixture to include the `presence_gate`
|
||||
`UserPromptSubmit` hook (FPLAN-0289), fixing 4 `test_hooks_snapshot` tests.
|
||||
- **hooks** — `cc_sessions.py` (added by the bridge, `f6cbe34`) was missing
|
||||
its README entry and a seedgo `modules` bypass (it reads external
|
||||
`~/.claude/sessions/*.json`, not branch data, so `json_handler` is the wrong
|
||||
tool — same precedent as `presence.py`). Added both.
|
||||
- **spawn** — retired the `passport(disabled).py` / `passport_ops(disabled).py`
|
||||
pair to `.archive/`; the `(disabled)` suffix kept them visible to the type
|
||||
checker, which flagged a broken cross-import between them.
|
||||
- **ai_mail** — `test_child_inherits_broker_fd` gave its throwaway test branch
|
||||
a real `.trinity/passport.json` so the broker's new `.trinity`-marker
|
||||
resolution (`f914ab6`) can resolve it and permit the delete.
|
||||
- **spawn** — the `builder→aipass_framework` template rename (`13463c0`) left
|
||||
`.gitignore` exceptions pointing at the old `templates/builder/` path, so
|
||||
`DASHBOARD.local.json` + ~10 other template files were silently untracked
|
||||
since the rename — present on disk (dirty tree passed) but absent from clean
|
||||
clones/CI, so `test_full_spawn` failed only in a clean checkout. Fixed all 23
|
||||
`.gitignore` exception paths and committed the now-visible template
|
||||
scaffolding.
|
||||
- **skills** — `test_streaming` asserted a `+1` newline byte, but `write_text`
|
||||
text mode translates `\n`→`\r\n` on Windows (2 bytes), failing `windows-setup`
|
||||
only. Switched the test's transcript writes to `write_bytes()` for
|
||||
deterministic LF; production `_tail_transcript_bytes` was already CRLF-safe.
|
||||
|
||||
## [2026-07-01]
|
||||
|
||||
### Added
|
||||
|
||||
- **`aipass init` is now a template selector (TDPLAN-0010)** — `init` presents a
|
||||
chooser with **`empty project`** at the top, pre-selected as the default
|
||||
(creates just the project folder, no scaffold), and **`aipass_framework`**
|
||||
below it (the full AIPass agent framework — the old always-on behavior, now
|
||||
opt-in). Flag and positional forms both work: `aipass init --list` (branches
|
||||
before the `--` catch-all) and `aipass init <template>`. The AIPass-specific
|
||||
stages (8 spawn-first-agent / 9 ping-registry / 11 handoff / 12 init_report,
|
||||
`AIPASS_SPECIFIC_STAGES`) and the `bootstrap.init_project()` scaffold are now
|
||||
gated on the chosen template, so an empty project stays empty. In-product pip
|
||||
hints in `init_flow.py` + `doctor.py` retuned to clone/`setup.sh`. 8 new
|
||||
selector tests; 499 tests pass. (built by @aipass, FPLAN-0295, TDPLAN-0010)
|
||||
|
||||
- **Unified Telegram ↔ Claude Code bridge — CC-native session discovery
|
||||
(DPLAN-0226)** — a Telegram message to a branch's bot now lands directly in
|
||||
that branch's live Claude Code session, and the reply tails back out to
|
||||
Telegram — a full round trip, **live-proven end-to-end from Patrick's own
|
||||
Telegram client** (not just a self-test). The bot's inbound path
|
||||
(`base_bot.ensure_tmux_session`) discovers the active session by enumerating
|
||||
CC-native `~/.claude/sessions/<pid>.json` files (match `cwd`, confirm PID
|
||||
alive, newest by `startedAt`), maps it to a tmux pane by cwd, and injects the
|
||||
message — replacing the old `PRESENCE.central.json` pointer, which is kept but
|
||||
commented out. The outbound path gains a CC-native "Strategy 0" in
|
||||
`_resolve_active_transcript` that prefers the discovered transcript, so
|
||||
assistant replies relay back reliably. Anthropic ToS rules out a cloud peer,
|
||||
so all delivery is local (tmux/PTY). New `session_boot.py` boot wrapper
|
||||
(attach-if-live-else-start-in-tmux; a thin `~/.bashrc claude()` shim delegates
|
||||
to it). Hooks tests 66 green (presence_gate / cc_sessions / session_boot),
|
||||
telegram presence_pointer 42 green. (DPLAN-0226 P1/P2, FPLAN-0290/0291/0292)
|
||||
|
||||
- **Seedgo stale-template audit checker (`template_check`)** — a new advisory
|
||||
standard that flags branches still carrying unrendered template markers in
|
||||
their local prompts / config, so a citizen that never customized its scaffold
|
||||
no longer fails silently. Auto-discovered like every other checker; advisory
|
||||
(warns, never blocks). Ships with `template_content.py` and a `template.md`
|
||||
standard doc, covered by `test_template_check.py`. (built by @seedgo, DPLAN-0228)
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Drone `--json` output no longer corrupts machine JSON** — `--json`
|
||||
pass-through was routed through Rich's `console.print()`, which defaults to
|
||||
width 80 on a non-TTY and hard-wraps mid-string, producing invalid JSON
|
||||
(e.g. `"Security \nScan"`). Fixed by writing raw JSON with `sys.stdout.write()`
|
||||
in the pass-through paths (`drone.py` + `router.py`) while keeping Rich for
|
||||
drone's own human UI. Verified live end-to-end. (fixed by @drone, td-49)
|
||||
|
||||
### Changed
|
||||
|
||||
- **README: pip removed, clone-only install (TDPLAN-0010)** — the top-level
|
||||
README no longer documents `pip install aipass` anywhere: the PyPI badge, the
|
||||
install steps (hero + Quick Start), the Project Status version badge, and the
|
||||
uninstall `pip uninstall` line are all removed. Install is now a single path —
|
||||
`git clone … && ./setup.sh` (puts `aipass` + `drone` on PATH), then
|
||||
`aipass init` scaffolds agents into your own project on top. Quick Start
|
||||
reorganized into Install → Your own project → Explore the full framework.
|
||||
(packaging code untouched; docs are clone-first.) (DPLAN-0228, devpulse)
|
||||
|
||||
- **Spawn: `builder` template → `aipass_framework`, birthright retired,
|
||||
per-project registry targeting (TDPLAN-0010)** — the citizen_class/template
|
||||
`builder` is renamed to **`aipass_framework`** across `class_registry.py`,
|
||||
`core.py`, `meta_ops.py`, `update_ops.py`, `sync_registry_ops.py`, help text,
|
||||
and the template dir itself (`templates/builder/` → `templates/aipass_framework/`).
|
||||
The class is no longer baked as a literal in the template passport — a new
|
||||
**`{{CITIZEN_CLASS}}` placeholder** (passport line 21, `placeholders.py`) now
|
||||
takes it from the create call. **`birthright`** (0 live users) is retired to
|
||||
`templates/.archive/birthright/` and its `passport` command disabled
|
||||
(`passport.py` / `passport_ops.py` → `(disabled).py`, routing removed).
|
||||
**Per-project registry targeting:** `spawn`'s `find_registry()` no longer
|
||||
passes `package_root` to the shared discovery (killing the silent fallback to
|
||||
AIPass's own registry for external targets), and `_spawn_agent` now validates
|
||||
containment and, if the found registry is outside the target's project, walks
|
||||
up from the target for `.git`/`pyproject.toml`/`setup.py`/`setup.cfg` to use
|
||||
**that project's own registry** — so an agent created into any project is
|
||||
tracked by that project's registry, never AIPass's. The
|
||||
`_validate_path_containment` isolation invariant is untouched. `create` also
|
||||
degrades gracefully when `@memory` is unavailable (empty meta-tabs, no crash).
|
||||
297 tests pass. (built by @spawn, FPLAN-0294, TDPLAN-0010)
|
||||
|
||||
- **Drone resolution + access checks made project-portable (TDPLAN-0010
|
||||
foundation)** — five `src/aipass`/fixed-depth self-location hardcodes are
|
||||
replaced with `.trinity/`-marker walk-ups: `rm_handler` sibling protection,
|
||||
`commit_handler` test-gate branch detection, `broker/daemon` allowed-bases,
|
||||
the `handlers/__init__` import-guard access check (now `is_relative_to()`
|
||||
instead of scanning path parts for the literal `aipass`), and
|
||||
`registry_handler`'s `parents[4]` last-resort (now a
|
||||
`.git`/`pyproject.toml`/`setup.py`/`setup.cfg` marker walk). `@name`→path
|
||||
resolution now works for an agent in any project layout via a CWD-first
|
||||
registry walk (AIPASS_HOME only as a last resort when the CWD ancestry has no
|
||||
registry at all). The `_validate_branch_path` containment invariant is
|
||||
untouched — per-project isolation preserved. (Drone uses its own resolver, not
|
||||
the shared `registry_discovery.py`.) 838 tests pass. (built by @drone,
|
||||
FPLAN-0296, TDPLAN-0010)
|
||||
|
||||
- **ai_mail routing made project-portable (TDPLAN-0010 foundation)** — the
|
||||
fixed-depth `_REPO_ROOT = parents[2].parents[2]` self-location in
|
||||
`email.py` / `email_send.py` / `dispatch.py` (4 sites) is replaced with the
|
||||
portable `find_repo_root()` marker-walk already used in
|
||||
`delivery.py` / `wake.py` / `paths.py`, so mail resolves via the project
|
||||
marker instead of a hardcoded tree depth — a prerequisite for agents that
|
||||
live outside `src/aipass/`. Per-project isolation preserved (no cross-project
|
||||
mailbox routing). 737 tests + seedgo 100%. (built by @ai_mail, FPLAN-0293,
|
||||
TDPLAN-0010)
|
||||
|
||||
- **Presence gate re-sourced to CC-native session files (presence_gate v2)** —
|
||||
the single-session guard now sources truth from `~/.claude/sessions/<pid>.json`
|
||||
via a new `cc_sessions` module (`find_occupant`/`find_live_for_cwd`) instead of
|
||||
`PRESENCE.central.json`. Resume-aware (a `/resume` keeps the same PID, so the
|
||||
session is correctly recognized as re-entry, not a duplicate) and exit-aware
|
||||
(CC deletes the file on clean exit). `handle_stop` is now a plain no-op —
|
||||
cleanup is CC's job. The old `presence.py` / `PRESENCE.central.json` are
|
||||
preserved, just no longer sourced. (DPLAN-0226 P1)
|
||||
|
||||
## [2026-06-25]
|
||||
|
||||
### Added
|
||||
|
||||
- **Daemon auto-runner — systemd user timer (the deferred last mile of the
|
||||
decentralized scheduler)** — `.daemon/schedule.json` jobs now fire **hands-off**.
|
||||
A oneshot `daemon-tick.service` + `daemon-tick.timer` (every ~2 min, mirroring
|
||||
the `prax-monitor.service` pattern: user-scope `~/.config/systemd/user/`, `%h`
|
||||
not hardcoded paths, venv-python ExecStart `-m aipass.daemon.apps.daemon run`,
|
||||
logs to `~/.aipass/daemon-tick.log` outside any tailed dir) reuses the existing
|
||||
fcntl-locked `run.py` tick unchanged — the timer is the ticker. New
|
||||
`apps/modules/timer_install.py` installs/enables it idempotently. Live-proven:
|
||||
@devpulse received a `DAEMON TEST` ping from a branch woken purely by the timer,
|
||||
no human tick. Tick profile: ~1.7s (import overhead only); the earlier CPU spike
|
||||
was `wake_branch` spawning opus agents concurrently, **not** the tick — so
|
||||
scheduled wakes want light models + staggering. Closes the piece DPLAN-0204 /
|
||||
FPLAN-0282 deferred. 461 daemon tests green, seedgo 100%. (FPLAN-0287)
|
||||
|
||||
- **Prax monitor → Telegram relay (`prax_monitor` bot)** — the live
|
||||
`drone @prax monitor run` Mission-Control feed now mirrors to a dedicated
|
||||
Telegram bot, so the whole-system monitor is watchable from a phone ("same
|
||||
monitor, different window"). New `monitoring/telegram_relay.py` taps the single
|
||||
render seam (`_render_event`), buffers events, and flushes every 5s (4000-char
|
||||
split, 150-line flood cap, `disable_notification`); fail-silent-once when
|
||||
unconfigured. Gated behind `--relay` / `AIPASS_PRAX_MONITOR_RELAY=1` so a local
|
||||
`monitor run` stays console-only (no double-send). Bot config (token + chat_id)
|
||||
loads from the @api secret `telegram/prax_monitor`. Ships a reboot-survivable
|
||||
`prax-monitor.service` user unit. 937 prax tests green (31 new). (DPLAN-0221)
|
||||
|
||||
- **Self-documenting `.trinity` state-tabs** — each memory-file section
|
||||
(`todos` / `key_learnings` / `sessions` / `observations`) now carries a
|
||||
config-sourced `⟦ rollover ON/OFF · keep N · ≤chars ⟧` tab rendered directly
|
||||
above it, so an agent editing a section sees its rollover state and character
|
||||
cap at the edit point (stops over-limit writes). Values are generated from
|
||||
`memory.config.json` (single source of truth) via @memory's new
|
||||
`render_all_meta_tabs()` / `tab_renderer.py`; @memory's `spawn_pusher` carries
|
||||
the `{{*_META}}` placeholders into @spawn's branch templates, and @spawn
|
||||
resolves them at create (`build_replacements_dict`, fail-loud on missing keys)
|
||||
so new branches auto-populate. `refresh_all_tabs` keeps live branches synced;
|
||||
@memory README documents the system. (FPLAN-0285, FPLAN-0286)
|
||||
|
||||
### Changed
|
||||
|
||||
- **Todo management — delete-on-done discipline** — `todos[]` are operational
|
||||
and exempt from rollover (confirmed; the vestigial `todos` entry was removed
|
||||
from `memory.config.json` rollover defaults). Because rollover never trims
|
||||
them, finished todos must be **deleted**, not left as `status: done` (which
|
||||
pile up and resurface as "open" across sessions). `/prep` and `/memo` (Claude
|
||||
+ Codex) and the `CLAUDE.md` startup protocol now codify: delete each todo when
|
||||
done (proof → session entry), reconcile on load. (FPLAN-0285)
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Daemonized wakes killed by systemd cgroup teardown (td-48)** — timer-fired
|
||||
`wake_branch()` calls spawned the dispatch monitor + claude child, then died
|
||||
within seconds with no email and a stale lock, while the *same* wake from an
|
||||
interactive terminal worked. Root cause: a systemd oneshot service defaults to
|
||||
`KillMode=control-group`, so when the ~1.7s tick process exits, systemd SIGTERMs
|
||||
**every member of its cgroup** — `start_new_session=True` is irrelevant because
|
||||
systemd tracks by cgroup, not process group. Fix in `ai_mail` dispatch: detect
|
||||
the systemd context (`INVOCATION_ID`) and re-spawn the monitor via
|
||||
`systemd-run --user` in its **own transient unit**, escaping the parent cgroup
|
||||
(falls back to direct `Popen` when not under systemd); plus `stdin=DEVNULL` on
|
||||
both the monitor and claude `Popen` calls and monitor PID self-registration in
|
||||
the lock. Now genuinely live-proven through the timer: 3 branches
|
||||
(commons/cli/backup) woken purely by `daemon-tick.timer` each emailed @devpulse
|
||||
and exited clean (~20s, code=0). 737 ai_mail tests green, seedgo 100%.
|
||||
|
||||
- **seedgo-audit — telegram ported-but-unwired functions** — the DPLAN-0218
|
||||
relocation pulled the telegram lib into the seedgo gate's scope, surfacing 16
|
||||
`unused_function` flags across 8 handler files. These are *not* dead code —
|
||||
they're ported-but-unwired from the ~9k-line Dev-Pass port (S249), awaiting
|
||||
DPLAN-0220 wiring (on_response hooks, response_router, tmux session mgmt, file
|
||||
up/download, multi-bot, config helpers). Added name-scoped `unused_function`
|
||||
bypasses in `skills/.seedgo/bypass.json` (the existing mechanism), each citing
|
||||
DPLAN-0220, and documented every one in `SKILL.md` → *Ported-but-unwired* with
|
||||
a "remove the bypass as you wire each fn" note. @skills back to 100%.
|
||||
|
||||
- **seedgo-audit — @spawn direct JSON read** — `core.py` adopt-path read a
|
||||
passport via `json.loads(path.read_text())` (direct file op), failing the
|
||||
`json_handler` standard and the CI seedgo-audit gate. Switched to
|
||||
`json_handler.read_json()` (the same pattern used a few lines above), dropping
|
||||
the now-unused `import json as _json`. @spawn back to 100%; 315 spawn tests
|
||||
green.
|
||||
|
||||
- **Windows CI — telegram `bot_registry` crashed test collection** — the module
|
||||
did a bare `import fcntl` (POSIX-only), so on Windows all 8 telegram test
|
||||
modules that transitively import it failed at *collection* with
|
||||
`ModuleNotFoundError: No module named 'fcntl'`, reddening Windows Test on the
|
||||
last several PRs. Guarded the import (`try/except ImportError → fcntl = None`,
|
||||
the established hooks/daemon convention) and routed the three flock call-sites
|
||||
through no-op-on-Windows `_lock`/`_unlock` helpers — advisory locking still
|
||||
applies on POSIX, is skipped where unavailable. Fixing collection then
|
||||
*unmasked* three telegram tests that had never actually run on Windows, all
|
||||
test-portability bugs (not product bugs): a log-streamer byte-count broke on
|
||||
CRLF translation (fixture now writes `newline=""`); a registry write-failure
|
||||
test used the Unix-only `/proc` path (now a cross-platform file-as-directory
|
||||
parent); and `validate_bot_config` rejected valid POSIX `work_dir`s on Windows
|
||||
because `Path.is_absolute()` is host-dependent (now tests POSIX *and* Windows
|
||||
absoluteness). 493 telegram tests green.
|
||||
|
||||
- **prax-monitor service feedback loop** — the unit wrote its own stdout into
|
||||
`system_logs/`, the very directory the monitor tails *and* @trigger watches,
|
||||
creating a self-reinforcing loop (monitor output → re-tailed and recorded by
|
||||
@trigger into `trigger_data.json` → reported as a file change → more output).
|
||||
Moved the service log to `~/.aipass/` to break the cycle. Also corrected the
|
||||
ExecStart to `monitor run` (relay enabled via env) — the module `__main__`
|
||||
rejects the drone-style `run all --relay` argument form. (DPLAN-0221)
|
||||
|
||||
## [2026-06-24]
|
||||
|
||||
### Changed
|
||||
|
||||
- **Skill library relocated to `src/aipass/skills/lib/`** — first-party skills
|
||||
were split across `catalog/` (built-in, cross-branch) and `.aipass/skills/`
|
||||
(the branch-prompt dir, cwd-relative). Renamed `catalog/`→`lib/`, moved the
|
||||
telegram skill in, archived three orphan test-fixture skills, and retired
|
||||
`.aipass/skills/` from the branch. This unifies all 6 first-party skills under
|
||||
one built-in tier and **fixes the telegram skill not being discoverable from
|
||||
other branches** (it sat in a cwd-relative path). The public discovery
|
||||
convention (`.aipass/skills/` + `~/.aipass/skills/`) is unchanged. One
|
||||
functional line changed (`discovery_handler` built-in path); telegram's test
|
||||
`conftest` path-depth, the systemd `.service` ExecStart, and seedgo bypass +
|
||||
test paths were updated to match. Packaging, imports, and gitignore are
|
||||
unaffected (everything stays under `src/aipass/`). 252/252 skills tests green;
|
||||
cross-branch discovery verified from another branch. Moving telegram into the
|
||||
gate's scope newly surfaced 9 pre-existing `unused_function` flags in its
|
||||
handlers — triage tracked separately. (DPLAN-0218)
|
||||
|
||||
### Added
|
||||
|
||||
- **`@api` in-process `set_secret` write-door** — `aipass.api.apps.modules.secrets.set_secret(provider, slug, value, *, as_json=False)`
|
||||
mirrors the existing `get_secret`, writing `~/.secrets/aipass/<provider>/<slug>.json`
|
||||
(dirs `0o700`, files `0o600`, value never echoed to stdout or logged). The @api
|
||||
secrets store was previously read-only; this is the writer the telegram
|
||||
mother-bot needs to persist a newly-created bot's config so the child can read
|
||||
its token. 515 @api tests pass (11 new), @api seedgo 100%. (DPLAN-0220)
|
||||
|
||||
- **Prax-monitor v1 on Telegram — `/monitor` system-wide log subscription** —
|
||||
the old Dev-Pass "prax monitor bot" (a `@prax` push relay on a dedicated token)
|
||||
was stripped during the port; this revives the capability as a feature of the
|
||||
existing `@aipass` bot (no second bot, no new credential). New `/monitor on`
|
||||
(errors+warnings) / `all` (firehose) / `off` / `status` command on `base_bot`,
|
||||
shown in the slash menu + `/help`. The subscribed chat is persisted to the `@api`
|
||||
store (`set_secret('telegram','monitor',{chat_id,mode})`) so it survives restart,
|
||||
and `base_bot` boot-starts the stream from it on startup — set-and-forget under
|
||||
systemd. `LogStreamer` gained `system_wide` (glob all `system_logs/*.log`, not one
|
||||
branch) + `level_filter` (default keeps `WARNING`/`ERROR`/`CRITICAL`, `all` =
|
||||
passthrough); `_init_positions` still seeks EOF so subscribing never floods
|
||||
history. 33 new tests (`test_monitor.py`), telegram suite 493/493, skills 252/252,
|
||||
@skills seedgo 98%. (First @skills run crashed mid-edit on 3 string-handling
|
||||
syntax errors; continued + fixed.) The richer AS-WAS `@prax` event-feed relay
|
||||
(rendered Mission-Control stream, needs a dedicated-bot-token decision) is tracked
|
||||
as Route B. (DPLAN-0221)
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Telegram port — wave 1 (persistence + monitor + state hygiene)**, surfaced by
|
||||
a full completeness audit against `TELEGRAM_PORT_MAP.md` (366 tags, ~83% ported,
|
||||
452/452 tests green): (1) **bot launch** — `bot_factory.start_bot_process` and
|
||||
`telegram-bot@.service` used a non-existent `~/.venv/bin/python3`; now launch via
|
||||
`sys.executable -m …base_bot` (added `lib/__init__.py` + `lib/telegram/__init__.py`
|
||||
for package resolution, since base_bot uses relative imports). (2) **reboot
|
||||
survival** — `enable_service` now installs the systemd unit to
|
||||
`~/.config/systemd/user/` + `daemon-reload` (previously the unit was never
|
||||
installed, so `enable` silently no-op'd). (3) **state hygiene** — gitignored
|
||||
`skills/.../lib/telegram/.local/` so the runtime registry/offset/lock files stop
|
||||
leaking into the repo. (4) **prax-monitor** — `log_streamer` tailed a hardcoded
|
||||
`~/system_logs` while prax writes to the repo-root `system_logs`; now resolves the
|
||||
repo root (honoring `AIPASS_TEST_LOG_DIR`) so the log stream actually delivers.
|
||||
(5) **auto-create (GAP1)** — `create_bot` wrote a new bot's config only to a disk
|
||||
shadow file while the runtime loads its token exclusively from the @api store, so
|
||||
a minted bot started then exited with no config; `create_bot` now calls
|
||||
`set_secret('telegram', bot_id, config, as_json=True)` (fail-loud) so the
|
||||
create→@api→load round-trip works and the mother-bot can mint startable bots. New
|
||||
round-trip + fail-loud tests; telegram suite 454/454.
|
||||
(6) **/help + Telegram command menu** — `setMyCommands` only ran inside
|
||||
`create_bot`, so hand-launched bots (like the live `@aipass`) had no slash-menu,
|
||||
and the menu list had drifted from `/help`; `base_bot` now sets its menu on
|
||||
startup from a single source (`build_botfather_commands`, also used by
|
||||
`create_bot` — `DEFAULT_BOT_COMMANDS` retired), so the Telegram menu and `/help`
|
||||
list the same enriched commands incl. `/create`/`/cancel`. Wiring the builder
|
||||
(rather than deleting it as "dead") also lifted Unused_Function 92→93%. 6 new
|
||||
tests, telegram suite 460/460. (A running bot needs a restart to pick up the
|
||||
startup menu.)
|
||||
(DPLAN-0220)
|
||||
|
||||
- **Telegram `@aipass` deployed under systemd (reboot survival + clean lifecycle)** —
|
||||
the live mother-bot was a hand-launched foreground process: no reboot survival, and
|
||||
`stop_bot`/restart targeted an uninstalled `telegram-bot@base` unit, so there was no
|
||||
working lifecycle command. Installed the user service + `enable --now` +
|
||||
`loginctl enable-linger` (`Linger=yes`); the 17:26 startup log confirms the full
|
||||
chain live — `Telegram API OK`, **`Command menu set (6 commands)`** (the new `/help`
|
||||
menu), stale-lock cleanup, poll loop, tmux Claude session preserved, `NRestarts=0`.
|
||||
Also corrected the ported unit's `StandardOutput`/`StandardError`, which pointed at a
|
||||
non-existent `~/system_logs` (would have crash-looped the service) — now
|
||||
`<repo>/system_logs`, matching where the app already logs. Restart is now
|
||||
`systemctl --user restart telegram-bot@base`. (DPLAN-0220)
|
||||
|
||||
- **seedgo CLI help checkers green-lit non-compliant `--help` output** — the
|
||||
`cli`/`help_text`/`introspection` standards are static source scans (they
|
||||
confirm a `print_help` function, `console.print`, and `--help` wiring exist)
|
||||
but never execute `--help`, so a module could score 100% while rendering raw
|
||||
argparse. `@ai_mail` did exactly that via `console.print(parser.format_help())`,
|
||||
laundering argparse's plain text through the approved console API and dodging
|
||||
the existing `parser.print_help()` ban. Closed the loophole: `cli_check` now
|
||||
flags `.format_help()`, `cli.md`/`cli_content.py` name it alongside
|
||||
`print_help()`, +2 regression tests. Also rewrote `@ai_mail`'s `print_help()`
|
||||
to render hand-rolled Rich (the `--help` content was complete, just unstyled).
|
||||
A behavioral `--help` check (run it, assert not raw argparse) is noted as a
|
||||
follow-up. (DPLAN-0217) On its first CI run the tightened checker immediately
|
||||
surfaced the same pattern in 4 `@api` modules (`api_key`, `usage_tracker`,
|
||||
`google_client`, `openrouter_client`) — migrated to Rich, `@api` back to 100%.
|
||||
- **seedgo `readme_check` ignored the `(disabled)` marker in self-scans** — its
|
||||
module-list and test-count scans now skip `foo(disabled).py`, matching the
|
||||
central audit collector. An in-place disabled module no longer trips a false
|
||||
"missing module" violation; disabled test files no longer inflate README test
|
||||
counts (td-103).
|
||||
- **seedgo `unused_function` bypasses are now name-scoped** — bypasses match by
|
||||
function name (`functions: [...]`) instead of line number (`lines: [...]`),
|
||||
which drifted silently when code shifted and re-flagged exempted functions
|
||||
(bit us S216/S217). `lines` stays supported for other standards. Migrated the
|
||||
10 existing line-scoped entries across drone/memory/skills and dropped 3 dead
|
||||
entries already pointing past EOF (td-009).
|
||||
- **Dispatch footer no longer tells workers to close the orchestrator's plan** —
|
||||
the standard email footer's checklist item read `CLOSE FPLAN → drone @flow
|
||||
close <plan_id>`, which led dispatched agents to close the master/parent plan
|
||||
referenced in their brief (bit us in FPLAN-0260). Reworded to `CLOSE YOUR PLAN
|
||||
→ ... this task's plan only, never the master/parent` — a worker still closes
|
||||
the sub-plan handed to it, but the master stays the orchestrator's to close on
|
||||
completion (td-6).
|
||||
|
||||
### Changed
|
||||
|
||||
- **Backup `.backupignore` default moved out of code into a template file** — the
|
||||
seed content backup writes into a new project's `.backupignore` now lives in
|
||||
`backup/templates/backupignore.template` (loaded at register), matching the
|
||||
AIPass convention that templates are data files, not hardcoded Python. Retired
|
||||
the `BUILTIN_IGNORES` list; `_build_backupignore()` reads the template and
|
||||
**raises** if it's missing — never silently empty, since an empty
|
||||
`.backupignore` would back up everything and crash. Docs/comments repointed to
|
||||
the template (td-30).
|
||||
|
||||
### Removed
|
||||
|
||||
- **Dead `bulletin_created` trigger handler** — the event handler that wrote a
|
||||
`bulletin_board` section into every branch dashboard is retired: nothing fired
|
||||
the event, its `BULLETINS.central.json` store no longer exists, and prax
|
||||
already prunes `bulletin_board` as a deprecated section. Archived + unwired
|
||||
from the event registry; prax's pruning stays (td-102).
|
||||
- **Dead `backup/run/` test dir** — leftover from an ad-hoc backup test run
|
||||
(only its generated `.backupignore` had been tracked); removed (td-218).
|
||||
|
||||
### Documentation
|
||||
|
||||
- **Backup docs corrected** — `.backup/` is now documented as a **shared runtime
|
||||
namespace** (@backup stores + @memory rollover safety copies + @flow plan
|
||||
archive), not @backup-exclusive. @backup's README gained full command coverage,
|
||||
the `.backup/` store layout, and a `.backupignore` ("gitignore for backups")
|
||||
section; its branch prompt's stale `.backup_system/` / `drive_test.py` names
|
||||
were fixed. Root README lists @backup and documents `.backupignore`; the navmap
|
||||
was corrected. The shipped root `/.backupignore` was realigned to
|
||||
`BUILTIN_IGNORES` (dropped stale `.backup_system/` + over-broad `*logs`).
|
||||
@memory and @flow READMEs now cross-reference their `.backup/` writes, and the
|
||||
orphaned `prax/.backupignore` (a stale per-branch config) was removed.
|
||||
- **Root README agent roster brought current** — added the three missing agents
|
||||
(`@daemon`, `@skills`, `@commons`) to the tree and tables, and normalized the
|
||||
agent count to **17** everywhere (was an inconsistent mix of "13" and "14").
|
||||
`@daemon` joins Quality & operations; a new "Capabilities and community" group
|
||||
covers `@skills` + `@commons` (td-28).
|
||||
- **`/prep` now reconciles todos against reality** — the session-wrap command
|
||||
(both the Claude `.claude/commands/prep.md` and the Codex skill mirror) gained
|
||||
a step to audit every open todo against the actual system (`ls`/`find`/`git
|
||||
ls-files`/`grep`/`audit`) and close what's verifiably done — catching todos
|
||||
finished in a past session but never closed.
|
||||
- **Backup ignore architecture documented** — confirmed and written down the
|
||||
two-layer model so it stops getting re-discovered: `BUILTIN_IGNORES` is the
|
||||
**seed** that generates a new project's `.backupignore` at register and is
|
||||
never consulted at backup time; `.backupignore` (via `load_spec`) is the
|
||||
**runtime source of truth**. There's no static fallback, so the seed is
|
||||
safety-critical — an empty `.backupignore` backs up everything and can crash
|
||||
the machine. Added a "How Ignores Work" README section + code comments on
|
||||
`BUILTIN_IGNORES` and `load_spec`. Also added `logs/` to the seed so new
|
||||
projects exclude log directories (e.g. prax `.jsonl` output) by default, not
|
||||
just `*.log` files (td-27).
|
||||
|
||||
## [2026-06-23]
|
||||
|
||||
The **2.6.0** release — a large `dev → main` merge spanning several weeks (68 commits).
|
||||
|
||||
@@ -18,4 +18,6 @@ Use drone commands for all operations. Never raw git, gh, file access, or python
|
||||
|
||||
# Memories
|
||||
|
||||
Update `.trinity/` at natural breakpoints, after milestones, and on `/memo`.
|
||||
Update `.trinity/` at natural breakpoints, after milestones, and on `/memo`.
|
||||
|
||||
Todos[] don't auto-roll — rollover never trims them. So **delete each todo the moment it's done** (never leave it as `status: done`), and **reconcile on load**: close/remove anything already finished so completed work never resurfaces as "open" and wastes a re-confirm.
|
||||
@@ -1,7 +1,6 @@
|
||||
[](#project-status)
|
||||
[](pyproject.toml)
|
||||
[](LICENSE)
|
||||
[](https://pypi.org/project/aipass/)
|
||||
[](https://github.com/AIOSAI/AIPass/issues/new?template=feedback.yml)
|
||||
[](https://codecov.io/gh/AIOSAI/AIPass)
|
||||
[](https://scorecard.dev/viewer/?uri=github.com/AIOSAI/AIPass)
|
||||
@@ -31,8 +30,10 @@ That's not a team. That's a room full of people wearing headphones.
|
||||
AIPass is a CLI-native scaffold that adds **persistent memory, identity, and coordination** to your AI agents. You bring your project — AIPass adds the agent layer on top. No UI, no dashboard. You work in your terminal.
|
||||
|
||||
```bash
|
||||
pip install aipass
|
||||
mkdir my-project && cd my-project
|
||||
git clone https://github.com/AIOSAI/AIPass.git
|
||||
cd AIPass && ./setup.sh # installs the `aipass` + `drone` commands on your PATH
|
||||
|
||||
cd ~ && mkdir my-project && cd my-project
|
||||
aipass init run
|
||||
```
|
||||
|
||||
@@ -78,12 +79,17 @@ aipass init agent my-agent # Full agent: apps, mail, memory, identity
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Your own project
|
||||
### 1. Install
|
||||
|
||||
```bash
|
||||
pip install aipass
|
||||
git clone https://github.com/AIOSAI/AIPass.git
|
||||
cd AIPass && ./setup.sh # Creates venv, installs, puts `aipass` + `drone` on your PATH, bootstraps 17 agents
|
||||
```
|
||||
|
||||
mkdir my-project && cd my-project
|
||||
### 2. Your own project
|
||||
|
||||
```bash
|
||||
cd ~ && mkdir my-project && cd my-project
|
||||
aipass init run # Guided setup — project, first agent, terminal handoff
|
||||
```
|
||||
|
||||
@@ -97,15 +103,11 @@ aipass doctor # Check system health
|
||||
|
||||
> **Need help?** [Ask in Discussions](https://github.com/AIOSAI/AIPass/discussions) or [file feedback](https://github.com/AIOSAI/AIPass/issues/new?template=feedback.yml) — both take 30 seconds.
|
||||
|
||||
### Explore the full framework
|
||||
### 3. Explore the full framework
|
||||
|
||||
Clone the repo to see all 13 agents working together — the reference implementation:
|
||||
The clone above already includes all 17 agents working together — the reference implementation:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/AIOSAI/AIPass.git
|
||||
cd AIPass
|
||||
./setup.sh # Creates venv, installs, bootstraps 13 agents
|
||||
|
||||
cd src/aipass/devpulse
|
||||
claude # Talk to the orchestrator
|
||||
```
|
||||
@@ -149,13 +151,13 @@ drone @ai_mail dispatch @agent "Archive old sessions" "Find sessions older than
|
||||
**Two ways to use AIPass:**
|
||||
|
||||
- **Your own project:** `aipass init run` sets up a new project with your first agent. Add more agents as you need them. Your first agent is the orchestrator — it coordinates the others.
|
||||
- **The full framework:** Clone the repo to work with all 13 core agents. Talk to `devpulse` (the orchestrator), dispatch work across specialists. Agents work in parallel and report back.
|
||||
- **The full framework:** Clone the repo to work with all 17 core agents. Talk to `devpulse` (the orchestrator), dispatch work across specialists. Agents work in parallel and report back.
|
||||
|
||||
---
|
||||
|
||||
## The Reference Implementation
|
||||
|
||||
AIPass ships with 13 core agents that maintain and develop the framework itself — proving the architecture works at scale. You don't need any of these to use AIPass in your own project. They're here as examples and as services your project can call.
|
||||
AIPass ships with 17 core agents that maintain and develop the framework itself — proving the architecture works at scale. You don't need any of these to use AIPass in your own project. They're here as examples and as services your project can call.
|
||||
|
||||
```
|
||||
devpulse (orchestrator)
|
||||
@@ -170,7 +172,11 @@ devpulse (orchestrator)
|
||||
├── memory — automatic archival, ChromaDB, semantic search
|
||||
├── api — LLM access layer (OpenRouter, multi-provider)
|
||||
├── trigger — event-driven automation + self-healing
|
||||
└── cli — terminal formatting and rich output
|
||||
├── cli — terminal formatting and rich output
|
||||
├── backup — local-first snapshots + restore (optional Drive sync)
|
||||
├── daemon — cron-style task scheduler (each branch owns its schedule)
|
||||
├── skills — discoverable capability units any agent can run
|
||||
└── commons — the social space — post, comment, vote, gather
|
||||
```
|
||||
|
||||
These agents work on the **same filesystem, same project, same time** — no sandboxes, no worktrees. This is the pattern your projects inherit.
|
||||
@@ -201,6 +207,15 @@ These agents work on the **same filesystem, same project, same time** — no san
|
||||
| [**hooks**](src/aipass/hooks/README.md) | Hook engine — per-project config, sound control, event dispatch |
|
||||
| [**trigger**](src/aipass/trigger/README.md) | Event-driven automation + self-healing |
|
||||
| [**cli**](src/aipass/cli/README.md) | Terminal formatting and rich output |
|
||||
| [**backup**](src/aipass/backup/README.md) | Local-first backups — snapshots, versioning, restore (optional Google Drive sync) |
|
||||
| [**daemon**](src/aipass/daemon/README.md) | Task scheduler — cron-style firing; each branch owns its schedule |
|
||||
|
||||
**Capabilities and community** — what agents can do and where they gather:
|
||||
|
||||
| Agent | Role |
|
||||
|-------|------|
|
||||
| [**skills**](src/aipass/skills/README.md) | Capability framework — discoverable, self-contained skill units any agent can run |
|
||||
| [**commons**](src/aipass/commons/README.md) | The social space — agents post, comment, vote, and gather as a community |
|
||||
|
||||
</details>
|
||||
|
||||
@@ -225,7 +240,7 @@ setup.sh auto-detects which CLIs are installed and configures hooks for each.
|
||||
|
||||
| Metric | Value |
|
||||
|--------|-------|
|
||||
| Version | [](https://pypi.org/project/aipass/) |
|
||||
| Version | See [git tags](https://github.com/AIOSAI/AIPass/tags) |
|
||||
| Agents | 13 core + user-created |
|
||||
| Quality | Automated standards enforced across every agent |
|
||||
| Coverage | [](https://codecov.io/gh/AIOSAI/AIPass) — 75% minimum, CI-gated |
|
||||
@@ -266,8 +281,8 @@ AIPass stores everything locally in your project directory. To remove it:
|
||||
rm -rf .aipass/ .claude/ .ai_mail.local/ hooks/ src/
|
||||
rm -f CLAUDE.md AGENTS.md *_REGISTRY.json .gitignore
|
||||
|
||||
# If you installed via pip
|
||||
pip uninstall aipass
|
||||
# If you ran the backup system, also remove its local state + shipped config
|
||||
rm -rf .backup/ && rm -f .backupignore
|
||||
```
|
||||
|
||||
No cloud accounts, no external services, no cleanup beyond your local filesystem.
|
||||
|
||||
+4
-1
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "aipass"
|
||||
version = "2.6.0"
|
||||
version = "2.6.1"
|
||||
description = "A local multi-agent framework where your AI agents keep their memory, work together, and never ask you to re-explain context"
|
||||
readme = "README.md"
|
||||
license = "MIT"
|
||||
@@ -51,6 +51,9 @@ memory = [
|
||||
"chromadb>=1.0",
|
||||
"fastembed>=0.4",
|
||||
]
|
||||
telegram = [
|
||||
"telethon>=1.36",
|
||||
]
|
||||
seedgo = []
|
||||
dev = [
|
||||
"pytest>=9.0.3",
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
"""AIPass — Multi-agent orchestration framework.
|
||||
|
||||
pip install aipass
|
||||
https://github.com/AIOSAI/AIPass
|
||||
git clone + ./setup.sh — https://github.com/AIOSAI/AIPass
|
||||
"""
|
||||
|
||||
__version__ = "2.6.0"
|
||||
__version__ = "2.6.1"
|
||||
|
||||
@@ -16,7 +16,6 @@ Main handles routing, modules implement functionality.
|
||||
# Standard library imports
|
||||
import sys
|
||||
import importlib
|
||||
import argparse
|
||||
import signal
|
||||
from pathlib import Path
|
||||
from typing import Any, List
|
||||
@@ -51,52 +50,47 @@ MODULES_DIR = MODULE_ROOT / "modules"
|
||||
|
||||
|
||||
def print_help():
|
||||
"""Print drone-compliant help output"""
|
||||
parser = argparse.ArgumentParser(
|
||||
description="AI_MAIL Branch Operations - Email system for branch communication",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog="""
|
||||
COMMANDS:
|
||||
dispatch - Send dispatch email + wake target (one step)
|
||||
email - Send email to a branch
|
||||
send - Send email (alias for email)
|
||||
inbox - List emails (new + opened)
|
||||
view - View email content (marks as opened)
|
||||
reply - Reply to email (closes + archives)
|
||||
close - Close email(s) without reply (archives)
|
||||
sent - View sent messages
|
||||
contacts - Manage contacts
|
||||
EMAIL LIFECYCLE (v2):
|
||||
new → opened → closed
|
||||
- new: Just arrived, never viewed
|
||||
- opened: You've viewed it, not yet resolved
|
||||
- closed: Resolved (replied or dismissed), auto-archived
|
||||
"""Print drone-compliant help output with Rich markup"""
|
||||
console.print()
|
||||
console.print("[bold cyan]AI_MAIL — Email system for branch communication[/bold cyan]")
|
||||
console.print()
|
||||
|
||||
USAGE:
|
||||
drone @ai_mail <command> [args]
|
||||
drone @ai_mail --help
|
||||
console.print("[yellow]COMMANDS:[/yellow]")
|
||||
console.print(" [cyan]dispatch[/cyan] [dim]Send dispatch email + wake target (one step)[/dim]")
|
||||
console.print(" [cyan]email[/cyan] [dim]Send email to a branch[/dim]")
|
||||
console.print(" [cyan]send[/cyan] [dim]Send email (alias for email)[/dim]")
|
||||
console.print(" [cyan]inbox[/cyan] [dim]List emails (new + opened)[/dim]")
|
||||
console.print(" [cyan]view[/cyan] [dim]View email content (marks as opened)[/dim]")
|
||||
console.print(" [cyan]reply[/cyan] [dim]Reply to email (closes + archives)[/dim]")
|
||||
console.print(" [cyan]close[/cyan] [dim]Close email(s) without reply (archives)[/dim]")
|
||||
console.print(" [cyan]sent[/cyan] [dim]View sent messages[/dim]")
|
||||
console.print(" [cyan]contacts[/cyan] [dim]Manage contacts[/dim]")
|
||||
console.print()
|
||||
|
||||
EXAMPLES:
|
||||
# Dispatch (send + wake in one command)
|
||||
drone @ai_mail dispatch @branch "Subject" "Body"
|
||||
drone @ai_mail dispatch @branch "Subject" "Body" --fresh
|
||||
console.print("[yellow]EMAIL LIFECYCLE (v2):[/yellow]")
|
||||
console.print(" new → opened → closed")
|
||||
console.print(" [dim]new: Just arrived, never viewed[/dim]")
|
||||
console.print(" [dim]opened: You've viewed it, not yet resolved[/dim]")
|
||||
console.print(" [dim]closed: Resolved (replied or dismissed), auto-archived[/dim]")
|
||||
console.print()
|
||||
|
||||
# Send mail (no wake)
|
||||
drone @ai_mail email @seedgo "Subject" "Msg" # Send to branch
|
||||
drone @ai_mail email @all "Subject" "Msg" # Broadcast to all
|
||||
console.print("[yellow]USAGE:[/yellow]")
|
||||
console.print(" [cyan]drone @ai_mail[/cyan] <command> [args]")
|
||||
console.print(" [cyan]drone @ai_mail --help[/cyan]")
|
||||
console.print()
|
||||
|
||||
# Check mail
|
||||
drone @ai_mail inbox # List all emails
|
||||
drone @ai_mail view abc123 # View email (marks as opened)
|
||||
|
||||
# Resolve emails
|
||||
drone @ai_mail reply abc123 "Thanks!" # Reply + close + archive
|
||||
drone @ai_mail close abc123 # Close single email
|
||||
drone @ai_mail close abc123 def456 ghi789 # Close multiple emails
|
||||
drone @ai_mail close all # Close ALL emails
|
||||
""",
|
||||
)
|
||||
console.print(parser.format_help())
|
||||
console.print("[yellow]EXAMPLES:[/yellow]")
|
||||
console.print(' [cyan]drone @ai_mail dispatch @branch "Subject" "Body"[/cyan]')
|
||||
console.print(' [cyan]drone @ai_mail dispatch @branch "Subject" "Body" --fresh[/cyan]')
|
||||
console.print(' [cyan]drone @ai_mail email @seedgo "Subject" "Msg"[/cyan] [dim]Send to branch[/dim]')
|
||||
console.print(' [cyan]drone @ai_mail email @all "Subject" "Msg"[/cyan] [dim]Broadcast to all[/dim]')
|
||||
console.print(" [cyan]drone @ai_mail inbox[/cyan] [dim]List all emails[/dim]")
|
||||
console.print(" [cyan]drone @ai_mail view abc123[/cyan] [dim]View email[/dim]")
|
||||
console.print(' [cyan]drone @ai_mail reply abc123 "Thanks!"[/cyan] [dim]Reply + close + archive[/dim]')
|
||||
console.print(" [cyan]drone @ai_mail close abc123[/cyan] [dim]Close single email[/dim]")
|
||||
console.print(" [cyan]drone @ai_mail close abc123 def456 ghi789[/cyan] [dim]Close multiple[/dim]")
|
||||
console.print(" [cyan]drone @ai_mail close all[/cyan] [dim]Close ALL emails[/dim]")
|
||||
console.print()
|
||||
|
||||
|
||||
# =============================================================================
|
||||
|
||||
@@ -245,6 +245,7 @@ def _run_with_startup_check(
|
||||
|
||||
try:
|
||||
popen_kwargs = {
|
||||
"stdin": subprocess.DEVNULL,
|
||||
"stdout": stdout_fh if stdout_fh is not None else subprocess.DEVNULL,
|
||||
"stderr": stderr_fh,
|
||||
"cwd": cwd,
|
||||
@@ -327,6 +328,18 @@ def main():
|
||||
|
||||
json_handler.log_operation("dispatch_monitor_start", {"branch": branch_email, "sender": sender})
|
||||
|
||||
# Self-register PID in lock file — the parent may have written its own
|
||||
# PID during pre-spawn lock acquisition (DPLAN-0155), and under
|
||||
# systemd-run the parent PID belongs to the caller, not the monitor.
|
||||
try:
|
||||
lock_path_obj = Path(lock_file)
|
||||
if lock_path_obj.exists():
|
||||
ld = json.loads(lock_path_obj.read_text(encoding="utf-8"))
|
||||
ld["pid"] = os.getpid()
|
||||
lock_path_obj.write_text(json.dumps(ld, indent=2), encoding="utf-8")
|
||||
except (json.JSONDecodeError, OSError):
|
||||
logger.info("[monitor] Could not self-register PID in lock file %s", lock_file)
|
||||
|
||||
# Open stderr log for claude output (rotate if > 500KB)
|
||||
stderr_fh = None
|
||||
try:
|
||||
|
||||
@@ -290,6 +290,78 @@ def _check_pid_alive(pid: int) -> bool:
|
||||
return True # Exists but can't check — assume alive
|
||||
|
||||
|
||||
def _spawn_in_systemd_scope(monitor_cmd, branch_path, spawn_env, branch_email, lock_file_path, custom_message, status):
|
||||
"""Spawn monitor in its own systemd unit to survive cgroup cleanup (td-48).
|
||||
|
||||
When wake_branch() runs inside a systemd oneshot service (e.g.
|
||||
daemon-tick.timer), the default KillMode=control-group sends SIGTERM to
|
||||
every process in the cgroup once the main process exits — killing the
|
||||
detached monitor and its claude child. systemd-run --user creates a
|
||||
transient service unit with its own cgroup so the monitor survives.
|
||||
|
||||
Returns True on success, False to fall back to direct Popen.
|
||||
"""
|
||||
unit_name = f"dispatch-{branch_email.lstrip('@')}"
|
||||
env_file = branch_path / "logs" / ".dispatch_env"
|
||||
|
||||
try:
|
||||
with open(env_file, "w", encoding="utf-8") as ef:
|
||||
for key, val in spawn_env.items():
|
||||
if "\n" not in str(val):
|
||||
ef.write(f"{key}={val}\n")
|
||||
env_file.chmod(0o600)
|
||||
except OSError as e:
|
||||
logger.warning("[wake] Failed to write env file for systemd-run: %s", e)
|
||||
return False
|
||||
|
||||
systemd_cmd = [
|
||||
"systemd-run",
|
||||
"--user",
|
||||
"--unit",
|
||||
unit_name,
|
||||
"--collect",
|
||||
"--property",
|
||||
f"WorkingDirectory={branch_path}",
|
||||
"--property",
|
||||
f"EnvironmentFile={env_file}",
|
||||
"--property",
|
||||
"StandardInput=null",
|
||||
"--",
|
||||
] + monitor_cmd
|
||||
|
||||
try:
|
||||
result = subprocess.run(systemd_cmd, capture_output=True, text=True, timeout=15)
|
||||
if result.returncode != 0:
|
||||
logger.warning("[wake] systemd-run failed (rc=%d): %s", result.returncode, result.stderr.strip())
|
||||
return False
|
||||
except (subprocess.SubprocessError, OSError) as e:
|
||||
logger.warning("[wake] systemd-run failed: %s", e)
|
||||
return False
|
||||
|
||||
try:
|
||||
pid_result = subprocess.run(
|
||||
["systemctl", "--user", "show", f"{unit_name}.service", "-p", "MainPID", "--value"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5,
|
||||
)
|
||||
monitor_pid = int(pid_result.stdout.strip())
|
||||
if monitor_pid > 0:
|
||||
lock_data = {
|
||||
"pid": monitor_pid,
|
||||
"timestamp": time.strftime("%Y-%m-%dT%H:%M:%S"),
|
||||
"branch": str(branch_path),
|
||||
"subject": custom_message or "daemon wake",
|
||||
}
|
||||
with open(lock_file_path, "w", encoding="utf-8") as f:
|
||||
json.dump(lock_data, f, indent=2)
|
||||
except (subprocess.SubprocessError, ValueError, OSError) as e:
|
||||
logger.info("[wake] Could not query systemd unit PID: %s", e)
|
||||
|
||||
status.ok("spawn", f"Monitor started via systemd scope ({unit_name})")
|
||||
return True
|
||||
|
||||
|
||||
# ─── Branch Resolution ──────────────────────────────────
|
||||
|
||||
|
||||
@@ -487,54 +559,92 @@ def wake_branch(
|
||||
return status, False
|
||||
status.ok("lock-acquire", "Dispatch lock acquired")
|
||||
|
||||
try:
|
||||
process = subprocess.Popen(
|
||||
# When inside a systemd oneshot service (e.g. daemon-tick.timer), the
|
||||
# default KillMode=control-group sends SIGTERM to all cgroup members
|
||||
# when the service exits — killing the detached monitor. Escape by
|
||||
# launching the monitor in its own transient systemd unit (td-48).
|
||||
spawned_via_scope = False
|
||||
monitor_pid = 0
|
||||
if os.environ.get("INVOCATION_ID") and shutil.which("systemd-run"):
|
||||
spawned_via_scope = _spawn_in_systemd_scope(
|
||||
monitor_cmd,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
start_new_session=True,
|
||||
cwd=str(branch_path),
|
||||
env=spawn_env,
|
||||
branch_path,
|
||||
spawn_env,
|
||||
email,
|
||||
lock_file_path,
|
||||
custom_message,
|
||||
status,
|
||||
)
|
||||
|
||||
monitor_pid = process.pid
|
||||
if not spawned_via_scope:
|
||||
try:
|
||||
process = subprocess.Popen(
|
||||
monitor_cmd,
|
||||
stdin=subprocess.DEVNULL,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
start_new_session=True,
|
||||
cwd=str(branch_path),
|
||||
env=spawn_env,
|
||||
)
|
||||
|
||||
# Update lock with real monitor PID
|
||||
lock_file = branch_path / ".ai_mail.local" / ".dispatch.lock"
|
||||
lock_data = {
|
||||
"pid": monitor_pid,
|
||||
"timestamp": time.strftime("%Y-%m-%dT%H:%M:%S"),
|
||||
"branch": str(branch_path),
|
||||
"subject": custom_message or "manual wake",
|
||||
}
|
||||
with open(lock_file, "w", encoding="utf-8") as f:
|
||||
json.dump(lock_data, f, indent=2)
|
||||
monitor_pid = process.pid
|
||||
|
||||
status.ok("spawn", f"Monitor started (PID {monitor_pid})")
|
||||
# Update lock with real monitor PID
|
||||
lock_file = branch_path / ".ai_mail.local" / ".dispatch.lock"
|
||||
lock_data = {
|
||||
"pid": monitor_pid,
|
||||
"timestamp": time.strftime("%Y-%m-%dT%H:%M:%S"),
|
||||
"branch": str(branch_path),
|
||||
"subject": custom_message or "manual wake",
|
||||
}
|
||||
with open(lock_file, "w", encoding="utf-8") as f:
|
||||
json.dump(lock_data, f, indent=2)
|
||||
|
||||
except FileNotFoundError as e:
|
||||
lock_file = branch_path / ".ai_mail.local" / ".dispatch.lock"
|
||||
lock_file.unlink(missing_ok=True)
|
||||
logger.warning("[wake] Spawn failed — script not found: %s", e)
|
||||
status.fail("spawn", "Python or monitor script not found")
|
||||
return status, False
|
||||
except Exception as e:
|
||||
lock_file = branch_path / ".ai_mail.local" / ".dispatch.lock"
|
||||
lock_file.unlink(missing_ok=True)
|
||||
logger.warning("[wake] Spawn failed for %s: %s", branch_email, e)
|
||||
status.fail("spawn", f"{type(e).__name__}: {e}")
|
||||
return status, False
|
||||
status.ok("spawn", f"Monitor started (PID {monitor_pid})")
|
||||
|
||||
except FileNotFoundError as e:
|
||||
lock_file = branch_path / ".ai_mail.local" / ".dispatch.lock"
|
||||
lock_file.unlink(missing_ok=True)
|
||||
logger.warning("[wake] Spawn failed — script not found: %s", e)
|
||||
status.fail("spawn", "Python or monitor script not found")
|
||||
return status, False
|
||||
except Exception as e:
|
||||
lock_file = branch_path / ".ai_mail.local" / ".dispatch.lock"
|
||||
lock_file.unlink(missing_ok=True)
|
||||
logger.warning("[wake] Spawn failed for %s: %s", branch_email, e)
|
||||
status.fail("spawn", f"{type(e).__name__}: {e}")
|
||||
return status, False
|
||||
|
||||
# Step 9: Liveness check (brief wait then verify)
|
||||
time.sleep(2)
|
||||
if _check_pid_alive(monitor_pid):
|
||||
status.ok("alive", f"Agent responding (PID {monitor_pid} alive)")
|
||||
if spawned_via_scope:
|
||||
_unit = f"dispatch-{email.lstrip('@')}"
|
||||
try:
|
||||
check = subprocess.run(
|
||||
["systemctl", "--user", "is-active", f"{_unit}.service"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5,
|
||||
)
|
||||
if check.stdout.strip() == "active":
|
||||
status.ok("alive", f"Agent responding (unit {_unit} active)")
|
||||
else:
|
||||
status.fail("alive", f"Agent died immediately ({check.stdout.strip()})")
|
||||
lock_file = branch_path / ".ai_mail.local" / ".dispatch.lock"
|
||||
lock_file.unlink(missing_ok=True)
|
||||
return status, False
|
||||
except Exception as e:
|
||||
logger.info("[wake] Cannot verify systemd unit %s: %s", _unit, e)
|
||||
status.warn("alive", "Cannot verify systemd unit status — assuming running")
|
||||
else:
|
||||
status.fail("alive", f"Agent died immediately (PID {monitor_pid})")
|
||||
# Clean up lock
|
||||
lock_file = branch_path / ".ai_mail.local" / ".dispatch.lock"
|
||||
lock_file.unlink(missing_ok=True)
|
||||
return status, False
|
||||
if _check_pid_alive(monitor_pid):
|
||||
status.ok("alive", f"Agent responding (PID {monitor_pid} alive)")
|
||||
else:
|
||||
status.fail("alive", f"Agent died immediately (PID {monitor_pid})")
|
||||
lock_file = branch_path / ".ai_mail.local" / ".dispatch.lock"
|
||||
lock_file.unlink(missing_ok=True)
|
||||
return status, False
|
||||
|
||||
# Desktop notification
|
||||
notif_body = custom_message[:80] if custom_message else "Manual wake: check inbox"
|
||||
|
||||
@@ -22,7 +22,7 @@ STANDARD_FOOTER = """
|
||||
⚠️ TASK CHECKLIST (before marking complete):
|
||||
□ SEEDGO CHECK → drone @seedgo audit @branch (80%+)
|
||||
□ UPDATE MEMORIES → Your .trinity/local.json records this work
|
||||
□ CLOSE FPLAN → drone @flow close <plan_id>
|
||||
□ CLOSE YOUR PLAN → drone @flow close <your_plan_id> — this task's plan only, never the master/parent
|
||||
□ EMAIL SENDER → drone @ai_mail email @<sender> "Subject" "Summary"
|
||||
|
||||
Memories = Presence. No update = No learning.
|
||||
|
||||
@@ -269,6 +269,7 @@ def _orchestrate_dispatch_send(args: List[str]) -> bool:
|
||||
from aipass.ai_mail.apps.handlers.email.error_dispatch import dispatch_send_error, on_email_delivered
|
||||
from aipass.ai_mail.apps.handlers.users.user import get_current_user
|
||||
from aipass.ai_mail.apps.handlers.registry.read import get_branch_by_email
|
||||
from aipass.ai_mail.apps.handlers.paths import find_repo_root
|
||||
|
||||
try:
|
||||
from aipass.ai_mail.apps.handlers.central_writer import update_central
|
||||
@@ -277,7 +278,7 @@ def _orchestrate_dispatch_send(args: List[str]) -> bool:
|
||||
update_central = None
|
||||
|
||||
_ai_mail_dir = Path(__file__).resolve().parents[2]
|
||||
_repo_root = _ai_mail_dir.parents[2]
|
||||
_repo_root = find_repo_root()
|
||||
|
||||
def _delivery_callback(branch_path, new_count, opened_count, total):
|
||||
on_email_delivered(
|
||||
@@ -350,14 +351,14 @@ def _orchestrate_dispatch_send(args: List[str]) -> bool:
|
||||
def _spawn_watchdog(target: str) -> None:
|
||||
"""Auto-spawn devpulse watchdog as a detached background process."""
|
||||
from aipass.ai_mail.apps.handlers.registry.read import get_branch_by_email
|
||||
from aipass.ai_mail.apps.handlers.paths import find_repo_root
|
||||
|
||||
devpulse_info = get_branch_by_email("@devpulse")
|
||||
if not devpulse_info:
|
||||
logger.warning("[dispatch] Cannot spawn watchdog — @devpulse not in registry")
|
||||
return
|
||||
|
||||
_ai_mail_dir = Path(__file__).resolve().parents[2]
|
||||
_repo_root = _ai_mail_dir.parents[2]
|
||||
_repo_root = find_repo_root()
|
||||
devpulse_path = devpulse_info.get("path", "")
|
||||
if not devpulse_path:
|
||||
logger.warning("[dispatch] Cannot spawn watchdog — @devpulse has no path")
|
||||
|
||||
@@ -39,10 +39,11 @@ from aipass.ai_mail.apps.handlers.registry.read import get_all_branches, get_bra
|
||||
from aipass.ai_mail.apps.handlers.json import json_handler
|
||||
from aipass.ai_mail.apps.handlers.email.close_ops import batch_close, batch_close_post_ops
|
||||
from aipass.ai_mail.apps.handlers.email.inbox_resolve import resolve_inbox_target
|
||||
from aipass.ai_mail.apps.handlers.paths import find_repo_root
|
||||
from aipass.ai_mail.apps.modules.email_send import handle_send
|
||||
|
||||
_AI_MAIL_DIR = Path(__file__).resolve().parents[2]
|
||||
_REPO_ROOT = _AI_MAIL_DIR.parents[2]
|
||||
_REPO_ROOT = find_repo_root()
|
||||
|
||||
try:
|
||||
from aipass.ai_mail.apps.handlers.central_writer import update_central
|
||||
|
||||
@@ -35,9 +35,10 @@ from aipass.ai_mail.apps.handlers.email.send import (
|
||||
)
|
||||
from aipass.ai_mail.apps.handlers.email.error_dispatch import dispatch_send_error, on_email_delivered
|
||||
from aipass.ai_mail.apps.handlers.email.send_args import parse_send_args, resolve_dispatch_target
|
||||
from aipass.ai_mail.apps.handlers.paths import find_repo_root
|
||||
|
||||
_AI_MAIL_DIR = Path(__file__).resolve().parents[2]
|
||||
_REPO_ROOT = _AI_MAIL_DIR.parents[2]
|
||||
_REPO_ROOT = find_repo_root()
|
||||
|
||||
try:
|
||||
from aipass.ai_mail.apps.handlers.central_writer import update_central
|
||||
|
||||
@@ -1731,10 +1731,13 @@ class TestBrokerRealE2E:
|
||||
from aipass.drone.apps.handlers.broker.daemon import BrokerDaemon
|
||||
from aipass.drone.apps.handlers.broker.client import create_identified_connection
|
||||
|
||||
# Set up repo root with branch dir
|
||||
# Set up repo root with branch dir + .trinity marker (broker marker-walk requires it)
|
||||
repo_root = tmp_path / "repo"
|
||||
branch_dir = repo_root / "src" / "aipass" / "testbranch"
|
||||
branch_dir.mkdir(parents=True)
|
||||
trinity_dir = branch_dir / ".trinity"
|
||||
trinity_dir.mkdir()
|
||||
(trinity_dir / "passport.json").write_text('{"branch_info": {"branch_name": "testbranch"}}', encoding="utf-8")
|
||||
target_file = branch_dir / "deleteme.txt"
|
||||
target_file.write_text("delete me", encoding="utf-8")
|
||||
|
||||
|
||||
@@ -46,7 +46,7 @@ def test_get_footer_contains_checklist():
|
||||
assert "TASK CHECKLIST" in result
|
||||
assert "SEEDGO CHECK" in result
|
||||
assert "UPDATE MEMORIES" in result
|
||||
assert "CLOSE FPLAN" in result
|
||||
assert "CLOSE YOUR PLAN" in result
|
||||
assert "EMAIL SENDER" in result
|
||||
|
||||
|
||||
|
||||
@@ -601,6 +601,7 @@ class TestWakeBranchSpawnEnv:
|
||||
|
||||
local_bin = str(_Path.home() / ".local" / "bin")
|
||||
monkeypatch.setenv("PATH", "/usr/bin:/bin")
|
||||
monkeypatch.delenv("INVOCATION_ID", raising=False)
|
||||
|
||||
captured_envs: list = []
|
||||
|
||||
@@ -831,6 +832,7 @@ def _patch_wake_deps(monkeypatch, **overrides):
|
||||
monkeypatch.setattr(wake_mod, attr, val)
|
||||
|
||||
monkeypatch.setattr("aipass.ai_mail.apps.handlers.dispatch.wake.time.sleep", lambda _: None)
|
||||
monkeypatch.delenv("INVOCATION_ID", raising=False)
|
||||
|
||||
|
||||
class _FakeProc:
|
||||
|
||||
@@ -12,9 +12,9 @@ Not suggestions. Violating = bug.
|
||||
|
||||
- **No writes outside own `.trinity/`.** Never create, edit, delete files anywhere else. Not code, not docs, not configs, not other branches' memories.
|
||||
- **No git. Ever.** Not `git status`, not `drone @git anything`. Git is drone's world.
|
||||
- **No `drone @ai_mail dispatch`.** Email only test-convention body (below). Never wake agent real work.
|
||||
- **Dispatch focused work via `drone @ai_mail dispatch`** — to ONE owning branch, as the user's voice with detailed feedback. Reply routes to @aipass; I track the loop and report back. Not an orchestrator (no fleets, no running the floor — that's devpulse). Test-convention pings (below) still fine.
|
||||
- **No registry / hooks / bypass.json / config edits.** Spot bug → report. Never patch.
|
||||
- User asks build/fix/change something: tell them who. Offer dispatch through devpulse/drone — don't do it.
|
||||
- User asks build/fix/change in another branch: name the owner, then dispatch focused work to them as the user's voice. Heavy orchestration, git, and fleets stay with devpulse.
|
||||
|
||||
## What I Do
|
||||
|
||||
|
||||
@@ -399,11 +399,23 @@ def _check_services(verbose: bool = False) -> List[CheckResult]:
|
||||
results.append(CheckResult("drone", GLYPH_PASS, detail, ""))
|
||||
else:
|
||||
results.append(
|
||||
CheckResult("drone", GLYPH_FAIL, "exit non-zero", "Ensure aipass is installed: pip install -e .")
|
||||
CheckResult(
|
||||
"drone",
|
||||
GLYPH_FAIL,
|
||||
"exit non-zero",
|
||||
"Ensure aipass is installed: clone the repo and run setup.sh",
|
||||
)
|
||||
)
|
||||
except FileNotFoundError as exc:
|
||||
logger.warning("[doctor] drone not found: %s", exc)
|
||||
results.append(CheckResult("drone", GLYPH_FAIL, "not found", "Ensure aipass is installed: pip install -e ."))
|
||||
results.append(
|
||||
CheckResult(
|
||||
"drone",
|
||||
GLYPH_FAIL,
|
||||
"not found",
|
||||
"Ensure aipass is installed: clone the repo and run setup.sh",
|
||||
)
|
||||
)
|
||||
except subprocess.TimeoutExpired as exc:
|
||||
logger.warning("[doctor] drone systems timed out: %s", exc)
|
||||
results.append(CheckResult("drone", GLYPH_WARN, "timed out", ""))
|
||||
|
||||
@@ -92,6 +92,11 @@ CLI_CHOICES = ["claude", "codex", "other"]
|
||||
FLAG_CHOICES = ["default", "skip-permissions"]
|
||||
STYLE_CHOICES = ["building-my-own-project", "improving-aipass", "just-exploring"]
|
||||
|
||||
TEMPLATE_EMPTY = "empty project"
|
||||
TEMPLATE_AIPASS = "aipass_framework"
|
||||
TEMPLATE_CHOICES = [TEMPLATE_EMPTY, TEMPLATE_AIPASS]
|
||||
AIPASS_SPECIFIC_STAGES = {8, 9, 11, 12}
|
||||
|
||||
|
||||
# --- LOCAL JSON HELPERS ---
|
||||
def _read_local_json() -> dict:
|
||||
@@ -597,12 +602,12 @@ def stage_10_smoke_test(non_interactive: bool = False, dry_run: bool = False) ->
|
||||
if drone_bin:
|
||||
console.print(f"[green]✓[/green] drone: {drone_bin}")
|
||||
else:
|
||||
warning("drone not on PATH — run: pip install -e .")
|
||||
warning("drone not on PATH — clone the repo and run setup.sh")
|
||||
|
||||
if aipass_bin:
|
||||
console.print(f"[green]✓[/green] aipass: {aipass_bin}")
|
||||
else:
|
||||
warning("aipass not on PATH — run: pip install -e .")
|
||||
warning("aipass not on PATH — clone the repo and run setup.sh")
|
||||
|
||||
_save_stage(10, {"drone": drone_bin, "aipass": aipass_bin}, dry_run=dry_run)
|
||||
return {"drone": drone_bin, "aipass": aipass_bin}
|
||||
@@ -775,6 +780,7 @@ def run_init(
|
||||
style: str | None = None,
|
||||
no_docker: bool = False,
|
||||
dry_run: bool = False,
|
||||
template: str | None = None,
|
||||
) -> int:
|
||||
"""Run the 12-stage init flow. Returns 0 on success."""
|
||||
# Pre-flight: refuse to run inside existing projects or agent dirs
|
||||
@@ -783,9 +789,20 @@ def run_init(
|
||||
console.print(f"[red]✗[/red] {err}")
|
||||
return 1
|
||||
|
||||
# Ensure scaffold exists (creates registry, .aipass, etc. if missing)
|
||||
# Template selection — before scaffold
|
||||
if template is None:
|
||||
if non_interactive:
|
||||
template = TEMPLATE_EMPTY
|
||||
else:
|
||||
template = _choose(
|
||||
"Choose a project template:",
|
||||
TEMPLATE_CHOICES,
|
||||
default=TEMPLATE_EMPTY,
|
||||
)
|
||||
|
||||
# Ensure scaffold exists — only for aipass_framework
|
||||
cwd = Path.cwd()
|
||||
if not list(cwd.glob("*_REGISTRY.json")):
|
||||
if template == TEMPLATE_AIPASS and not list(cwd.glob("*_REGISTRY.json")):
|
||||
from aipass.aipass.apps.handlers.init.bootstrap import init_project
|
||||
|
||||
if not dry_run:
|
||||
@@ -804,7 +821,7 @@ def run_init(
|
||||
if last_done > 0:
|
||||
warning(f"Resuming from stage {last_done + 1}...")
|
||||
|
||||
accumulated: Dict[str, Any] = {}
|
||||
accumulated: Dict[str, Any] = {"template": template}
|
||||
|
||||
stage_fns = [
|
||||
(1, lambda: stage_1_welcome(dry_run=dry_run)),
|
||||
@@ -834,6 +851,9 @@ def run_init(
|
||||
for stage_num, fn in stage_fns:
|
||||
if stage_num <= last_done:
|
||||
continue
|
||||
if stage_num in AIPASS_SPECIFIC_STAGES and template != TEMPLATE_AIPASS:
|
||||
logger.info("[init_flow] skipping stage %d (not aipass_framework)", stage_num)
|
||||
continue
|
||||
try:
|
||||
result = fn() or {}
|
||||
accumulated.update(result)
|
||||
@@ -846,6 +866,11 @@ def run_init(
|
||||
warning(f"Stage {stage_num} error: {exc} — continuing.")
|
||||
_save_stage(stage_num, {"error": str(exc)}, dry_run=dry_run)
|
||||
|
||||
if template != TEMPLATE_AIPASS:
|
||||
console.print()
|
||||
console.print("[green]✓[/green] Project initialized.")
|
||||
console.print("[dim]Run 'aipass init agent <name>' to add an agent.[/dim]")
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
@@ -878,8 +903,10 @@ def print_help() -> None:
|
||||
console.print(" [green]aipass init run --non-interactive[/green] [dim]# CI/headless[/dim]")
|
||||
console.print(" [green]aipass init run --name YourName[/green] [dim]# pre-fill name[/dim]")
|
||||
console.print(" [green]aipass init run --cli claude[/green] [dim]# pre-fill CLI[/dim]")
|
||||
console.print(" [green]aipass init run --template <name>[/green] [dim]# select template[/dim]")
|
||||
console.print(" [green]aipass init run --no-docker[/green] [dim]# skip docker offer[/dim]")
|
||||
console.print(" [green]aipass init run --dry-run[/green] [dim]# walk all stages, no writes[/dim]")
|
||||
console.print(" [green]aipass init --list[/green] [dim]# list available templates[/dim]")
|
||||
console.print()
|
||||
console.print("[yellow]STAGES:[/yellow] 12 stages, each saved — resume on ctrl-C")
|
||||
console.print()
|
||||
@@ -1008,6 +1035,15 @@ def handle_command(command: str, args: list[str]) -> bool:
|
||||
sys.exit(_handle_init_update(args[1:]))
|
||||
return True
|
||||
|
||||
if args[0] == "--list":
|
||||
console.print()
|
||||
console.print("[bold cyan]Available project templates:[/bold cyan]")
|
||||
for t in TEMPLATE_CHOICES:
|
||||
marker = " [dim](default)[/dim]" if t == TEMPLATE_EMPTY else ""
|
||||
console.print(f" • {t}{marker}")
|
||||
console.print()
|
||||
return True
|
||||
|
||||
if args[0] == "run" or args[0].startswith("--"):
|
||||
run_args = args[1:] if args[0] == "run" else args
|
||||
non_interactive = "--non-interactive" in run_args
|
||||
@@ -1022,6 +1058,7 @@ def handle_command(command: str, args: list[str]) -> bool:
|
||||
name = _flag_value("--name")
|
||||
cli = _flag_value("--cli")
|
||||
style = _flag_value("--style")
|
||||
template = _flag_value("--template")
|
||||
no_docker = "--no-docker" in run_args
|
||||
dry_run = "--dry-run" in run_args
|
||||
|
||||
@@ -1032,6 +1069,7 @@ def handle_command(command: str, args: list[str]) -> bool:
|
||||
style=style,
|
||||
no_docker=no_docker,
|
||||
dry_run=dry_run,
|
||||
template=template,
|
||||
)
|
||||
json_handler.log_operation(
|
||||
"init_run",
|
||||
@@ -1040,6 +1078,12 @@ def handle_command(command: str, args: list[str]) -> bool:
|
||||
sys.exit(result)
|
||||
return True
|
||||
|
||||
# Template name as positional arg
|
||||
if args[0] in TEMPLATE_CHOICES:
|
||||
result = run_init(template=args[0])
|
||||
sys.exit(result)
|
||||
return True
|
||||
|
||||
# Positional args = target path and/or project name for scaffold
|
||||
err = _preflight_check()
|
||||
if err:
|
||||
|
||||
@@ -16,6 +16,10 @@ from unittest.mock import MagicMock, patch
|
||||
import pytest
|
||||
|
||||
from aipass.aipass.apps.modules.init_flow import (
|
||||
AIPASS_SPECIFIC_STAGES,
|
||||
TEMPLATE_AIPASS,
|
||||
TEMPLATE_CHOICES,
|
||||
TEMPLATE_EMPTY,
|
||||
TOTAL_STAGES,
|
||||
_get_last_completed_stage,
|
||||
_get_setup_progress,
|
||||
@@ -287,7 +291,7 @@ class TestRunInit:
|
||||
assert result == 0
|
||||
|
||||
def test_non_interactive_runs_all_stages(self, tmp_local_json) -> None:
|
||||
"""non_interactive=True runs all 12 stages from fresh state."""
|
||||
"""non_interactive=True with aipass_framework runs all 12 stages."""
|
||||
patches = self._patch_all_stages()
|
||||
mocks = []
|
||||
ctx = __import__("contextlib").ExitStack()
|
||||
@@ -296,7 +300,7 @@ class TestRunInit:
|
||||
with ctx:
|
||||
with patch("aipass.aipass.apps.modules.init_flow.json_handler"):
|
||||
with patch("aipass.aipass.apps.modules.init_flow.console"):
|
||||
result = run_init(non_interactive=True)
|
||||
result = run_init(non_interactive=True, template=TEMPLATE_AIPASS)
|
||||
assert result == 0
|
||||
|
||||
def test_keyboard_interrupt_pauses_gracefully(self, tmp_local_json) -> None:
|
||||
@@ -304,7 +308,7 @@ class TestRunInit:
|
||||
with patch("aipass.aipass.apps.modules.init_flow.stage_1_welcome", side_effect=KeyboardInterrupt):
|
||||
with patch("aipass.aipass.apps.modules.init_flow.console"):
|
||||
with patch("aipass.aipass.apps.modules.init_flow.warning"):
|
||||
result = run_init(non_interactive=False)
|
||||
result = run_init(non_interactive=False, template=TEMPLATE_EMPTY)
|
||||
assert result == 0
|
||||
|
||||
def test_stage_error_continues(self, tmp_local_json) -> None:
|
||||
@@ -336,7 +340,7 @@ class TestRunInit:
|
||||
warning=MagicMock(),
|
||||
console=MagicMock(),
|
||||
):
|
||||
result = run_init(non_interactive=True)
|
||||
result = run_init(non_interactive=True, template=TEMPLATE_AIPASS)
|
||||
assert result == 0
|
||||
|
||||
def test_resumes_from_last_completed(self, tmp_local_json_with_progress: Path) -> None:
|
||||
@@ -361,7 +365,7 @@ class TestRunInit:
|
||||
warning=MagicMock(),
|
||||
console=MagicMock(),
|
||||
):
|
||||
run_init(non_interactive=True)
|
||||
run_init(non_interactive=True, template=TEMPLATE_AIPASS)
|
||||
stage_1_mock.assert_not_called()
|
||||
stage_4_mock.assert_called_once()
|
||||
|
||||
@@ -719,3 +723,114 @@ class TestInitUpdateRegistrySync:
|
||||
):
|
||||
rc = _handle_init_update([str(tmp_path)])
|
||||
assert rc == 0
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# TestTemplateSelector
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class TestTemplateSelector:
|
||||
"""Tests for the template selector in aipass init."""
|
||||
|
||||
@staticmethod
|
||||
def _stage_patches():
|
||||
"""Return patches for all 12 stage functions as no-ops."""
|
||||
stage_names = [
|
||||
"stage_1_welcome",
|
||||
"stage_2_system_detect",
|
||||
"stage_3_doctor",
|
||||
"stage_4_user_profile",
|
||||
"stage_5_style_questions",
|
||||
"stage_6_tool_choice",
|
||||
"stage_7_docker_offer",
|
||||
"stage_8_first_agent",
|
||||
"stage_9_ping_sweep",
|
||||
"stage_10_smoke_test",
|
||||
"stage_11_handoff",
|
||||
"stage_12_done",
|
||||
]
|
||||
return {name: MagicMock(return_value={}) for name in stage_names}
|
||||
|
||||
def test_empty_project_default_skips_scaffold(self, tmp_local_json) -> None:
|
||||
"""empty project (default) = no scaffold, stages 8,9,11,12 skipped."""
|
||||
mocks = self._stage_patches()
|
||||
with patch.multiple(_MOD, console=MagicMock(), warning=MagicMock(), **mocks):
|
||||
result = run_init(non_interactive=True, template=TEMPLATE_EMPTY)
|
||||
assert result == 0
|
||||
for name in (
|
||||
"stage_1_welcome",
|
||||
"stage_2_system_detect",
|
||||
"stage_3_doctor",
|
||||
"stage_4_user_profile",
|
||||
"stage_5_style_questions",
|
||||
"stage_6_tool_choice",
|
||||
"stage_7_docker_offer",
|
||||
"stage_10_smoke_test",
|
||||
):
|
||||
assert mocks[name].called, f"{name} should have been called"
|
||||
for name in ("stage_8_first_agent", "stage_9_ping_sweep", "stage_11_handoff", "stage_12_done"):
|
||||
assert not mocks[name].called, f"{name} should NOT have been called"
|
||||
|
||||
def test_aipass_framework_runs_full_scaffold(self, tmp_local_json) -> None:
|
||||
"""aipass_framework = full scaffold + all 12 stages."""
|
||||
mocks = self._stage_patches()
|
||||
with patch.multiple(_MOD, console=MagicMock(), warning=MagicMock(), **mocks):
|
||||
with patch(
|
||||
"aipass.aipass.apps.handlers.init.bootstrap.init_project",
|
||||
return_value={},
|
||||
):
|
||||
result = run_init(non_interactive=True, template=TEMPLATE_AIPASS)
|
||||
assert result == 0
|
||||
for name in mocks:
|
||||
assert mocks[name].called, f"{name} should have been called"
|
||||
|
||||
def test_list_flag_shows_catalog(self) -> None:
|
||||
"""aipass init --list shows the catalog (not swallowed into run)."""
|
||||
with patch(f"{_MOD}.console") as mock_console:
|
||||
result = handle_command("init", ["--list"])
|
||||
assert result is True
|
||||
printed = " ".join(str(c) for c in mock_console.print.call_args_list)
|
||||
for t in TEMPLATE_CHOICES:
|
||||
assert t in printed
|
||||
|
||||
def test_template_flag_form_works(self, tmp_local_json) -> None:
|
||||
"""aipass init run --template aipass_framework passes template to run_init."""
|
||||
with patch(f"{_MOD}.run_init", return_value=0) as mock_run:
|
||||
with pytest.raises(SystemExit):
|
||||
handle_command("init", ["run", "--template", TEMPLATE_AIPASS])
|
||||
mock_run.assert_called_once()
|
||||
_, kwargs = mock_run.call_args
|
||||
assert kwargs["template"] == TEMPLATE_AIPASS
|
||||
|
||||
def test_positional_template_routes_to_run_init(self, tmp_local_json) -> None:
|
||||
"""aipass init aipass_framework routes to run_init with template."""
|
||||
with patch(f"{_MOD}.run_init", return_value=0) as mock_run:
|
||||
with pytest.raises(SystemExit):
|
||||
handle_command("init", [TEMPLATE_AIPASS])
|
||||
mock_run.assert_called_once()
|
||||
_, kwargs = mock_run.call_args
|
||||
assert kwargs["template"] == TEMPLATE_AIPASS
|
||||
|
||||
def test_positional_path_still_works(self, tmp_local_json) -> None:
|
||||
"""Non-template positional args still route to scaffold."""
|
||||
with patch(f"{_MOD}._preflight_check", return_value=None):
|
||||
with patch(f"{_MOD}._handle_init_scaffold", return_value=0) as mock_scaffold:
|
||||
with pytest.raises(SystemExit):
|
||||
handle_command("init", ["/tmp/test-proj"])
|
||||
mock_scaffold.assert_called_once_with(["/tmp/test-proj"])
|
||||
|
||||
def test_pip_hints_say_clone(self, tmp_local_json) -> None:
|
||||
"""in-product hints say clone/setup.sh, not pip."""
|
||||
with patch(f"{_MOD}.console"):
|
||||
with patch(f"{_MOD}.warning") as mock_warn:
|
||||
with patch(f"{_MOD}.shutil.which", return_value=None):
|
||||
stage_10_smoke_test()
|
||||
for call in mock_warn.call_args_list:
|
||||
msg = call[0][0].lower()
|
||||
assert "setup.sh" in msg
|
||||
assert "pip" not in msg
|
||||
|
||||
def test_aipass_specific_stages_constant(self) -> None:
|
||||
"""AIPASS_SPECIFIC_STAGES contains exactly {8, 9, 11, 12}."""
|
||||
assert AIPASS_SPECIFIC_STAGES == {8, 9, 11, 12}
|
||||
|
||||
@@ -5,8 +5,8 @@
|
||||
> Centralized external API gateway — authenticated service clients for all external APIs
|
||||
|
||||
**Module:** `aipass.api` | **Role:** `api_gateway`
|
||||
**Seedgo:** 100% (37/37 at 100%) | **Tests:** 504 pass | **Functions:** 82 public (82 tested)
|
||||
**Last Updated:** 2026-06-15
|
||||
**Seedgo:** 100% (38/38 at 100%) | **Tests:** 515 pass | **Functions:** 84 public (84 tested)
|
||||
**Last Updated:** 2026-06-24
|
||||
|
||||
---
|
||||
|
||||
@@ -68,7 +68,7 @@ api/
|
||||
│ │ └── usage/aggregation.py, cleanup.py, tracking.py
|
||||
│ └── integrations/ # Private driver space (gitignored)
|
||||
│ └── {project}/driver.py
|
||||
└── tests/ # 504 tests across 28 files
|
||||
└── tests/ # 515 tests across 28 files
|
||||
```
|
||||
|
||||
Three-tier: entry point routes to modules (orchestration), modules delegate to handlers (business logic). Modules auto-discovered from `apps/modules/*.py` via `handle_command()`.
|
||||
@@ -88,11 +88,12 @@ service = get_drive_service(thread_safe=True) # For concurrent workers
|
||||
from aipass.api.apps.modules.google_client import get_google_service
|
||||
service = get_google_service("calendar", "v3")
|
||||
|
||||
from aipass.api.apps.modules.secrets import get_secret, list_secrets
|
||||
from aipass.api.apps.modules.secrets import get_secret, set_secret, list_secrets
|
||||
token = get_secret("telegram", "bot") # Returns bot_token string
|
||||
config = get_secret("telegram", "bot", as_json=True) # Returns full dict
|
||||
slugs = list_secrets("telegram") # Returns ["bot", "webhook", ...]
|
||||
# CLI never prints raw values — use the Python API above for programmatic access
|
||||
set_secret("telegram", "newbot", cfg, as_json=True) # Writes ~/.secrets/aipass/telegram/newbot.json
|
||||
slugs = list_secrets("telegram") # Returns ["bot", "newbot", ...]
|
||||
# Values never reach stdout — use the Python API above for programmatic access
|
||||
```
|
||||
|
||||
---
|
||||
@@ -128,6 +129,6 @@ Private drivers in `apps/integrations/{project}/driver.py` (gitignored) register
|
||||
|
||||
---
|
||||
|
||||
*Last Updated: 2026-05-16*
|
||||
*Last Updated: 2026-06-24*
|
||||
|
||||
[← Back to AIPass](../../../README.md)
|
||||
|
||||
@@ -9,17 +9,19 @@
|
||||
"""
|
||||
Secrets Store Handler
|
||||
|
||||
Reads structured secrets from ~/.secrets/aipass/<provider>/<slug>.
|
||||
Reads and writes structured secrets in ~/.secrets/aipass/<provider>/<slug>.
|
||||
Supports JSON config files and raw secret files.
|
||||
|
||||
Functions:
|
||||
get_secret() - Read a secret by provider/slug
|
||||
set_secret() - Write a secret to the provider store
|
||||
list_secrets() - List available slugs for a provider
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Any, List, Optional
|
||||
from typing import Any, List, Optional, Union
|
||||
|
||||
from aipass.prax import logger
|
||||
from aipass.api.apps.handlers.json import json_handler
|
||||
@@ -115,6 +117,53 @@ def list_secrets(provider: str) -> List[str]:
|
||||
return sorted(slugs)
|
||||
|
||||
|
||||
# ==============================================
|
||||
# SECRET WRITING
|
||||
# ==============================================
|
||||
|
||||
|
||||
def set_secret(provider: str, slug: str, value: Union[str, dict], *, as_json: bool = False) -> Path:
|
||||
"""
|
||||
Write a secret to the provider store.
|
||||
|
||||
Destination: ~/.secrets/aipass/<provider>/<slug>.json
|
||||
|
||||
Args:
|
||||
provider: Provider directory name (e.g., 'telegram')
|
||||
slug: Secret identifier (without .json extension)
|
||||
value: Secret value — string or dict
|
||||
as_json: If True, json.dump the value; else write as plain string
|
||||
|
||||
Returns:
|
||||
Path to the written file
|
||||
|
||||
Raises:
|
||||
OSError: If directory creation or file write fails
|
||||
"""
|
||||
provider_dir = SECRETS_BASE / provider
|
||||
provider_dir.mkdir(parents=True, exist_ok=True)
|
||||
os.chmod(provider_dir, 0o700)
|
||||
|
||||
target = provider_dir / f"{slug}.json"
|
||||
|
||||
if as_json:
|
||||
content = json.dumps(value, indent=2).encode("utf-8")
|
||||
else:
|
||||
content = json.dumps(str(value)).encode("utf-8")
|
||||
|
||||
fd = os.open(str(target), os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
||||
try:
|
||||
os.write(fd, content)
|
||||
finally:
|
||||
os.close(fd)
|
||||
|
||||
json_handler.log_operation(
|
||||
"secret_written",
|
||||
{"provider": provider, "slug": slug, "format": "json" if as_json else "raw"},
|
||||
)
|
||||
return target
|
||||
|
||||
|
||||
# ==============================================
|
||||
# PRIVATE HELPERS
|
||||
# ==============================================
|
||||
|
||||
@@ -250,55 +250,48 @@ def get_validation_rules(provider: str) -> dict:
|
||||
|
||||
|
||||
def print_help():
|
||||
"""Print help output for API key management"""
|
||||
import argparse
|
||||
|
||||
parser = argparse.ArgumentParser(
|
||||
prog="drone @api",
|
||||
description="API Key Management Module - Manage API keys and credentials",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog="""
|
||||
COMMANDS:
|
||||
get-key - Retrieve API key for a provider
|
||||
get-secret - Read secret from provider store
|
||||
validate - Validate API key
|
||||
list-providers - List available providers
|
||||
init - Initialize .env template
|
||||
|
||||
USAGE:
|
||||
drone @api <command> [args]
|
||||
drone @api --help
|
||||
|
||||
EXAMPLES:
|
||||
# Get key for provider
|
||||
drone @api get-key openrouter
|
||||
|
||||
# Check if a secret exists (masked summary, no raw value)
|
||||
drone @api get-secret telegram/bot
|
||||
|
||||
# Write secret to a protected file
|
||||
drone @api get-secret telegram/bot --out /tmp/token.txt
|
||||
|
||||
# Write secret as JSON to a protected file
|
||||
drone @api get-secret telegram/bot --out /tmp/bot.json --json
|
||||
|
||||
# List secrets for a provider
|
||||
drone @api get-secret telegram --list
|
||||
|
||||
# Programmatic access (in-process, no stdout):
|
||||
# from aipass.api.apps.modules.secrets import get_secret
|
||||
|
||||
# Validate key
|
||||
drone @api validate openrouter
|
||||
|
||||
# List providers
|
||||
drone @api list-providers
|
||||
|
||||
# Initialize environment
|
||||
drone @api init
|
||||
""",
|
||||
)
|
||||
console.print(parser.format_help())
|
||||
"""Print drone-compliant help output with Rich markup"""
|
||||
console.print()
|
||||
console.print("[bold cyan]API_KEY — Manage API keys and credentials[/bold cyan]")
|
||||
console.print()
|
||||
console.print("[yellow]COMMANDS:[/yellow]")
|
||||
console.print(" [cyan]get-key[/cyan] [dim]Retrieve API key for a provider[/dim]")
|
||||
console.print(" [cyan]get-secret[/cyan] [dim]Read secret from provider store[/dim]")
|
||||
console.print(" [cyan]validate[/cyan] [dim]Validate API key[/dim]")
|
||||
console.print(" [cyan]list-providers[/cyan] [dim]List available providers[/dim]")
|
||||
console.print(" [cyan]init[/cyan] [dim]Initialize .env template[/dim]")
|
||||
console.print()
|
||||
console.print("[yellow]USAGE:[/yellow]")
|
||||
console.print(" [cyan]drone @api[/cyan] <command> [args]")
|
||||
console.print(" [cyan]drone @api[/cyan] --help")
|
||||
console.print()
|
||||
console.print("[yellow]EXAMPLES:[/yellow]")
|
||||
console.print(" [cyan]drone @api get-key openrouter[/cyan]")
|
||||
console.print()
|
||||
console.print(" [dim]# Check if a secret exists (masked summary, no raw value)[/dim]")
|
||||
console.print(" [cyan]drone @api get-secret telegram/bot[/cyan]")
|
||||
console.print()
|
||||
console.print(" [dim]# Write secret to a protected file[/dim]")
|
||||
console.print(" [cyan]drone @api get-secret telegram/bot --out /tmp/token.txt[/cyan]")
|
||||
console.print()
|
||||
console.print(" [dim]# Write secret as JSON to a protected file[/dim]")
|
||||
console.print(" [cyan]drone @api get-secret telegram/bot --out /tmp/bot.json --json[/cyan]")
|
||||
console.print()
|
||||
console.print(" [dim]# List secrets for a provider[/dim]")
|
||||
console.print(" [cyan]drone @api get-secret telegram --list[/cyan]")
|
||||
console.print()
|
||||
console.print(" [dim]# Programmatic access (in-process, no stdout):[/dim]")
|
||||
console.print(" [dim]from aipass.api.apps.modules.secrets import get_secret[/dim]")
|
||||
console.print()
|
||||
console.print(" [dim]# Validate key[/dim]")
|
||||
console.print(" [cyan]drone @api validate openrouter[/cyan]")
|
||||
console.print()
|
||||
console.print(" [dim]# List providers[/dim]")
|
||||
console.print(" [cyan]drone @api list-providers[/cyan]")
|
||||
console.print()
|
||||
console.print(" [dim]# Initialize environment[/dim]")
|
||||
console.print(" [cyan]drone @api init[/cyan]")
|
||||
console.print()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -82,31 +82,25 @@ def print_introspection() -> None:
|
||||
|
||||
|
||||
def print_help() -> None:
|
||||
"""Print module help."""
|
||||
import argparse
|
||||
|
||||
parser = argparse.ArgumentParser(
|
||||
prog="drone @api",
|
||||
description="Google Client - Google API authentication and service access",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog="""
|
||||
COMMANDS (via drone @api):
|
||||
validate google - Check Google OAuth2 credentials
|
||||
reauth google - Re-run OAuth2 flow for Google
|
||||
|
||||
CROSS-BRANCH API:
|
||||
from aipass.api.apps.modules.google_client import get_drive_service
|
||||
service = get_drive_service()
|
||||
|
||||
CREDENTIAL SETUP:
|
||||
1. Get OAuth client secret from Google Cloud Console
|
||||
2. Save as: ~/.secrets/aipass/google_client_secret.json
|
||||
3. Run: drone @api reauth google
|
||||
4. Complete OAuth consent in browser
|
||||
5. Credentials saved to: ~/.secrets/aipass/google_creds.json
|
||||
""",
|
||||
)
|
||||
console.print(parser.format_help())
|
||||
"""Print drone-compliant help output with Rich markup"""
|
||||
console.print()
|
||||
console.print("[bold cyan]GOOGLE_CLIENT — Google API authentication and service access[/bold cyan]")
|
||||
console.print()
|
||||
console.print("[yellow]COMMANDS:[/yellow] [dim](via drone @api)[/dim]")
|
||||
console.print(" [cyan]validate google[/cyan] [dim]Check Google OAuth2 credentials[/dim]")
|
||||
console.print(" [cyan]reauth google[/cyan] [dim]Re-run OAuth2 flow for Google[/dim]")
|
||||
console.print()
|
||||
console.print("[yellow]CROSS-BRANCH API:[/yellow]")
|
||||
console.print(" [dim]from aipass.api.apps.modules.google_client import get_drive_service[/dim]")
|
||||
console.print(" [dim]service = get_drive_service()[/dim]")
|
||||
console.print()
|
||||
console.print("[yellow]CREDENTIAL SETUP:[/yellow]")
|
||||
console.print(" [dim]1.[/dim] Get OAuth client secret from Google Cloud Console")
|
||||
console.print(" [dim]2.[/dim] Save as: [cyan]~/.secrets/aipass/google_client_secret.json[/cyan]")
|
||||
console.print(" [dim]3.[/dim] Run: [cyan]drone @api reauth google[/cyan]")
|
||||
console.print(" [dim]4.[/dim] Complete OAuth consent in browser")
|
||||
console.print(" [dim]5.[/dim] Credentials saved to: [cyan]~/.secrets/aipass/google_creds.json[/cyan]")
|
||||
console.print()
|
||||
|
||||
|
||||
# =============================================
|
||||
|
||||
@@ -52,62 +52,39 @@ def print_introspection():
|
||||
|
||||
|
||||
def print_help():
|
||||
"""Print module help with argparse"""
|
||||
import argparse
|
||||
|
||||
parser = argparse.ArgumentParser(
|
||||
prog="drone @api",
|
||||
description="OpenRouter Client - Manage LLM API connections",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog="""
|
||||
COMMANDS:
|
||||
test - Test OpenRouter connection
|
||||
call - Make API call to model
|
||||
models - List available models
|
||||
status - Check connection status
|
||||
|
||||
USAGE:
|
||||
drone @api test
|
||||
drone @api call <prompt> [--model MODEL]
|
||||
drone @api models
|
||||
drone @api status
|
||||
|
||||
ARGUMENTS:
|
||||
prompt - Prompt to send to the model
|
||||
--model - Model to use (optional)
|
||||
|
||||
EXAMPLES:
|
||||
# Test OpenRouter connection
|
||||
drone @api test
|
||||
|
||||
# Make an API call
|
||||
drone @api call "What is AI?" --model gpt-4
|
||||
|
||||
# List available models
|
||||
drone @api models
|
||||
|
||||
# Check connection status
|
||||
drone @api status
|
||||
""",
|
||||
)
|
||||
|
||||
subparsers = parser.add_subparsers(dest="command", help="Available commands")
|
||||
|
||||
# test command
|
||||
subparsers.add_parser("test", help="Test OpenRouter connection")
|
||||
|
||||
# call command
|
||||
call_parser = subparsers.add_parser("call", help="Make API call to model")
|
||||
call_parser.add_argument("prompt", help="Prompt to send")
|
||||
call_parser.add_argument("--model", help="Model to use")
|
||||
|
||||
# models command
|
||||
subparsers.add_parser("models", help="List available models")
|
||||
|
||||
# status command
|
||||
subparsers.add_parser("status", help="Check connection status")
|
||||
|
||||
console.print(parser.format_help())
|
||||
"""Print drone-compliant help output with Rich markup"""
|
||||
console.print()
|
||||
console.print("[bold cyan]OPENROUTER_CLIENT — Manage LLM API connections[/bold cyan]")
|
||||
console.print()
|
||||
console.print("[yellow]COMMANDS:[/yellow]")
|
||||
console.print(" [cyan]test[/cyan] [dim]Test OpenRouter connection[/dim]")
|
||||
console.print(" [cyan]call[/cyan] [dim]Make API call to model[/dim]")
|
||||
console.print(" [cyan]models[/cyan] [dim]List available models[/dim]")
|
||||
console.print(" [cyan]status[/cyan] [dim]Check connection status[/dim]")
|
||||
console.print()
|
||||
console.print("[yellow]USAGE:[/yellow]")
|
||||
console.print(" [cyan]drone @api test[/cyan]")
|
||||
console.print(" [cyan]drone @api call[/cyan] <prompt> [--model MODEL]")
|
||||
console.print(" [cyan]drone @api models[/cyan]")
|
||||
console.print(" [cyan]drone @api status[/cyan]")
|
||||
console.print()
|
||||
console.print("[yellow]ARGUMENTS:[/yellow]")
|
||||
console.print(" [cyan]prompt[/cyan] [dim]Prompt to send to the model[/dim]")
|
||||
console.print(" [cyan]--model[/cyan] [dim]Model to use (optional)[/dim]")
|
||||
console.print()
|
||||
console.print("[yellow]EXAMPLES:[/yellow]")
|
||||
console.print(" [dim]# Test OpenRouter connection[/dim]")
|
||||
console.print(" [cyan]drone @api test[/cyan]")
|
||||
console.print()
|
||||
console.print(" [dim]# Make an API call[/dim]")
|
||||
console.print(' [cyan]drone @api call "What is AI?" --model gpt-4[/cyan]')
|
||||
console.print()
|
||||
console.print(" [dim]# List available models[/dim]")
|
||||
console.print(" [cyan]drone @api models[/cyan]")
|
||||
console.print()
|
||||
console.print(" [dim]# Check connection status[/dim]")
|
||||
console.print(" [cyan]drone @api status[/cyan]")
|
||||
console.print()
|
||||
|
||||
|
||||
def handle_command(command: str, args: List[str]) -> bool:
|
||||
|
||||
@@ -9,17 +9,19 @@
|
||||
"""
|
||||
Secrets Module
|
||||
|
||||
Cross-branch in-process API for reading secrets from the provider store.
|
||||
Cross-branch in-process API for the secrets provider store.
|
||||
Consumers import directly instead of shelling out to the CLI.
|
||||
|
||||
Functions:
|
||||
get_secret() - Read a secret by provider/slug
|
||||
set_secret() - Write a secret to the provider store
|
||||
list_secrets() - List available slugs for a provider
|
||||
handle_command() - Route CLI commands (seedgo module discovery)
|
||||
"""
|
||||
|
||||
import sys
|
||||
from typing import Any, List, Optional
|
||||
from pathlib import Path
|
||||
from typing import Any, List, Optional, Union
|
||||
|
||||
from aipass.prax import logger # noqa: F401 — seedgo imports standard
|
||||
from aipass.cli.apps.modules import console, header
|
||||
@@ -42,6 +44,7 @@ def print_introspection():
|
||||
|
||||
console.print("[cyan]Available Workflows:[/cyan]")
|
||||
console.print(" • get_secret() - Read secret by provider/slug")
|
||||
console.print(" • set_secret() - Write secret to provider store")
|
||||
console.print(" • list_secrets() - List slugs for a provider")
|
||||
console.print()
|
||||
|
||||
@@ -96,6 +99,30 @@ def get_secret(provider: str, slug: str, as_json: bool = False) -> Optional[Any]
|
||||
return result
|
||||
|
||||
|
||||
def set_secret(provider: str, slug: str, value: Union[str, dict], *, as_json: bool = False) -> Path:
|
||||
"""
|
||||
Write a secret to the provider store.
|
||||
|
||||
This is the sanctioned cross-branch write path. Consumers call this
|
||||
instead of shelling out to the CLI.
|
||||
|
||||
Args:
|
||||
provider: Provider directory name (e.g., 'telegram')
|
||||
slug: Secret identifier (without .json extension)
|
||||
value: Secret value — string or dict
|
||||
as_json: If True, json.dump the value; else write as plain string
|
||||
|
||||
Returns:
|
||||
Path to the written file
|
||||
|
||||
Raises:
|
||||
OSError: If directory creation or file write fails
|
||||
"""
|
||||
result = _handler.set_secret(provider, slug, value, as_json=as_json)
|
||||
json_handler.log_operation("secrets_set", {"provider": provider, "slug": slug, "wrote": str(result)})
|
||||
return result
|
||||
|
||||
|
||||
def list_secrets(provider: str) -> List[str]:
|
||||
"""
|
||||
List available secret slugs for a provider.
|
||||
|
||||
@@ -53,76 +53,44 @@ def print_introspection():
|
||||
|
||||
|
||||
def print_help():
|
||||
"""Print module help with argparse"""
|
||||
import argparse
|
||||
|
||||
parser = argparse.ArgumentParser(
|
||||
prog="drone @api",
|
||||
description="Usage Tracker - Monitor API usage and costs",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog="""
|
||||
COMMANDS:
|
||||
track - Track API usage
|
||||
stats - Show usage statistics
|
||||
session - Show session data
|
||||
caller-usage - Show usage by caller
|
||||
cleanup - Clean up old usage data
|
||||
|
||||
USAGE:
|
||||
drone @api track <caller>
|
||||
drone @api stats
|
||||
drone @api session
|
||||
drone @api caller-usage <caller>
|
||||
drone @api cleanup [days]
|
||||
|
||||
ARGUMENTS:
|
||||
caller - Caller identifier
|
||||
days - Number of days to retain (default: 30)
|
||||
|
||||
EXAMPLES:
|
||||
# Track usage for a caller
|
||||
drone @api track my_application
|
||||
|
||||
# Show usage statistics
|
||||
drone @api stats
|
||||
|
||||
# Show session data
|
||||
drone @api session
|
||||
|
||||
# Show usage for specific caller
|
||||
drone @api caller-usage my_application
|
||||
|
||||
# Cleanup data older than 60 days
|
||||
drone @api cleanup 60
|
||||
""",
|
||||
)
|
||||
|
||||
subparsers = parser.add_subparsers(dest="command", help="Available commands")
|
||||
|
||||
# track command
|
||||
track_parser = subparsers.add_parser("track", help="Track API usage")
|
||||
track_parser.add_argument("caller", help="Caller identifier")
|
||||
|
||||
# stats command
|
||||
subparsers.add_parser("stats", help="Show usage statistics")
|
||||
|
||||
# session command
|
||||
subparsers.add_parser("session", help="Show session data")
|
||||
|
||||
# caller-usage command
|
||||
caller_parser = subparsers.add_parser("caller-usage", help="Show usage by caller")
|
||||
caller_parser.add_argument("caller", help="Caller identifier")
|
||||
|
||||
# cleanup command
|
||||
cleanup_parser = subparsers.add_parser("cleanup", help="Clean up old usage data")
|
||||
cleanup_parser.add_argument(
|
||||
"days",
|
||||
nargs="?",
|
||||
default=str(DEFAULT_RETENTION_DAYS),
|
||||
help=f"Days to retain (default: {DEFAULT_RETENTION_DAYS})",
|
||||
)
|
||||
|
||||
console.print(parser.format_help())
|
||||
"""Print drone-compliant help output with Rich markup"""
|
||||
console.print()
|
||||
console.print("[bold cyan]USAGE_TRACKER — Monitor API usage and costs[/bold cyan]")
|
||||
console.print()
|
||||
console.print("[yellow]COMMANDS:[/yellow]")
|
||||
console.print(" [cyan]track[/cyan] [dim]Track API usage[/dim]")
|
||||
console.print(" [cyan]stats[/cyan] [dim]Show usage statistics[/dim]")
|
||||
console.print(" [cyan]session[/cyan] [dim]Show session data[/dim]")
|
||||
console.print(" [cyan]caller-usage[/cyan] [dim]Show usage by caller[/dim]")
|
||||
console.print(" [cyan]cleanup[/cyan] [dim]Clean up old usage data[/dim]")
|
||||
console.print()
|
||||
console.print("[yellow]USAGE:[/yellow]")
|
||||
console.print(" [cyan]drone @api track[/cyan] <caller>")
|
||||
console.print(" [cyan]drone @api stats[/cyan]")
|
||||
console.print(" [cyan]drone @api session[/cyan]")
|
||||
console.print(" [cyan]drone @api caller-usage[/cyan] <caller>")
|
||||
console.print(" [cyan]drone @api cleanup[/cyan] [days]")
|
||||
console.print()
|
||||
console.print("[yellow]ARGUMENTS:[/yellow]")
|
||||
console.print(" [cyan]caller[/cyan] [dim]Caller identifier[/dim]")
|
||||
console.print(" [cyan]days[/cyan] [dim]Number of days to retain (default: 30)[/dim]")
|
||||
console.print()
|
||||
console.print("[yellow]EXAMPLES:[/yellow]")
|
||||
console.print(" [dim]# Track usage for a caller[/dim]")
|
||||
console.print(" [cyan]drone @api track my_application[/cyan]")
|
||||
console.print()
|
||||
console.print(" [dim]# Show usage statistics[/dim]")
|
||||
console.print(" [cyan]drone @api stats[/cyan]")
|
||||
console.print()
|
||||
console.print(" [dim]# Show session data[/dim]")
|
||||
console.print(" [cyan]drone @api session[/cyan]")
|
||||
console.print()
|
||||
console.print(" [dim]# Show usage for specific caller[/dim]")
|
||||
console.print(" [cyan]drone @api caller-usage my_application[/cyan]")
|
||||
console.print()
|
||||
console.print(" [dim]# Cleanup data older than 60 days[/dim]")
|
||||
console.print(" [cyan]drone @api cleanup 60[/cyan]")
|
||||
console.print()
|
||||
|
||||
|
||||
def handle_command(command: str, args: List[str]) -> bool:
|
||||
@@ -194,9 +162,10 @@ def track_usage(args: List[str]):
|
||||
|
||||
if result.get("success"):
|
||||
metrics = result.get("metrics", {})
|
||||
success(
|
||||
f"Tracked: {metrics.get('tokens_prompt', 0)} prompt + {metrics.get('tokens_completion', 0)} completion tokens, ${metrics.get('total_cost', 0):.6f}"
|
||||
)
|
||||
prompt_t = metrics.get("tokens_prompt", 0)
|
||||
comp_t = metrics.get("tokens_completion", 0)
|
||||
cost = metrics.get("total_cost", 0)
|
||||
success(f"Tracked: {prompt_t} prompt + {comp_t} completion tokens, ${cost:.6f}")
|
||||
else:
|
||||
error(f"Tracking failed: {result.get('error', 'unknown')}")
|
||||
|
||||
|
||||
@@ -21,8 +21,19 @@ Tests — handlers/auth/secrets.py (get_secret, list_secrets):
|
||||
- list_secrets: non-existent provider returns empty list
|
||||
- list_secrets: skips dotfiles, __pycache__, directories
|
||||
|
||||
Tests — handlers/auth/secrets.py (set_secret):
|
||||
- set_secret: writes string value to provider/slug.json
|
||||
- set_secret: as_json writes JSON-serialized dict
|
||||
- set_secret: creates provider directory if missing
|
||||
- set_secret: file has 0o600 permissions (POSIX)
|
||||
- set_secret: provider dir has 0o700 permissions (POSIX)
|
||||
- set_secret: overwrites existing secret
|
||||
- set_secret: round-trip with get_secret returns same value
|
||||
- set_secret: round-trip with get_secret as_json returns same dict
|
||||
|
||||
Tests — modules/secrets.py (in-process door):
|
||||
- get_secret wraps handler and logs operation
|
||||
- set_secret wraps handler and logs operation
|
||||
- list_secrets wraps handler
|
||||
|
||||
Tests — api_key.py (get_secret_cmd — hardened, no raw values to stdout):
|
||||
@@ -52,6 +63,7 @@ from aipass.api.apps.modules.api_key import handle_command as _hc # noqa: F401
|
||||
from aipass.api.apps.modules.secrets import handle_command as _hc2 # noqa: F401 — seedgo test_coverage detection
|
||||
from aipass.api.apps.handlers.auth.secrets import (
|
||||
get_secret,
|
||||
set_secret,
|
||||
list_secrets,
|
||||
)
|
||||
from aipass.api.apps.modules.api_key import get_secret_cmd
|
||||
@@ -491,3 +503,131 @@ class TestGetSecretCmd:
|
||||
get_secret_cmd(["empty_provider", "--list"])
|
||||
|
||||
mock_secrets.list_secrets.assert_called_once_with("empty_provider")
|
||||
|
||||
|
||||
# =============================================
|
||||
# set_secret (handler)
|
||||
# =============================================
|
||||
|
||||
|
||||
class TestSetSecret:
|
||||
"""Verifies secret writing under various conditions."""
|
||||
|
||||
def test_writes_string_value(self, tmp_path: Path) -> None:
|
||||
"""Writes a plain string value to provider/slug.json."""
|
||||
with patch(PATCH_SECRETS_BASE, tmp_path), patch(PATCH_JSON_HANDLER), patch(PATCH_LOGGER):
|
||||
result = set_secret("telegram", "bot", "my-token-value")
|
||||
|
||||
assert result == tmp_path / "telegram" / "bot.json"
|
||||
assert json.loads(result.read_text(encoding="utf-8")) == "my-token-value"
|
||||
|
||||
def test_as_json_writes_dict(self, tmp_path: Path) -> None:
|
||||
"""as_json=True writes JSON-serialized dict."""
|
||||
data = {"bot_token": "abc123", "webhook_url": "https://example.com"}
|
||||
|
||||
with patch(PATCH_SECRETS_BASE, tmp_path), patch(PATCH_JSON_HANDLER), patch(PATCH_LOGGER):
|
||||
result = set_secret("telegram", "bot", data, as_json=True)
|
||||
|
||||
written = json.loads(result.read_text(encoding="utf-8"))
|
||||
assert written == data
|
||||
|
||||
def test_creates_provider_directory(self, tmp_path: Path) -> None:
|
||||
"""Creates provider directory if it doesn't exist."""
|
||||
assert not (tmp_path / "newprovider").exists()
|
||||
|
||||
with patch(PATCH_SECRETS_BASE, tmp_path), patch(PATCH_JSON_HANDLER), patch(PATCH_LOGGER):
|
||||
set_secret("newprovider", "cred", "value")
|
||||
|
||||
assert (tmp_path / "newprovider").is_dir()
|
||||
|
||||
@pytest.mark.skipif(sys.platform == "win32", reason="File permission checks are POSIX-only")
|
||||
def test_file_has_0600_permissions(self, tmp_path: Path) -> None:
|
||||
"""Written file has 0o600 permissions."""
|
||||
with patch(PATCH_SECRETS_BASE, tmp_path), patch(PATCH_JSON_HANDLER), patch(PATCH_LOGGER):
|
||||
result = set_secret("telegram", "bot", "token")
|
||||
|
||||
file_mode = stat.S_IMODE(os.stat(result).st_mode)
|
||||
assert file_mode == 0o600
|
||||
|
||||
@pytest.mark.skipif(sys.platform == "win32", reason="File permission checks are POSIX-only")
|
||||
def test_provider_dir_has_0700_permissions(self, tmp_path: Path) -> None:
|
||||
"""Provider directory has 0o700 permissions."""
|
||||
with patch(PATCH_SECRETS_BASE, tmp_path), patch(PATCH_JSON_HANDLER), patch(PATCH_LOGGER):
|
||||
set_secret("telegram", "bot", "token")
|
||||
|
||||
dir_mode = stat.S_IMODE(os.stat(tmp_path / "telegram").st_mode)
|
||||
assert dir_mode == 0o700
|
||||
|
||||
def test_overwrites_existing_secret(self, tmp_path: Path) -> None:
|
||||
"""Overwrites an existing secret file."""
|
||||
with patch(PATCH_SECRETS_BASE, tmp_path), patch(PATCH_JSON_HANDLER), patch(PATCH_LOGGER):
|
||||
set_secret("telegram", "bot", "old-value")
|
||||
set_secret("telegram", "bot", "new-value")
|
||||
|
||||
content = json.loads((tmp_path / "telegram" / "bot.json").read_text(encoding="utf-8"))
|
||||
assert content == "new-value"
|
||||
|
||||
def test_round_trip_string(self, tmp_path: Path) -> None:
|
||||
"""set_secret then get_secret returns the same string value."""
|
||||
with patch(PATCH_SECRETS_BASE, tmp_path), patch(PATCH_JSON_HANDLER), patch(PATCH_LOGGER):
|
||||
set_secret("telegram", "bot", "round-trip-token")
|
||||
result = get_secret("telegram", "bot")
|
||||
|
||||
assert result == "round-trip-token"
|
||||
|
||||
def test_round_trip_json(self, tmp_path: Path) -> None:
|
||||
"""set_secret as_json then get_secret as_json returns the same dict."""
|
||||
data = {"bot_token": "abc123", "chat_id": 42}
|
||||
|
||||
with patch(PATCH_SECRETS_BASE, tmp_path), patch(PATCH_JSON_HANDLER), patch(PATCH_LOGGER):
|
||||
set_secret("telegram", "bot", data, as_json=True)
|
||||
result = get_secret("telegram", "bot", as_json=True)
|
||||
|
||||
assert result == data
|
||||
|
||||
def test_logs_operation(self, tmp_path: Path) -> None:
|
||||
"""set_secret logs the write operation via json_handler."""
|
||||
mock_jh = MagicMock()
|
||||
with patch(PATCH_SECRETS_BASE, tmp_path), patch(PATCH_JSON_HANDLER, mock_jh), patch(PATCH_LOGGER):
|
||||
set_secret("telegram", "bot", "token")
|
||||
|
||||
mock_jh.log_operation.assert_called_once()
|
||||
call_args = mock_jh.log_operation.call_args[0]
|
||||
assert call_args[0] == "secret_written"
|
||||
assert call_args[1]["provider"] == "telegram"
|
||||
assert call_args[1]["slug"] == "bot"
|
||||
|
||||
|
||||
# =============================================
|
||||
# set_secret (module door)
|
||||
# =============================================
|
||||
|
||||
|
||||
class TestSetSecretModule:
|
||||
"""Verifies the module-level set_secret wrapper."""
|
||||
|
||||
def test_set_secret_wraps_handler(self, tmp_path: Path) -> None:
|
||||
"""Module set_secret delegates to handler and logs the operation."""
|
||||
mock_handler = MagicMock()
|
||||
mock_handler.set_secret.return_value = tmp_path / "telegram" / "bot.json"
|
||||
mock_jh = MagicMock()
|
||||
|
||||
with patch(PATCH_MOD_HANDLER, mock_handler), patch(PATCH_MOD_JSON_HANDLER, mock_jh):
|
||||
result = secrets_module.set_secret("telegram", "bot", "token-val")
|
||||
|
||||
assert result == tmp_path / "telegram" / "bot.json"
|
||||
mock_handler.set_secret.assert_called_once_with("telegram", "bot", "token-val", as_json=False)
|
||||
mock_jh.log_operation.assert_called_once()
|
||||
assert mock_jh.log_operation.call_args[0][0] == "secrets_set"
|
||||
|
||||
def test_set_secret_as_json(self) -> None:
|
||||
"""Module set_secret passes as_json through to handler."""
|
||||
mock_handler = MagicMock()
|
||||
mock_handler.set_secret.return_value = Path("/fake/path.json")
|
||||
mock_jh = MagicMock()
|
||||
data = {"bot_token": "abc"}
|
||||
|
||||
with patch(PATCH_MOD_HANDLER, mock_handler), patch(PATCH_MOD_JSON_HANDLER, mock_jh):
|
||||
secrets_module.set_secret("telegram", "bot", data, as_json=True)
|
||||
|
||||
mock_handler.set_secret.assert_called_once_with("telegram", "bot", data, as_json=True)
|
||||
|
||||
@@ -39,7 +39,7 @@ apps/
|
||||
│ ├── settings.py # Settings UI (stub — low priority)
|
||||
│ ├── drive_sync.py # Drive sync (stub — DPLAN-003)
|
||||
│ ├── drive_stats.py # Drive stats (stub)
|
||||
│ ├── drive_test.py # Drive test (stub)
|
||||
│ ├── drive_check.py # Drive check (stub — DPLAN-003)
|
||||
│ └── drive_clear.py # Drive clear (stub)
|
||||
└── handlers/
|
||||
├── copy/ # File copying (snapshot + versioned)
|
||||
@@ -47,7 +47,7 @@ apps/
|
||||
├── ignore/ # .backupignore patterns + whitelist
|
||||
├── json/ # JSON persistence, atomic writes, ops log
|
||||
├── path/ # Backup path building
|
||||
├── project/ # Config, registry, setup (.backup_system/)
|
||||
├── project/ # Config, registry, setup (.backup/)
|
||||
├── report/ # Result formatting
|
||||
├── scan/ # Directory walking + filtering
|
||||
├── state/ # Changelog, metadata, timestamps
|
||||
@@ -58,14 +58,14 @@ apps/
|
||||
## Integration
|
||||
|
||||
- **Depends on:** @prax for logging, @cli for Rich console output
|
||||
- **Serves:** Any project on the PC — backups are project-owned (.backup_system/ in target root)
|
||||
- **Serves:** Any project on the PC — backups are project-owned (.backup/ in target root)
|
||||
|
||||
## Working Habits
|
||||
|
||||
- Project-owned design: .backup_system/ and .backupignore live in the TARGET project, not centrally
|
||||
- Project-owned design: .backup/ and .backupignore live in the TARGET project, not centrally
|
||||
- Normal citizen namespace: uses `from aipass.backup.apps.modules.*` / `from aipass.backup.apps.handlers.*`
|
||||
- Entry point sets AIPASS_BRANCH_NAME env var for Prax
|
||||
- BUILTIN_IGNORES in patterns.py is the single source for default ignore patterns
|
||||
- templates/backupignore.template is the single source for default ignore patterns
|
||||
|
||||
## Known Gotchas
|
||||
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"version": 1,
|
||||
"branch": "@backup",
|
||||
"jobs": [
|
||||
{
|
||||
"id": "wake-test",
|
||||
"enabled": false,
|
||||
"schedule": {
|
||||
"type": "interval",
|
||||
"interval_minutes": 5
|
||||
},
|
||||
"wake": {
|
||||
"fresh": true
|
||||
},
|
||||
"prompt": "AUTOMATED DAEMON TEST. Do ONLY this: run drone @ai_mail email @devpulse \"DAEMON TEST \u2014 backup\" \"Woke via @daemon systemd timer. No memory touched.\" then STOP. Do NOT run startup, do NOT update memory, do NOT do anything else."
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -64,11 +64,61 @@ apps/
|
||||
backup register <path> [--name <name>] # Register a project for backup
|
||||
backup snapshot <path|@name> # Full mirror backup
|
||||
backup versioned <path|@name> # Incremental timestamped backup
|
||||
backup all <path|@name> # Snapshot + versioned
|
||||
backup all <path|@name> # Snapshot + versioned + drive
|
||||
backup status <path|@name> # Show backup info and history
|
||||
backup --version # Show version
|
||||
backup restore <path|@name> list <file> # List available versions of a file
|
||||
backup restore <path|@name> file <f> <o> # Restore a file version to output path
|
||||
backup settings <path|@name> # Settings UI (stub)
|
||||
backup drive_sync <path|@name> # Google Drive sync (stub — DPLAN-003)
|
||||
backup drive_check <path|@name> # Drive connectivity check (stub — DPLAN-003)
|
||||
backup drive_stats <path|@name> # Drive storage stats (stub — DPLAN-003)
|
||||
backup drive_clear <path|@name> # Clear Drive sync state (stub — DPLAN-003)
|
||||
```
|
||||
|
||||
All 11 commands are auto-discovered by the entry point router.
|
||||
|
||||
---
|
||||
|
||||
## `.backup/` Store Structure
|
||||
|
||||
Each registered project gets a `.backup/` directory at its root:
|
||||
|
||||
```
|
||||
.backup/
|
||||
├── config.json # Project backup configuration
|
||||
├── snapshots/ # Full mirror copies (eager — created on register)
|
||||
├── versioned/ # Incremental timestamped backups (lazy)
|
||||
├── logs/ # Operation logs (eager — created on register)
|
||||
├── timestamps.json # Backup timing metadata (lazy)
|
||||
├── changelog.json # Change history (lazy)
|
||||
└── drive_tracker.json # Drive sync dedup tracker (lazy)
|
||||
```
|
||||
|
||||
On `register`, only `snapshots/` and `logs/` are created eagerly (plus `config.json`). The rest are created lazily on first use.
|
||||
|
||||
**Shared namespace:** `.backup/` is NOT exclusive to @backup. Three writers use it:
|
||||
- **@backup** — snapshot/versioned stores at a registered project root
|
||||
- **@memory** — rollover safety copies (`rollover_backup_*.json`) written to `<branch>/.backup/` during memory overflow
|
||||
- **@flow** — closed plans archived to `<repo-root>/.backup/processed_plans/` for vectorization by @memory
|
||||
|
||||
The root `.gitignore` covers all three with a single `.backup/` entry.
|
||||
|
||||
---
|
||||
|
||||
## How Ignores Work
|
||||
|
||||
Two layers — seed and runtime:
|
||||
|
||||
1. **`templates/backupignore.template`** — the **seed**. Read by `setup._build_backupignore()` and written into a new project's `.backupignore` at `register` time. Never consulted at backup time. If this file is missing, registration raises — an empty seed would back up everything and crash the machine.
|
||||
2. **`.backupignore`** — the **runtime source of truth**. `load_spec()` reads it on every backup; the seed template is not applied. True pathspec/gitwildmatch semantics: `#` comments, `!` negation, trailing `/` for dirs, last-match-wins.
|
||||
|
||||
There is no static fallback. The seed IS the safety mechanism — an empty or missing `.backupignore` means back up everything (`.venv`, `node_modules`, `.git`), which can crash the machine. Keep the template sane.
|
||||
|
||||
- To change defaults for **new** projects → edit `templates/backupignore.template`
|
||||
- To change ignores for an **existing** project → edit its `.backupignore`
|
||||
|
||||
The repo-root `/.backupignore` ships intentionally as the curated default so users don't snapshot junk.
|
||||
|
||||
---
|
||||
|
||||
## Integration Points
|
||||
@@ -78,4 +128,4 @@ backup --version # Show version
|
||||
- @cli — Rich console output
|
||||
|
||||
### Provides To
|
||||
- Any project on the PC — backups are project-owned (.backup/ in target root)
|
||||
- Any project on the PC — backups are project-owned (`.backup/` in target root)
|
||||
|
||||
@@ -73,6 +73,7 @@ def print_help() -> None:
|
||||
console.print(" [green]drive_sync[/green] Sync backups to the remote drive")
|
||||
console.print(" [green]drive_check[/green] Test the remote drive connection")
|
||||
console.print(" [green]drive_stats[/green] Drive usage statistics")
|
||||
console.print(" [green]share[/green] Upload a single file to Drive + get a shareable link")
|
||||
console.print(" [green]drive_clear[/green] Clear backups from the remote drive")
|
||||
console.print()
|
||||
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
# =================== AIPass ====================
|
||||
# Name: share.py
|
||||
# Description: Drive sharing — upload + permission + shareable link retrieval
|
||||
# Version: 1.0.0
|
||||
# Created: 2026-07-01
|
||||
# Modified: 2026-07-01
|
||||
# =============================================
|
||||
|
||||
"""Drive file sharing.
|
||||
|
||||
Uploads a single file to Drive (under ``AIPass Backups/Shared``),
|
||||
sets a read permission, and retrieves a shareable webViewLink.
|
||||
Idempotent: reuses an existing file if one is found by name.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from aipass.prax import logger
|
||||
|
||||
from ..json import json_handler
|
||||
from . import upload as upload_mod
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .client import DriveClient
|
||||
|
||||
SHARE_PROJECT_NAME = "Shared"
|
||||
|
||||
|
||||
def _get_authenticated_email(client: DriveClient) -> str | None:
|
||||
"""Derive the authenticated user's email from Drive ``about`` API."""
|
||||
try:
|
||||
request = client.drive_service.about().get(fields="user") # type: ignore[union-attr]
|
||||
result = client._api_call(request)
|
||||
if result and result.get("user"):
|
||||
return result["user"].get("emailAddress")
|
||||
except Exception as exc:
|
||||
logger.info(f"Failed to get authenticated email: {exc}")
|
||||
return None
|
||||
|
||||
|
||||
def upload_for_share(
|
||||
client: DriveClient,
|
||||
local_file: Path,
|
||||
note: str = "",
|
||||
) -> str | None:
|
||||
"""Upload a file for sharing. Returns the Drive file ID or ``None``.
|
||||
|
||||
Idempotent: checks the ``Shared`` project folder first and reuses an
|
||||
existing file (matched by name) instead of re-uploading. Falls back
|
||||
to :func:`upload_single_file` for the actual upload.
|
||||
"""
|
||||
folder_id = client.get_or_create_project_folder(SHARE_PROJECT_NAME)
|
||||
if not folder_id:
|
||||
return None
|
||||
|
||||
existing = client._find_existing_file(local_file.name, folder_id)
|
||||
if existing:
|
||||
logger.info(f"File already on Drive: {existing['id']}")
|
||||
return existing["id"]
|
||||
|
||||
client.file_tracker = {}
|
||||
success = upload_mod.upload_single_file(
|
||||
client,
|
||||
local_file,
|
||||
SHARE_PROJECT_NAME,
|
||||
local_file.parent,
|
||||
note=note,
|
||||
)
|
||||
if not success:
|
||||
return None
|
||||
|
||||
entry = client.file_tracker.get(local_file.name, {})
|
||||
return entry.get("drive_id")
|
||||
|
||||
|
||||
def set_share_permission(
|
||||
client: DriveClient,
|
||||
file_id: str,
|
||||
*,
|
||||
public: bool = False,
|
||||
) -> str | None:
|
||||
"""Set a read permission on *file_id*.
|
||||
|
||||
*public* ``False`` (default): restricted to the authenticated user
|
||||
(``type=user``, ``role=reader``). ``True``: anyone with the link
|
||||
(``type=anyone``, ``role=reader``).
|
||||
|
||||
Returns the permission ID, or ``None`` on failure.
|
||||
"""
|
||||
if public:
|
||||
body: dict[str, Any] = {"type": "anyone", "role": "reader"}
|
||||
else:
|
||||
email = _get_authenticated_email(client)
|
||||
if not email:
|
||||
client.last_error = "Could not determine authenticated email"
|
||||
return None
|
||||
body = {"type": "user", "role": "reader", "emailAddress": email}
|
||||
|
||||
try:
|
||||
request = client.drive_service.permissions().create( # type: ignore[union-attr]
|
||||
fileId=file_id,
|
||||
body=body,
|
||||
fields="id",
|
||||
)
|
||||
result = client._api_call(request)
|
||||
if result:
|
||||
return result.get("id")
|
||||
except Exception as exc:
|
||||
client.last_error = str(exc)
|
||||
logger.warning(f"Failed to set permission on {file_id}: {exc}")
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def get_share_link(client: DriveClient, file_id: str) -> str | None:
|
||||
"""Retrieve the shareable link for *file_id*.
|
||||
|
||||
Prefers ``webViewLink``; falls back to ``webContentLink``.
|
||||
"""
|
||||
try:
|
||||
request = client.drive_service.files().get( # type: ignore[union-attr]
|
||||
fileId=file_id,
|
||||
fields="webViewLink,webContentLink",
|
||||
)
|
||||
result = client._api_call(request)
|
||||
if result:
|
||||
return result.get("webViewLink") or result.get("webContentLink")
|
||||
except Exception as exc:
|
||||
client.last_error = str(exc)
|
||||
logger.warning(f"Failed to get share link for {file_id}: {exc}")
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def share_file(
|
||||
client: DriveClient,
|
||||
local_file: str | Path,
|
||||
*,
|
||||
public: bool = False,
|
||||
note: str = "",
|
||||
) -> dict[str, Any]:
|
||||
"""Upload, share, and return a link — the complete pipeline.
|
||||
|
||||
Returns a dict with ``success``, ``link``, ``file_id``, and
|
||||
``error`` keys.
|
||||
"""
|
||||
local_file = Path(local_file).resolve()
|
||||
|
||||
if not local_file.is_file():
|
||||
return {
|
||||
"success": False,
|
||||
"link": None,
|
||||
"file_id": None,
|
||||
"error": f"Not a file: {local_file}",
|
||||
}
|
||||
|
||||
file_id = upload_for_share(client, local_file, note=note)
|
||||
if not file_id:
|
||||
return {
|
||||
"success": False,
|
||||
"link": None,
|
||||
"file_id": None,
|
||||
"error": f"Upload failed: {client.last_error or 'unknown'}",
|
||||
}
|
||||
|
||||
perm_id = set_share_permission(client, file_id, public=public)
|
||||
if not perm_id:
|
||||
return {
|
||||
"success": False,
|
||||
"link": None,
|
||||
"file_id": file_id,
|
||||
"error": f"Permission failed: {client.last_error or 'unknown'}",
|
||||
}
|
||||
|
||||
link = get_share_link(client, file_id)
|
||||
if not link:
|
||||
return {
|
||||
"success": False,
|
||||
"link": None,
|
||||
"file_id": file_id,
|
||||
"error": f"Link retrieval failed: {client.last_error or 'unknown'}",
|
||||
}
|
||||
|
||||
json_handler.log_operation(
|
||||
"share_file",
|
||||
{
|
||||
"file": str(local_file),
|
||||
"file_id": file_id,
|
||||
"public": public,
|
||||
"link": link,
|
||||
},
|
||||
)
|
||||
|
||||
return {"success": True, "link": link, "file_id": file_id, "error": None}
|
||||
|
||||
|
||||
# =============================================
|
||||
@@ -17,37 +17,11 @@ import pathspec
|
||||
from ..json import json_handler
|
||||
from ..path import builder
|
||||
|
||||
BUILTIN_IGNORES = [
|
||||
".backup/",
|
||||
".git/",
|
||||
".svn/",
|
||||
".hg/",
|
||||
"__pycache__/",
|
||||
".pytest_cache/",
|
||||
"*.pyc",
|
||||
"*.pyo",
|
||||
"*.egg-info/",
|
||||
".venv/",
|
||||
"venv/",
|
||||
".tox/",
|
||||
"node_modules/",
|
||||
".vscode/",
|
||||
".idea/",
|
||||
"*.swp",
|
||||
"*.swo",
|
||||
".DS_Store",
|
||||
"Thumbs.db",
|
||||
"build/",
|
||||
"dist/",
|
||||
"*.log",
|
||||
".ruff_cache/",
|
||||
".coverage",
|
||||
]
|
||||
|
||||
|
||||
def load_spec(project_root: str) -> pathspec.PathSpec:
|
||||
"""Load a PathSpec from .backupignore at the project root.
|
||||
|
||||
This is the runtime source of truth — the seed template is not consulted here.
|
||||
Reads raw lines — pathspec handles #comments, blanks, !negation,
|
||||
anchoring, dir-only trailing /, and last-match-wins natively.
|
||||
|
||||
|
||||
@@ -15,22 +15,22 @@ inside a target project path, and a ``.backupignore`` at the project root.
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
from ..ignore.patterns import BUILTIN_IGNORES
|
||||
from ..json import json_handler
|
||||
from ..path import builder
|
||||
|
||||
_TEMPLATE_PATH = Path(__file__).resolve().parents[3] / "templates" / "backupignore.template"
|
||||
|
||||
|
||||
def _build_backupignore() -> str:
|
||||
"""Generate .backupignore content from BUILTIN_IGNORES."""
|
||||
lines = [
|
||||
"# Backup System ignore patterns (gitignore-style)",
|
||||
"# Lines starting with # are comments. Blank lines are ignored.",
|
||||
"# Edit this file to customize. Source defaults: handlers/ignore/patterns.py",
|
||||
"",
|
||||
]
|
||||
for pattern in BUILTIN_IGNORES:
|
||||
lines.append(pattern)
|
||||
return "\n".join(lines) + "\n"
|
||||
"""Read the seed .backupignore content from the template file.
|
||||
|
||||
Raises:
|
||||
FileNotFoundError: If the template is missing.
|
||||
OSError: If the template cannot be read.
|
||||
"""
|
||||
if not _TEMPLATE_PATH.exists():
|
||||
raise FileNotFoundError(f"Seed template missing: {_TEMPLATE_PATH} — cannot create a safe .backupignore")
|
||||
return _TEMPLATE_PATH.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
DEFAULT_CONFIG = {
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
# =================== AIPass ====================
|
||||
# Name: share.py
|
||||
# Description: Share module — single-file Drive upload with shareable link
|
||||
# Version: 1.0.0
|
||||
# Created: 2026-07-01
|
||||
# Modified: 2026-07-01
|
||||
# =============================================
|
||||
|
||||
"""Share Module — upload a single file to Drive and return a shareable link."""
|
||||
|
||||
import sys
|
||||
|
||||
from aipass.prax import logger
|
||||
from aipass.cli.apps.modules import console
|
||||
|
||||
from aipass.backup.apps.handlers.json import json_handler
|
||||
|
||||
MODULE_NAME = "share"
|
||||
PRIMARY_COMMAND = "share"
|
||||
|
||||
|
||||
def print_introspection():
|
||||
"""Display module info and connected handlers."""
|
||||
console.print(f"[bold cyan]{MODULE_NAME} Module[/bold cyan]")
|
||||
console.print(f" Primary command: [yellow]{PRIMARY_COMMAND}[/yellow]")
|
||||
console.print(" Status: Live — single-file Drive upload + share link")
|
||||
console.print(" Handlers: drive/client, drive/upload, drive/share")
|
||||
|
||||
|
||||
def print_help():
|
||||
"""Display help for this module."""
|
||||
print_introspection()
|
||||
console.print()
|
||||
console.print("[bold cyan]USAGE:[/bold cyan]")
|
||||
console.print()
|
||||
console.print(" [dim]drone @backup share <file_path> [--public][/dim]")
|
||||
console.print()
|
||||
console.print("[bold cyan]OPTIONS:[/bold cyan]")
|
||||
console.print()
|
||||
console.print(" [green]--public[/green] Share with anyone (default: restricted to owner)")
|
||||
console.print()
|
||||
console.print("[bold cyan]OUTPUT:[/bold cyan]")
|
||||
console.print()
|
||||
console.print(" Final stdout line is the shareable Google Drive link.")
|
||||
console.print()
|
||||
|
||||
|
||||
def run_share(file_path: str, *, public: bool = False) -> dict:
|
||||
"""Upload a single file to Drive and return a shareable link.
|
||||
|
||||
Authenticates via @api, uploads (or reuses existing), sets a
|
||||
share permission, and prints the webViewLink as the last line.
|
||||
|
||||
Args:
|
||||
file_path: Path to the file to share.
|
||||
public: If True, anyone-with-link; else restricted to owner.
|
||||
|
||||
Returns:
|
||||
Result dict with success, link, file_id, error.
|
||||
"""
|
||||
from aipass.backup.apps.handlers.drive.client import DriveClient
|
||||
from aipass.backup.apps.handlers.drive.share import share_file
|
||||
|
||||
client = DriveClient()
|
||||
if not client.authenticate():
|
||||
error = f"Drive authentication failed: {client.last_error}"
|
||||
console.print(f"[red]{error}[/red]")
|
||||
logger.warning(f"[backup] {error}")
|
||||
return {"success": False, "link": None, "file_id": None, "error": error}
|
||||
|
||||
console.print(f"[dim]Uploading {file_path}...[/dim]")
|
||||
result = share_file(client, file_path, public=public)
|
||||
|
||||
if result["success"]:
|
||||
mode = "public" if public else "restricted"
|
||||
console.print(f"[green]Shared ({mode}):[/green] {result['link']}")
|
||||
logger.info(f"[backup] Shared {file_path} ({mode})")
|
||||
else:
|
||||
console.print(f"[red]Share failed:[/red] {result['error']}")
|
||||
logger.warning(f"[backup] Share failed: {result['error']}")
|
||||
|
||||
if result.get("link"):
|
||||
console.print(result["link"], highlight=False)
|
||||
|
||||
json_handler.log_operation(
|
||||
"share_command",
|
||||
{
|
||||
"file": file_path,
|
||||
"public": public,
|
||||
"success": result["success"],
|
||||
},
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def handle_command(command: str, args: list) -> bool:
|
||||
"""Handle the share command. Returns True if handled."""
|
||||
if command != PRIMARY_COMMAND:
|
||||
return False
|
||||
|
||||
if not args:
|
||||
print_introspection()
|
||||
return True
|
||||
|
||||
if args[0] in ("--help", "-h", "help"):
|
||||
print_help()
|
||||
return True
|
||||
|
||||
file_path = args[0]
|
||||
public = "--public" in args
|
||||
|
||||
run_share(file_path, public=public)
|
||||
return True
|
||||
|
||||
|
||||
# =============================================
|
||||
|
||||
if __name__ == "__main__":
|
||||
if len(sys.argv) == 1:
|
||||
print_introspection()
|
||||
sys.exit(0)
|
||||
handle_command(PRIMARY_COMMAND, sys.argv[1:])
|
||||
sys.exit(0)
|
||||
@@ -3,3 +3,7 @@
|
||||
Branch-specific templates for `BACKUP`.
|
||||
|
||||
Any templates this branch provides to the system or uses internally. Examples: plan templates (flow), trinity templates (memory), test templates (seedgo).
|
||||
|
||||
## Files
|
||||
|
||||
- **`backupignore.template`** — Seed content for a new project's `.backupignore`. Written at `register` time by `setup._build_backupignore()`. Edit this to change the default ignore patterns for newly registered projects.
|
||||
|
||||
+2
-1
@@ -1,6 +1,6 @@
|
||||
# Backup System ignore patterns (gitignore-style)
|
||||
# Lines starting with # are comments. Blank lines are ignored.
|
||||
# Edit this file to customize. Source defaults: handlers/ignore/patterns.py
|
||||
# Edit this file to customize. Source defaults: templates/backupignore.template
|
||||
|
||||
.backup/
|
||||
.git/
|
||||
@@ -24,5 +24,6 @@ Thumbs.db
|
||||
build/
|
||||
dist/
|
||||
*.log
|
||||
logs/
|
||||
.ruff_cache/
|
||||
.coverage
|
||||
@@ -11,7 +11,6 @@
|
||||
import pathspec
|
||||
|
||||
from aipass.backup.apps.handlers.ignore.patterns import (
|
||||
BUILTIN_IGNORES,
|
||||
is_ignored,
|
||||
load_spec,
|
||||
)
|
||||
@@ -240,24 +239,61 @@ class TestDotfilesIncluded:
|
||||
|
||||
|
||||
class TestSeedTemplate:
|
||||
"""BUILTIN_IGNORES is used for seeding, not runtime merge."""
|
||||
"""Seed template (backupignore.template) provisions new projects."""
|
||||
|
||||
def test_builtin_has_ruff_cache(self):
|
||||
"""Seed defaults include .ruff_cache/."""
|
||||
assert ".ruff_cache/" in BUILTIN_IGNORES
|
||||
def test_template_has_ruff_cache(self):
|
||||
"""Seed template includes .ruff_cache/."""
|
||||
from aipass.backup.apps.handlers.project.setup import _TEMPLATE_PATH
|
||||
|
||||
def test_builtin_has_coverage(self):
|
||||
"""Seed defaults include .coverage."""
|
||||
assert ".coverage" in BUILTIN_IGNORES
|
||||
content = _TEMPLATE_PATH.read_text(encoding="utf-8")
|
||||
assert ".ruff_cache/" in content
|
||||
|
||||
def test_build_backupignore_content(self):
|
||||
"""Seed template includes ruff_cache, coverage, and pycache."""
|
||||
from aipass.backup.apps.handlers.project.setup import _build_backupignore
|
||||
def test_template_has_coverage(self):
|
||||
"""Seed template includes .coverage."""
|
||||
from aipass.backup.apps.handlers.project.setup import _TEMPLATE_PATH
|
||||
|
||||
content = _TEMPLATE_PATH.read_text(encoding="utf-8")
|
||||
assert ".coverage" in content
|
||||
|
||||
def test_template_has_logs_dir(self):
|
||||
"""Seed template excludes logs/ directories."""
|
||||
from aipass.backup.apps.handlers.project.setup import _TEMPLATE_PATH
|
||||
|
||||
content = _TEMPLATE_PATH.read_text(encoding="utf-8")
|
||||
assert "logs/" in content
|
||||
|
||||
def test_template_has_git(self):
|
||||
"""Seed template excludes .git/."""
|
||||
from aipass.backup.apps.handlers.project.setup import _TEMPLATE_PATH
|
||||
|
||||
content = _TEMPLATE_PATH.read_text(encoding="utf-8")
|
||||
assert ".git/" in content
|
||||
|
||||
def test_template_has_venv(self):
|
||||
"""Seed template excludes .venv/."""
|
||||
from aipass.backup.apps.handlers.project.setup import _TEMPLATE_PATH
|
||||
|
||||
content = _TEMPLATE_PATH.read_text(encoding="utf-8")
|
||||
assert ".venv/" in content
|
||||
|
||||
def test_build_backupignore_reads_template(self):
|
||||
"""_build_backupignore returns the template content."""
|
||||
from aipass.backup.apps.handlers.project.setup import _TEMPLATE_PATH, _build_backupignore
|
||||
|
||||
content = _build_backupignore()
|
||||
assert ".ruff_cache/" in content
|
||||
assert ".coverage" in content
|
||||
assert "__pycache__/" in content
|
||||
assert content == _TEMPLATE_PATH.read_text(encoding="utf-8")
|
||||
|
||||
def test_build_backupignore_raises_on_missing_template(self, tmp_path):
|
||||
"""Missing template raises FileNotFoundError, not empty content."""
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from aipass.backup.apps.handlers.project import setup
|
||||
|
||||
fake_path = tmp_path / "nonexistent.template"
|
||||
with patch.object(setup, "_TEMPLATE_PATH", fake_path), pytest.raises(FileNotFoundError):
|
||||
setup._build_backupignore()
|
||||
|
||||
def test_seed_writes_only_when_absent(self, tmp_path):
|
||||
"""Seeding does not overwrite an existing .backupignore."""
|
||||
|
||||
@@ -0,0 +1,408 @@
|
||||
# =================== AIPass ====================
|
||||
# Name: test_share.py
|
||||
# Description: Tests for share module + handler (mocked Drive API)
|
||||
# Version: 1.0.0
|
||||
# Created: 2026-07-01
|
||||
# Modified: 2026-07-01
|
||||
# =============================================
|
||||
|
||||
"""Tests for share — module routing, handler logic, permission paths."""
|
||||
|
||||
import importlib
|
||||
import sys
|
||||
import types
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
|
||||
def _build_mocks():
|
||||
"""Build the standard mock dict for importing share modules."""
|
||||
mocks: dict[str, object] = {}
|
||||
|
||||
prax = types.ModuleType("aipass.prax")
|
||||
setattr(prax, "logger", MagicMock())
|
||||
mocks["aipass.prax"] = prax
|
||||
|
||||
cli = types.ModuleType("aipass.cli")
|
||||
cli_apps = types.ModuleType("aipass.cli.apps")
|
||||
cli_modules = types.ModuleType("aipass.cli.apps.modules")
|
||||
setattr(cli_modules, "console", MagicMock())
|
||||
setattr(cli_modules, "header", MagicMock())
|
||||
setattr(cli_modules, "success", MagicMock())
|
||||
setattr(cli_modules, "warning", MagicMock())
|
||||
setattr(cli_modules, "error", MagicMock())
|
||||
mocks["aipass.cli"] = cli
|
||||
mocks["aipass.cli.apps"] = cli_apps
|
||||
mocks["aipass.cli.apps.modules"] = cli_modules
|
||||
|
||||
json_pkg = types.ModuleType("aipass.backup.apps.handlers.json")
|
||||
json_handler = types.ModuleType(
|
||||
"aipass.backup.apps.handlers.json.json_handler",
|
||||
)
|
||||
setattr(json_handler, "log_operation", MagicMock())
|
||||
setattr(json_handler, "load_json", MagicMock(return_value={}))
|
||||
setattr(json_handler, "save_json", MagicMock())
|
||||
mocks["aipass.backup.apps.handlers.json"] = json_pkg
|
||||
mocks["aipass.backup.apps.handlers.json.json_handler"] = json_handler
|
||||
|
||||
google_client = types.ModuleType("aipass.api.apps.modules.google_client")
|
||||
setattr(google_client, "get_drive_service", MagicMock())
|
||||
setattr(google_client, "api_call_with_retry", MagicMock())
|
||||
mocks["aipass.api.apps.modules.google_client"] = google_client
|
||||
mocks["aipass.api"] = types.ModuleType("aipass.api")
|
||||
mocks["aipass.api.apps"] = types.ModuleType("aipass.api.apps")
|
||||
mocks["aipass.api.apps.modules"] = types.ModuleType("aipass.api.apps.modules")
|
||||
|
||||
ghttp = types.ModuleType("googleapiclient.http")
|
||||
setattr(ghttp, "MediaFileUpload", MagicMock())
|
||||
mocks["googleapiclient"] = types.ModuleType("googleapiclient")
|
||||
mocks["googleapiclient.http"] = ghttp
|
||||
|
||||
return mocks
|
||||
|
||||
|
||||
def _fresh_import(module_path: str, mocks: dict):
|
||||
"""Import a module with mocked dependencies, clearing stale entries."""
|
||||
stale = [k for k in sys.modules if k.startswith(module_path.rsplit(".", 1)[0])]
|
||||
with patch.dict(sys.modules, mocks):
|
||||
for k in stale:
|
||||
sys.modules.pop(k, None)
|
||||
return importlib.import_module(module_path)
|
||||
|
||||
|
||||
# ── Module routing tests ─────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestShareModuleRouting:
|
||||
"""Verify handle_command routing for the share module."""
|
||||
|
||||
def test_handle_command_returns_true_for_primary(self) -> None:
|
||||
"""handle_command returns True for the primary command."""
|
||||
mocks = _build_mocks()
|
||||
mod = _fresh_import("aipass.backup.apps.modules.share", mocks)
|
||||
assert mod.handle_command(mod.PRIMARY_COMMAND, []) is True
|
||||
|
||||
def test_handle_command_returns_false_for_unknown(self) -> None:
|
||||
"""handle_command returns False for unknown commands."""
|
||||
mocks = _build_mocks()
|
||||
mod = _fresh_import("aipass.backup.apps.modules.share", mocks)
|
||||
assert mod.handle_command("nonexistent", []) is False
|
||||
|
||||
def test_handle_command_help(self) -> None:
|
||||
"""--help flag returns True early."""
|
||||
mocks = _build_mocks()
|
||||
mod = _fresh_import("aipass.backup.apps.modules.share", mocks)
|
||||
assert mod.handle_command(mod.PRIMARY_COMMAND, ["--help"]) is True
|
||||
|
||||
def test_handle_command_help_short(self) -> None:
|
||||
"""-h flag returns True early."""
|
||||
mocks = _build_mocks()
|
||||
mod = _fresh_import("aipass.backup.apps.modules.share", mocks)
|
||||
assert mod.handle_command(mod.PRIMARY_COMMAND, ["-h"]) is True
|
||||
|
||||
def test_handle_command_help_word(self) -> None:
|
||||
"""help word returns True early."""
|
||||
mocks = _build_mocks()
|
||||
mod = _fresh_import("aipass.backup.apps.modules.share", mocks)
|
||||
assert mod.handle_command(mod.PRIMARY_COMMAND, ["help"]) is True
|
||||
|
||||
def test_module_constants(self) -> None:
|
||||
"""MODULE_NAME and PRIMARY_COMMAND are correct."""
|
||||
mocks = _build_mocks()
|
||||
mod = _fresh_import("aipass.backup.apps.modules.share", mocks)
|
||||
assert mod.MODULE_NAME == "share"
|
||||
assert mod.PRIMARY_COMMAND == "share"
|
||||
|
||||
|
||||
# ── Handler tests ────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _make_mock_client():
|
||||
"""Build a mock DriveClient with chainable Drive API methods."""
|
||||
client = MagicMock()
|
||||
client.last_error = None
|
||||
client.file_tracker = {}
|
||||
client.backup_folder_id = None
|
||||
client.project_folder_cache = {}
|
||||
|
||||
client.get_or_create_project_folder.return_value = "folder-shared-123"
|
||||
client._find_existing_file.return_value = None
|
||||
|
||||
service = MagicMock()
|
||||
client.drive_service = service
|
||||
|
||||
service.permissions.return_value.create.return_value = MagicMock()
|
||||
service.files.return_value.get.return_value = MagicMock()
|
||||
service.files.return_value.create.return_value = MagicMock()
|
||||
service.files.return_value.update.return_value = MagicMock()
|
||||
service.files.return_value.list.return_value = MagicMock()
|
||||
service.about.return_value.get.return_value = MagicMock()
|
||||
|
||||
return client
|
||||
|
||||
|
||||
class TestShareHandler:
|
||||
"""Test the share handler functions with mocked Drive API."""
|
||||
|
||||
def test_share_file_success_public(self, tmp_path) -> None:
|
||||
"""Public share uploads, sets permission, returns webViewLink."""
|
||||
mocks = _build_mocks()
|
||||
handler = _fresh_import("aipass.backup.apps.handlers.drive.share", mocks)
|
||||
|
||||
client = _make_mock_client()
|
||||
test_file = tmp_path / "report.pdf"
|
||||
test_file.write_bytes(b"PDF content")
|
||||
|
||||
client._api_call.side_effect = [
|
||||
{"id": "file-abc-123"},
|
||||
{"id": "perm-xyz-789"},
|
||||
{"webViewLink": "https://drive.google.com/file/d/file-abc-123/view"},
|
||||
]
|
||||
|
||||
result = handler.share_file(client, str(test_file), public=True)
|
||||
|
||||
assert result["success"] is True
|
||||
assert result["link"] == "https://drive.google.com/file/d/file-abc-123/view"
|
||||
assert result["file_id"] is not None
|
||||
assert result["error"] is None
|
||||
|
||||
def test_share_file_success_restricted(self, tmp_path) -> None:
|
||||
"""Restricted share uses authenticated email for permission."""
|
||||
mocks = _build_mocks()
|
||||
handler = _fresh_import("aipass.backup.apps.handlers.drive.share", mocks)
|
||||
|
||||
client = _make_mock_client()
|
||||
test_file = tmp_path / "data.csv"
|
||||
test_file.write_text("a,b,c")
|
||||
|
||||
client._api_call.side_effect = [
|
||||
{"id": "file-def-456"},
|
||||
{"user": {"emailAddress": "test@gmail.com"}},
|
||||
{"id": "perm-abc-123"},
|
||||
{"webViewLink": "https://drive.google.com/file/d/file-def-456/view"},
|
||||
]
|
||||
|
||||
result = handler.share_file(client, str(test_file), public=False)
|
||||
|
||||
assert result["success"] is True
|
||||
assert result["link"] is not None
|
||||
assert result["error"] is None
|
||||
|
||||
def test_share_file_not_a_file(self, tmp_path) -> None:
|
||||
"""Nonexistent path returns error."""
|
||||
mocks = _build_mocks()
|
||||
handler = _fresh_import("aipass.backup.apps.handlers.drive.share", mocks)
|
||||
|
||||
client = _make_mock_client()
|
||||
result = handler.share_file(client, str(tmp_path / "nonexistent.txt"))
|
||||
|
||||
assert result["success"] is False
|
||||
assert "Not a file" in result["error"]
|
||||
|
||||
def test_share_file_directory_rejected(self, tmp_path) -> None:
|
||||
"""Directory path returns error."""
|
||||
mocks = _build_mocks()
|
||||
handler = _fresh_import("aipass.backup.apps.handlers.drive.share", mocks)
|
||||
|
||||
client = _make_mock_client()
|
||||
result = handler.share_file(client, str(tmp_path))
|
||||
|
||||
assert result["success"] is False
|
||||
assert "Not a file" in result["error"]
|
||||
|
||||
def test_share_file_upload_failure(self, tmp_path) -> None:
|
||||
"""Upload failure surfaces error."""
|
||||
mocks = _build_mocks()
|
||||
handler = _fresh_import("aipass.backup.apps.handlers.drive.share", mocks)
|
||||
|
||||
client = _make_mock_client()
|
||||
client.get_or_create_project_folder.return_value = None
|
||||
client.last_error = "Folder creation failed"
|
||||
|
||||
test_file = tmp_path / "fail.txt"
|
||||
test_file.write_text("content")
|
||||
|
||||
result = handler.share_file(client, str(test_file))
|
||||
|
||||
assert result["success"] is False
|
||||
assert "Upload failed" in result["error"]
|
||||
|
||||
def test_share_file_permission_failure(self, tmp_path) -> None:
|
||||
"""Permission failure after upload surfaces error."""
|
||||
mocks = _build_mocks()
|
||||
handler = _fresh_import("aipass.backup.apps.handlers.drive.share", mocks)
|
||||
|
||||
client = _make_mock_client()
|
||||
client._find_existing_file.return_value = {"id": "existing-file-id"}
|
||||
|
||||
client._api_call.side_effect = [
|
||||
{"user": {"emailAddress": "test@gmail.com"}},
|
||||
None,
|
||||
]
|
||||
client.last_error = "Permission denied"
|
||||
|
||||
test_file = tmp_path / "secret.txt"
|
||||
test_file.write_text("restricted")
|
||||
|
||||
result = handler.share_file(client, str(test_file))
|
||||
|
||||
assert result["success"] is False
|
||||
assert "Permission failed" in result["error"]
|
||||
|
||||
def test_share_file_link_retrieval_failure(self, tmp_path) -> None:
|
||||
"""Link retrieval failure after permission surfaces error."""
|
||||
mocks = _build_mocks()
|
||||
handler = _fresh_import("aipass.backup.apps.handlers.drive.share", mocks)
|
||||
|
||||
client = _make_mock_client()
|
||||
client._find_existing_file.return_value = {"id": "file-id"}
|
||||
|
||||
client._api_call.side_effect = [
|
||||
{"id": "perm-id"},
|
||||
None,
|
||||
]
|
||||
client.last_error = "API error"
|
||||
|
||||
test_file = tmp_path / "doc.txt"
|
||||
test_file.write_text("document")
|
||||
|
||||
result = handler.share_file(client, str(test_file), public=True)
|
||||
|
||||
assert result["success"] is False
|
||||
assert "Link retrieval failed" in result["error"]
|
||||
|
||||
def test_idempotent_reuses_existing(self, tmp_path) -> None:
|
||||
"""Existing file on Drive is reused, not re-uploaded."""
|
||||
mocks = _build_mocks()
|
||||
handler = _fresh_import("aipass.backup.apps.handlers.drive.share", mocks)
|
||||
|
||||
client = _make_mock_client()
|
||||
client._find_existing_file.return_value = {"id": "already-on-drive"}
|
||||
|
||||
client._api_call.side_effect = [
|
||||
{"id": "perm-id"},
|
||||
{"webViewLink": "https://drive.google.com/file/d/already-on-drive/view"},
|
||||
]
|
||||
|
||||
test_file = tmp_path / "existing.txt"
|
||||
test_file.write_text("already uploaded")
|
||||
|
||||
result = handler.share_file(client, str(test_file), public=True)
|
||||
|
||||
assert result["success"] is True
|
||||
assert result["file_id"] == "already-on-drive"
|
||||
client.get_or_create_project_folder.assert_called_once_with("Shared")
|
||||
|
||||
def test_webcontentlink_fallback(self, tmp_path) -> None:
|
||||
"""Falls back to webContentLink when webViewLink is absent."""
|
||||
mocks = _build_mocks()
|
||||
handler = _fresh_import("aipass.backup.apps.handlers.drive.share", mocks)
|
||||
|
||||
client = _make_mock_client()
|
||||
client._find_existing_file.return_value = {"id": "file-id"}
|
||||
|
||||
client._api_call.side_effect = [
|
||||
{"id": "perm-id"},
|
||||
{"webContentLink": "https://drive.google.com/uc?id=file-id"},
|
||||
]
|
||||
|
||||
test_file = tmp_path / "download.bin"
|
||||
test_file.write_bytes(b"\x00\x01")
|
||||
|
||||
result = handler.share_file(client, str(test_file), public=True)
|
||||
|
||||
assert result["success"] is True
|
||||
assert "uc?id=file-id" in result["link"]
|
||||
|
||||
|
||||
class TestSetSharePermission:
|
||||
"""Test permission-setting specifically."""
|
||||
|
||||
def test_public_permission_body(self, tmp_path) -> None:
|
||||
"""Public permission uses type=anyone, role=reader."""
|
||||
mocks = _build_mocks()
|
||||
handler = _fresh_import("aipass.backup.apps.handlers.drive.share", mocks)
|
||||
|
||||
client = _make_mock_client()
|
||||
client._api_call.return_value = {"id": "perm-id"}
|
||||
|
||||
handler.set_share_permission(client, "file-123", public=True)
|
||||
|
||||
call_args = client.drive_service.permissions().create.call_args
|
||||
assert call_args.kwargs["body"]["type"] == "anyone"
|
||||
assert call_args.kwargs["body"]["role"] == "reader"
|
||||
|
||||
def test_restricted_permission_body(self, tmp_path) -> None:
|
||||
"""Restricted permission uses type=user with authenticated email."""
|
||||
mocks = _build_mocks()
|
||||
handler = _fresh_import("aipass.backup.apps.handlers.drive.share", mocks)
|
||||
|
||||
client = _make_mock_client()
|
||||
client._api_call.side_effect = [
|
||||
{"user": {"emailAddress": "user@example.com"}},
|
||||
{"id": "perm-id"},
|
||||
]
|
||||
|
||||
handler.set_share_permission(client, "file-123", public=False)
|
||||
|
||||
call_args = client.drive_service.permissions().create.call_args
|
||||
assert call_args.kwargs["body"]["type"] == "user"
|
||||
assert call_args.kwargs["body"]["emailAddress"] == "user@example.com"
|
||||
|
||||
def test_restricted_fails_without_email(self) -> None:
|
||||
"""Restricted permission fails when email lookup returns None."""
|
||||
mocks = _build_mocks()
|
||||
handler = _fresh_import("aipass.backup.apps.handlers.drive.share", mocks)
|
||||
|
||||
client = _make_mock_client()
|
||||
client._api_call.return_value = None
|
||||
|
||||
result = handler.set_share_permission(client, "file-123", public=False)
|
||||
|
||||
assert result is None
|
||||
assert "email" in client.last_error.lower()
|
||||
|
||||
|
||||
class TestGetShareLink:
|
||||
"""Test link retrieval."""
|
||||
|
||||
def test_prefers_webviewlink(self) -> None:
|
||||
"""webViewLink is preferred over webContentLink."""
|
||||
mocks = _build_mocks()
|
||||
handler = _fresh_import("aipass.backup.apps.handlers.drive.share", mocks)
|
||||
|
||||
client = _make_mock_client()
|
||||
client._api_call.return_value = {
|
||||
"webViewLink": "https://view-link",
|
||||
"webContentLink": "https://content-link",
|
||||
}
|
||||
|
||||
link = handler.get_share_link(client, "file-id")
|
||||
assert link == "https://view-link"
|
||||
|
||||
def test_falls_back_to_webcontentlink(self) -> None:
|
||||
"""Falls back to webContentLink when webViewLink is None."""
|
||||
mocks = _build_mocks()
|
||||
handler = _fresh_import("aipass.backup.apps.handlers.drive.share", mocks)
|
||||
|
||||
client = _make_mock_client()
|
||||
client._api_call.return_value = {
|
||||
"webViewLink": None,
|
||||
"webContentLink": "https://content-link",
|
||||
}
|
||||
|
||||
link = handler.get_share_link(client, "file-id")
|
||||
assert link == "https://content-link"
|
||||
|
||||
def test_returns_none_on_api_failure(self) -> None:
|
||||
"""Returns None when API call fails."""
|
||||
mocks = _build_mocks()
|
||||
handler = _fresh_import("aipass.backup.apps.handlers.drive.share", mocks)
|
||||
|
||||
client = _make_mock_client()
|
||||
client._api_call.return_value = None
|
||||
|
||||
link = handler.get_share_link(client, "file-id")
|
||||
assert link is None
|
||||
|
||||
|
||||
# =============================================
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"version": 1,
|
||||
"branch": "@cli",
|
||||
"jobs": [
|
||||
{
|
||||
"id": "wake-test",
|
||||
"enabled": false,
|
||||
"schedule": {
|
||||
"type": "interval",
|
||||
"interval_minutes": 5
|
||||
},
|
||||
"wake": {
|
||||
"fresh": true
|
||||
},
|
||||
"prompt": "AUTOMATED DAEMON TEST. Do ONLY this: run drone @ai_mail email @devpulse \"DAEMON TEST \u2014 cli\" \"Woke via @daemon systemd timer. No memory touched.\" then STOP. Do NOT run startup, do NOT update memory, do NOT do anything else."
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -7,10 +7,11 @@
|
||||
"enabled": false,
|
||||
"schedule": {
|
||||
"type": "interval",
|
||||
"interval_minutes": 1
|
||||
"interval_minutes": 5
|
||||
},
|
||||
"wake": {
|
||||
"fresh": true
|
||||
"fresh": true,
|
||||
"model": "haiku"
|
||||
},
|
||||
"prompt": "AUTOMATED DAEMON TEST. Do ONLY this: run drone @ai_mail email @devpulse \"DAEMON TEST FIRED\" \"Wake received from @daemon scheduler. No memory updated.\" then STOP. Do NOT run startup, do NOT update memory, do NOT do anything else."
|
||||
}
|
||||
|
||||
@@ -5,18 +5,6 @@
|
||||
"description": "Standards bypass configuration for this branch"
|
||||
},
|
||||
"bypass": [
|
||||
{
|
||||
"file": "apps/scheduler_cron.py",
|
||||
"standard": "naming",
|
||||
"reason": "False positive: get_due_tasks, mark_dispatching, mark_completed are function references assigned at module level from task_registry imports, not constants",
|
||||
"pattern": "get_due_tasks|mark_dispatching|mark_completed"
|
||||
},
|
||||
{
|
||||
"file": "apps/modules/scheduler_ops.py",
|
||||
"standard": "naming",
|
||||
"reason": "False positive: get_due_tasks, mark_dispatching, mark_completed are function re-exports from task_registry, not constants",
|
||||
"pattern": "get_due_tasks|mark_dispatching|mark_completed"
|
||||
},
|
||||
{
|
||||
"file": "apps/modules/schedule.py",
|
||||
"standard": "naming",
|
||||
@@ -47,30 +35,12 @@
|
||||
"reason": "False positive: red_flags, activity, health_data are local variables inside functions, not module-level constants",
|
||||
"pattern": "red_flags|activity|health_data"
|
||||
},
|
||||
{
|
||||
"file": "apps/handlers/schedule/task_registry.py",
|
||||
"standard": "naming",
|
||||
"reason": "False positive: task_result, email_body, to_branch are local variables inside functions, not module-level constants",
|
||||
"pattern": "task_result|email_body|to_branch"
|
||||
},
|
||||
{
|
||||
"file": "apps/handlers/actions/actions_registry.py",
|
||||
"standard": "naming",
|
||||
"reason": "False positive: action is a local variable inside functions, not a module-level constant. File name matches its domain (actions/actions_registry.py) — renaming would lose specificity",
|
||||
"pattern": "action|actions_registry"
|
||||
},
|
||||
{
|
||||
"file": "apps/handlers/actions/action_processor.py",
|
||||
"standard": "naming",
|
||||
"reason": "False positive: load_registry, is_action_due, update_last_run are function references from try/except import fallback, not constants",
|
||||
"pattern": "load_registry|is_action_due|update_last_run"
|
||||
},
|
||||
{
|
||||
"file": "apps/scheduler_cron.py",
|
||||
"standard": "architecture",
|
||||
"reason": "Entry point script — lives in apps/ root by design, not a module or handler",
|
||||
"pattern": "File not in standard 3-layer structure"
|
||||
},
|
||||
{
|
||||
"file": "apps/daemon_wakeup.py",
|
||||
"standard": "architecture",
|
||||
@@ -83,18 +53,6 @@
|
||||
"reason": "Branch entry point — lives in apps/ root by design per AIPass convention",
|
||||
"pattern": "File not in standard 3-layer structure"
|
||||
},
|
||||
{
|
||||
"file": "apps/scheduler_cron.py",
|
||||
"standard": "encapsulation",
|
||||
"reason": "scheduler_cron.py is itself an entry point script that directly uses handler functions — not a module violating encapsulation",
|
||||
"pattern": "Handler imported directly"
|
||||
},
|
||||
{
|
||||
"file": "apps/handlers/schedule/task_registry.py",
|
||||
"standard": "unused_function",
|
||||
"reason": "Public API functions used by tests (test_task_registry.py) and available for external callers",
|
||||
"pattern": "get_task_by_id|get_pending_tasks"
|
||||
},
|
||||
{
|
||||
"file": "apps/plugins/__init__.py",
|
||||
"standard": "unused_function",
|
||||
@@ -107,6 +65,18 @@
|
||||
"reason": "Authorized cross-branch wake_branch import (DPLAN-0204 §2.8 path A). ai_mail exposes wake_branch only via its handler — ai_mail's own modules and @trigger import it identically; no module entry point exists to use. Root fix (an ai_mail module wrapper) is tracked separately.",
|
||||
"pattern": "Handler imported directly"
|
||||
},
|
||||
{
|
||||
"file": "apps/handlers/schedule/telegram_notifier.py",
|
||||
"standard": "encapsulation",
|
||||
"reason": "Authorized cross-branch import (TDPLAN-0008 contract). daemon emits lifecycle pings via skills telegram notifier — the only send path; no module wrapper exists.",
|
||||
"pattern": "Handler imported directly"
|
||||
},
|
||||
{
|
||||
"file": "apps/handlers/schedule/telegram_notifier.py",
|
||||
"standard": "handlers",
|
||||
"reason": "Authorized cross-branch import (TDPLAN-0008 contract). send_telegram_notification lives in @skills handler — daemon's notifier wraps it fail-soft.",
|
||||
"pattern": "Cross-handler imports"
|
||||
},
|
||||
{
|
||||
"file": "apps/modules/run.py",
|
||||
"standard": "introspection",
|
||||
@@ -142,6 +112,54 @@
|
||||
"standard": "architecture",
|
||||
"reason": "Scheduler plugin — autodiscovered by plugins/__init__.py discover_plugins(). Lives in apps/plugins/ by design, not a module or handler.",
|
||||
"pattern": "File not in standard 3-layer structure"
|
||||
},
|
||||
{
|
||||
"file": "tests/test_timer_install.py",
|
||||
"standard": "architecture",
|
||||
"reason": "Test file — lives in tests/ by convention, not in 3-layer apps/ structure",
|
||||
"pattern": "File not in standard 3-layer structure"
|
||||
},
|
||||
{
|
||||
"file": "tests/test_run_module.py",
|
||||
"standard": "architecture",
|
||||
"reason": "Test file — lives in tests/ by convention, not in 3-layer apps/ structure",
|
||||
"pattern": "File not in standard 3-layer structure"
|
||||
},
|
||||
{
|
||||
"file": "tests/test_run_module.py",
|
||||
"standard": "documentation",
|
||||
"reason": "Test methods are self-documenting via descriptive names — pytest convention",
|
||||
"pattern": "missing docstrings"
|
||||
},
|
||||
{
|
||||
"file": "tests/test_scheduler_bot.py",
|
||||
"standard": "architecture",
|
||||
"reason": "Test file — lives in tests/ by convention, not in 3-layer apps/ structure",
|
||||
"pattern": "File not in standard 3-layer structure"
|
||||
},
|
||||
{
|
||||
"file": "tests/test_scheduler_bot.py",
|
||||
"standard": "encapsulation",
|
||||
"reason": "Test file imports handlers directly to unit-test them — standard pytest pattern",
|
||||
"pattern": "Handler imported directly"
|
||||
},
|
||||
{
|
||||
"file": "apps/modules/queue.py",
|
||||
"standard": "introspection",
|
||||
"reason": "Bare 'queue' renders the operational Rich table (td-47). Introspection gate would break the primary use case — users expect the job table, not module metadata.",
|
||||
"pattern": "no-args gate"
|
||||
},
|
||||
{
|
||||
"file": "apps/modules/timer_install.py",
|
||||
"standard": "introspection",
|
||||
"reason": "install-timer/uninstall-timer are action commands — no-args IS the action (install). Introspection gate would break primary use case (same pattern as run.py).",
|
||||
"pattern": "no-args gate"
|
||||
},
|
||||
{
|
||||
"file": "apps/modules/timer_install.py",
|
||||
"standard": "modules",
|
||||
"reason": "Installer module — mkdir for ~/.config/systemd/user/ is the core purpose. Direct file ops are inherent to the install function.",
|
||||
"pattern": "direct file operations"
|
||||
}
|
||||
],
|
||||
"notes": {
|
||||
|
||||
@@ -94,6 +94,8 @@ drone @daemon actions list # Action registry
|
||||
drone @daemon actions <id> on/off # Toggle action
|
||||
drone @daemon actions set reminder 7d "msg" --to @branch
|
||||
drone @daemon actions set schedule @branch "prompt" daily 04:00
|
||||
drone @daemon install-timer # Install + enable systemd user timer
|
||||
drone @daemon uninstall-timer # Stop + remove systemd user timer
|
||||
```
|
||||
|
||||
Each module accepts `--help` for module-specific usage:
|
||||
@@ -108,11 +110,56 @@ drone @daemon <command> --help
|
||||
| Module | Description | Status |
|
||||
|--------|-------------|--------|
|
||||
| `update` | Status digest of DAEMON activity | *(partial)* — reads inbox/sessions but data_loader paths return empty |
|
||||
| `schedule` | Fire-and-forget scheduled follow-ups and task management | Operational |
|
||||
| `queue` | Unified job queue view — Rich table or `--json` (frozen schema for @skills bot) | Operational |
|
||||
| `schedule` | *(retired)* Fire-and-forget follow-ups — superseded by `.daemon/schedule.json` | Retired |
|
||||
| `activity_report` | Branch activity reports: `activity`, `activity-report`, `branch-health` | Operational |
|
||||
| `actions` | Action registry CLI — list, toggle, info, set reminder, set schedule, migrate | Operational |
|
||||
| `actions` | *(retired)* Action registry — superseded by `.daemon/schedule.json` | Retired |
|
||||
| `scheduler_ops` | Scheduler cron operations facade for scheduler_cron.py | Operational |
|
||||
| `wakeup_ops` | Wake-up cron operations facade for daemon_wakeup.py | Operational |
|
||||
| `timer_install` | Idempotent systemd user timer installer for daemon scheduler | Operational |
|
||||
| `run` | Decentralized scheduler tick: discover .daemon/ jobs, fire due ones | Operational |
|
||||
|
||||
---
|
||||
|
||||
## Scheduling Jobs
|
||||
|
||||
Each branch owns its schedule at `src/aipass/<branch>/.daemon/schedule.json`. The daemon discovers and fires — branches define their own jobs.
|
||||
|
||||
### Job file schema
|
||||
|
||||
```json
|
||||
{
|
||||
"version": 1,
|
||||
"branch": "@<branch>",
|
||||
"jobs": [
|
||||
{
|
||||
"id": "my-job",
|
||||
"enabled": true,
|
||||
"schedule": { "type": "interval", "interval_minutes": 30 },
|
||||
"wake": { "fresh": true, "model": "haiku" },
|
||||
"prompt": "Do something, then STOP."
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Schedule types
|
||||
|
||||
| Type | Fields | Due when |
|
||||
|------|--------|----------|
|
||||
| `interval` | `interval_minutes: N` | Elapsed >= N since last_run. Fires immediately if never run. |
|
||||
| `daily` | `time: "HH:MM"` | Within +/-15 min of target time, once per day. |
|
||||
| `hourly` | `time: "M"` (minute) | Within +/-15 min of target minute, once per hour. |
|
||||
| `once` | `due_date: "YYYY-MM-DD"` | Date <= today, then marks completed. |
|
||||
|
||||
### Wake options
|
||||
|
||||
- `fresh` (bool) — start a fresh Claude session (true) or resume (false)
|
||||
- `model` (string, optional) — `"haiku"` or `"sonnet"` recommended for light wakes
|
||||
|
||||
### Staggering
|
||||
|
||||
No native offset field. To stagger jobs, seed different `last_run` values in `daemon_json/daemon_runstate.json`.
|
||||
|
||||
---
|
||||
|
||||
@@ -160,11 +207,11 @@ drone @daemon <command> --help
|
||||
|
||||
## Test Suite
|
||||
|
||||
- **448 tests** across 19 test files
|
||||
- 8/8 modules covered, 43/51 public functions tested
|
||||
- **300 tests** across 15 test files
|
||||
- 8/8 modules covered, 30/36 public functions tested
|
||||
- Seedgo audit: **100%** across all standards
|
||||
|
||||
*Last Updated: 2026-04-07*
|
||||
*Last Updated: 2026-06-29*
|
||||
|
||||
---
|
||||
[← Back to AIPass](../../../README.md)
|
||||
|
||||
@@ -24,7 +24,7 @@ from aipass.prax.apps.modules.logger import system_logger as logger
|
||||
# Console
|
||||
from aipass.cli.apps.modules import console, error
|
||||
from aipass.daemon.apps.handlers.json import json_handler
|
||||
from aipass.daemon.apps.modules import update, schedule, activity_report, actions, run
|
||||
from aipass.daemon.apps.modules import update, schedule, activity_report, actions, run, timer_install, queue
|
||||
|
||||
|
||||
def _header(text):
|
||||
@@ -46,7 +46,7 @@ def get_modules() -> List[Any]:
|
||||
List of module objects with handle_command function
|
||||
"""
|
||||
modules = []
|
||||
for mod in [update, schedule, activity_report, actions, run]:
|
||||
for mod in [update, schedule, activity_report, actions, run, timer_install, queue]:
|
||||
if hasattr(mod, "handle_command"):
|
||||
modules.append(mod)
|
||||
return modules
|
||||
@@ -136,12 +136,15 @@ def print_help(modules: List[Any]):
|
||||
# Show actual routable commands, not module names
|
||||
_COMMAND_HELP = [
|
||||
("update", "Returns digest of DAEMON activity for check-ins."),
|
||||
("schedule", "CLI interface for fire-and-forget scheduled follow-ups."),
|
||||
("queue", "Unified job queue view (--json for frozen schema)."),
|
||||
("activity", "Quick 24-hour activity summary."),
|
||||
("activity-report", "Full detailed activity report (--json for raw)."),
|
||||
("branch-health", "Single branch deep dive (e.g., branch-health DAEMON)."),
|
||||
("actions", "CLI interface for the numbered action registry."),
|
||||
("run", "One scheduler tick: discover .daemon/ jobs, fire due ones."),
|
||||
("install-timer", "Install + enable daemon-tick systemd user timer (~2 min)."),
|
||||
("uninstall-timer", "Stop + remove daemon-tick systemd user timer."),
|
||||
("schedule", "(retired) Use .daemon/schedule.json — see run --help."),
|
||||
("actions", "(retired) Use .daemon/schedule.json — see run --help."),
|
||||
]
|
||||
|
||||
for cmd_name, desc in _COMMAND_HELP:
|
||||
|
||||
@@ -1,550 +0,0 @@
|
||||
# =================== AIPass ====================
|
||||
# Name: actions_registry.py
|
||||
# Description: Numbered Action Registry
|
||||
# Version: 1.0.0
|
||||
# Created: 2026-03-02
|
||||
# Modified: 2026-03-02
|
||||
# =============================================
|
||||
|
||||
"""
|
||||
Numbered Action Registry — DPLAN-043
|
||||
|
||||
Central registry for all scheduled actions. Each action gets a sequential
|
||||
numeric ID (0001, 0002, ...) and can be individually toggled on/off.
|
||||
|
||||
Replaces the old all-or-nothing daemon + kill switch model with granular
|
||||
per-action control.
|
||||
|
||||
Action types:
|
||||
- plugin: Backed by a plugin file in apps/plugins/ (migrated from existing system)
|
||||
- schedule: Custom recurring action (dispatches via wake.py)
|
||||
- reminder: One-shot action that auto-completes after firing
|
||||
"""
|
||||
|
||||
import json
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from aipass.prax import logger
|
||||
from aipass.daemon.apps.handlers.json import json_handler
|
||||
|
||||
# logger imported from aipass.prax
|
||||
|
||||
# Paths
|
||||
_DAEMON_ROOT = Path(__file__).resolve().parents[3] # src/aipass/daemon/
|
||||
REGISTRY_FILE = _DAEMON_ROOT / "daemon_json" / "actions_registry.json"
|
||||
PLUGINS_DIR = _DAEMON_ROOT / "apps" / "plugins"
|
||||
|
||||
|
||||
def _empty_registry() -> dict:
|
||||
"""Return a fresh empty registry structure (avoids shared mutable state)."""
|
||||
return {"version": 1, "next_id": 1, "actions": []}
|
||||
|
||||
|
||||
# =============================================
|
||||
# STORAGE
|
||||
# =============================================
|
||||
|
||||
|
||||
def load_registry() -> dict:
|
||||
"""Load the actions registry from disk. Returns empty registry if missing."""
|
||||
if not REGISTRY_FILE.exists():
|
||||
return _empty_registry().copy()
|
||||
try:
|
||||
with open(REGISTRY_FILE, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
if "actions" not in data:
|
||||
data["actions"] = []
|
||||
if "next_id" not in data:
|
||||
data["next_id"] = 1
|
||||
return data
|
||||
except (json.JSONDecodeError, OSError) as e:
|
||||
logger.error("[actions_registry] Failed to load: %s", e)
|
||||
return _empty_registry().copy()
|
||||
|
||||
|
||||
def save_registry(data: dict) -> bool:
|
||||
"""Save the actions registry to disk. Returns True on success."""
|
||||
try:
|
||||
REGISTRY_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(REGISTRY_FILE, "w", encoding="utf-8") as f:
|
||||
json.dump(data, f, indent=2)
|
||||
f.write("\n")
|
||||
return True
|
||||
except OSError as e:
|
||||
logger.error("[actions_registry] Failed to save: %s", e)
|
||||
return False
|
||||
|
||||
|
||||
# =============================================
|
||||
# ID GENERATION
|
||||
# =============================================
|
||||
|
||||
|
||||
def _get_next_id(registry: dict) -> str:
|
||||
"""Get next sequential ID as 4-digit string. Advances next_id."""
|
||||
next_num = registry.get("next_id", 1)
|
||||
action_id = f"{next_num:04d}"
|
||||
registry["next_id"] = next_num + 1
|
||||
return action_id
|
||||
|
||||
|
||||
# =============================================
|
||||
# CRUD OPERATIONS
|
||||
# =============================================
|
||||
|
||||
|
||||
def create_action(
|
||||
name: str,
|
||||
action_type: str,
|
||||
schedule_type: str,
|
||||
target_branch: str = "",
|
||||
prompt: str = "",
|
||||
time: Optional[str] = None,
|
||||
interval_minutes: Optional[int] = None,
|
||||
due_date: Optional[str] = None,
|
||||
fresh: bool = True,
|
||||
max_turns: int = 50,
|
||||
enabled: bool = True,
|
||||
self_dispatch: bool = False,
|
||||
plugin_file: Optional[str] = None,
|
||||
) -> dict:
|
||||
"""
|
||||
Create a new action and save to registry.
|
||||
|
||||
Args:
|
||||
name: Human-readable action name (e.g., "daily_audit")
|
||||
action_type: "plugin" | "schedule" | "reminder"
|
||||
schedule_type: "daily" | "hourly" | "interval" | "once"
|
||||
target_branch: Target branch email (e.g., "@seedgo")
|
||||
prompt: What the dispatched agent should do
|
||||
time: For daily: "HH:MM", for hourly: "MM"
|
||||
interval_minutes: For interval schedule type
|
||||
due_date: For reminder (once) type, ISO date string
|
||||
fresh: Start fresh session (True) or resume (False)
|
||||
max_turns: Max agent turns
|
||||
enabled: Active by default
|
||||
self_dispatch: Plugin handles its own dispatch
|
||||
plugin_file: Plugin filename (without .py) for plugin-backed actions
|
||||
|
||||
Returns:
|
||||
The created action dict
|
||||
"""
|
||||
registry = load_registry()
|
||||
action_id = _get_next_id(registry)
|
||||
|
||||
action = {
|
||||
"id": action_id,
|
||||
"name": name,
|
||||
"type": action_type,
|
||||
"schedule_type": schedule_type,
|
||||
"time": time,
|
||||
"interval_minutes": interval_minutes,
|
||||
"due_date": due_date,
|
||||
"target_branch": target_branch,
|
||||
"prompt": prompt,
|
||||
"fresh": fresh,
|
||||
"max_turns": max_turns,
|
||||
"enabled": enabled,
|
||||
"self_dispatch": self_dispatch,
|
||||
"plugin_file": plugin_file,
|
||||
"last_run": None,
|
||||
"next_run": None,
|
||||
"created": datetime.now().isoformat(),
|
||||
"completed": None,
|
||||
}
|
||||
|
||||
registry["actions"].append(action)
|
||||
save_registry(registry)
|
||||
|
||||
json_handler.log_operation("action_registry_modified", {"action": name})
|
||||
logger.info("[actions_registry] Created action %s: %s (%s)", action_id, name, action_type)
|
||||
return action
|
||||
|
||||
|
||||
def get_action(action_id: str) -> Optional[dict]:
|
||||
"""Get a single action by ID. Returns None if not found."""
|
||||
registry = load_registry()
|
||||
for action in registry["actions"]:
|
||||
if action["id"] == action_id:
|
||||
return action
|
||||
return None
|
||||
|
||||
|
||||
def list_actions(include_completed: bool = False) -> list:
|
||||
"""
|
||||
List all actions.
|
||||
|
||||
Args:
|
||||
include_completed: If True, include completed reminders.
|
||||
|
||||
Returns:
|
||||
List of action dicts.
|
||||
"""
|
||||
registry = load_registry()
|
||||
actions = registry["actions"]
|
||||
if not include_completed:
|
||||
actions = [a for a in actions if a.get("completed") is None]
|
||||
return actions
|
||||
|
||||
|
||||
def toggle_action(action_id: str, enabled: bool) -> bool:
|
||||
"""Toggle an action on or off. Returns True if found and updated."""
|
||||
registry = load_registry()
|
||||
for action in registry["actions"]:
|
||||
if action["id"] == action_id:
|
||||
action["enabled"] = enabled
|
||||
save_registry(registry)
|
||||
state = "enabled" if enabled else "disabled"
|
||||
logger.info("[actions_registry] Action %s %s: %s", action_id, state, action["name"])
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def delete_action(action_id: str) -> bool:
|
||||
"""Delete an action by ID. Returns True if found and removed."""
|
||||
registry = load_registry()
|
||||
original_len = len(registry["actions"])
|
||||
registry["actions"] = [a for a in registry["actions"] if a["id"] != action_id]
|
||||
if len(registry["actions"]) < original_len:
|
||||
save_registry(registry)
|
||||
logger.info("[actions_registry] Deleted action %s", action_id)
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def update_last_run(action_id: str, timestamp: Optional[str] = None) -> bool:
|
||||
"""Update last_run timestamp for an action. Returns True if found."""
|
||||
if timestamp is None:
|
||||
timestamp = datetime.now().isoformat()
|
||||
registry = load_registry()
|
||||
for action in registry["actions"]:
|
||||
if action["id"] == action_id:
|
||||
action["last_run"] = timestamp
|
||||
action["next_run"] = calc_next_run(action)
|
||||
save_registry(registry)
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def mark_reminder_completed(action_id: str) -> bool:
|
||||
"""Mark a reminder as completed (one-shot). Returns True if found."""
|
||||
registry = load_registry()
|
||||
for action in registry["actions"]:
|
||||
if action["id"] == action_id:
|
||||
action["completed"] = datetime.now().isoformat()
|
||||
action["enabled"] = False
|
||||
save_registry(registry)
|
||||
logger.info("[actions_registry] Reminder %s completed: %s", action_id, action["name"])
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
# =============================================
|
||||
# DUE CHECKING
|
||||
# =============================================
|
||||
|
||||
|
||||
def _already_ran_today(action: dict, now: datetime) -> bool:
|
||||
"""Check if a daily action already ran today."""
|
||||
last_run = action.get("last_run")
|
||||
if not last_run:
|
||||
return False
|
||||
try:
|
||||
last_dt = datetime.fromisoformat(last_run)
|
||||
return last_dt.date() == now.date()
|
||||
except (ValueError, TypeError) as e:
|
||||
logger.info("[actions_registry] Daily last_run parse failed: %s", e)
|
||||
return False
|
||||
|
||||
|
||||
def _already_ran_this_hour(action: dict, now: datetime) -> bool:
|
||||
"""Check if an hourly action already ran this hour."""
|
||||
last_run = action.get("last_run")
|
||||
if not last_run:
|
||||
return False
|
||||
try:
|
||||
last_dt = datetime.fromisoformat(last_run)
|
||||
return last_dt.hour == now.hour and last_dt.date() == now.date()
|
||||
except (ValueError, TypeError) as e:
|
||||
logger.info("[actions_registry] Hourly last_run parse failed: %s", e)
|
||||
return False
|
||||
|
||||
|
||||
def _is_daily_due(action: dict, now: datetime) -> bool:
|
||||
"""Check if a daily action is due."""
|
||||
target_time = action.get("time", "00:00")
|
||||
try:
|
||||
target_h, target_m = map(int, target_time.split(":"))
|
||||
except (ValueError, AttributeError) as e:
|
||||
logger.info("[actions_registry] Daily time parse failed for %r: %s", target_time, e)
|
||||
return False
|
||||
current_minutes = now.hour * 60 + now.minute
|
||||
target_minutes = target_h * 60 + target_m
|
||||
minutes_diff = abs(current_minutes - target_minutes)
|
||||
minutes_diff = min(minutes_diff, 1440 - minutes_diff)
|
||||
if minutes_diff > 15:
|
||||
return False
|
||||
return not _already_ran_today(action, now)
|
||||
|
||||
|
||||
def _is_hourly_due(action: dict, now: datetime) -> bool:
|
||||
"""Check if an hourly action is due."""
|
||||
target_m_str = action.get("time", "0")
|
||||
try:
|
||||
target_m = int(target_m_str)
|
||||
except (ValueError, TypeError) as e:
|
||||
logger.info("[actions_registry] Hourly time parse failed for %r: %s", target_m_str, e)
|
||||
return False
|
||||
minutes_diff = abs(now.minute - target_m)
|
||||
minutes_diff = min(minutes_diff, 60 - minutes_diff)
|
||||
if minutes_diff > 15:
|
||||
return False
|
||||
return not _already_ran_this_hour(action, now)
|
||||
|
||||
|
||||
def _is_interval_due(action: dict, now: datetime) -> bool:
|
||||
"""Check if an interval action is due."""
|
||||
interval = action.get("interval_minutes", 60)
|
||||
last_run = action.get("last_run")
|
||||
if not last_run:
|
||||
return True
|
||||
try:
|
||||
last_dt = datetime.fromisoformat(last_run)
|
||||
elapsed = (now - last_dt).total_seconds() / 60
|
||||
return elapsed >= interval
|
||||
except (ValueError, TypeError) as e:
|
||||
logger.info("[actions_registry] Interval last_run parse failed: %s", e)
|
||||
return True
|
||||
|
||||
|
||||
def _is_once_due(action: dict, now: datetime) -> bool:
|
||||
"""Check if a one-shot reminder action is due."""
|
||||
due_date = action.get("due_date")
|
||||
if not due_date:
|
||||
return False
|
||||
try:
|
||||
due_dt = (
|
||||
datetime.fromisoformat(due_date).date()
|
||||
if "T" in due_date
|
||||
else datetime.strptime(due_date, "%Y-%m-%d").date()
|
||||
)
|
||||
return now.date() >= due_dt
|
||||
except (ValueError, TypeError) as e:
|
||||
logger.info("[actions_registry] Once due_date parse failed for %r: %s", due_date, e)
|
||||
return False
|
||||
|
||||
|
||||
def is_action_due(action: dict) -> bool:
|
||||
"""
|
||||
Check if an action should run now.
|
||||
|
||||
For daily: matches current hour:minute, hasn't run today
|
||||
For hourly: matches current minute, hasn't run this hour
|
||||
For interval: enough time has elapsed since last run
|
||||
For once (reminder): due_date <= today, not completed
|
||||
"""
|
||||
if not action.get("enabled", False):
|
||||
return False
|
||||
|
||||
if action.get("completed"):
|
||||
return False
|
||||
|
||||
now = datetime.now()
|
||||
schedule_type = action.get("schedule_type", "")
|
||||
|
||||
_due_checkers = {
|
||||
"daily": _is_daily_due,
|
||||
"hourly": _is_hourly_due,
|
||||
"interval": _is_interval_due,
|
||||
"once": _is_once_due,
|
||||
}
|
||||
checker = _due_checkers.get(schedule_type)
|
||||
if checker is None:
|
||||
return False
|
||||
return checker(action, now)
|
||||
|
||||
|
||||
def _calc_next_daily(action: dict, now: datetime) -> Optional[str]:
|
||||
"""Calculate next run for a daily action."""
|
||||
target_time = action.get("time", "00:00")
|
||||
try:
|
||||
target_h, target_m = map(int, target_time.split(":"))
|
||||
except (ValueError, AttributeError) as e:
|
||||
logger.info("[actions_registry] calc_next_run daily time parse failed: %s", e)
|
||||
return None
|
||||
next_dt = now.replace(hour=target_h, minute=target_m, second=0, microsecond=0)
|
||||
if next_dt <= now:
|
||||
next_dt += timedelta(days=1)
|
||||
return next_dt.isoformat()
|
||||
|
||||
|
||||
def _calc_next_hourly(action: dict, now: datetime) -> Optional[str]:
|
||||
"""Calculate next run for an hourly action."""
|
||||
target_m_str = action.get("time", "0")
|
||||
try:
|
||||
target_m = int(target_m_str)
|
||||
except (ValueError, TypeError) as e:
|
||||
logger.info("[actions_registry] calc_next_run hourly time parse failed: %s", e)
|
||||
return None
|
||||
next_dt = now.replace(minute=target_m, second=0, microsecond=0)
|
||||
if next_dt <= now:
|
||||
next_dt += timedelta(hours=1)
|
||||
return next_dt.isoformat()
|
||||
|
||||
|
||||
def _calc_next_interval(action: dict, now: datetime) -> Optional[str]:
|
||||
"""Calculate next run for an interval action."""
|
||||
interval = action.get("interval_minutes", 60)
|
||||
last_run = action.get("last_run")
|
||||
if not last_run:
|
||||
return now.isoformat()
|
||||
try:
|
||||
last_dt = datetime.fromisoformat(last_run)
|
||||
return (last_dt + timedelta(minutes=interval)).isoformat()
|
||||
except (ValueError, TypeError) as e:
|
||||
logger.info("[actions_registry] calc_next_run interval last_run parse failed: %s", e)
|
||||
return now.isoformat()
|
||||
|
||||
|
||||
def calc_next_run(action: dict) -> Optional[str]:
|
||||
"""Calculate the next run time for an action. Returns ISO string or None."""
|
||||
now = datetime.now()
|
||||
schedule_type = action.get("schedule_type", "")
|
||||
|
||||
if schedule_type == "daily":
|
||||
return _calc_next_daily(action, now)
|
||||
if schedule_type == "hourly":
|
||||
return _calc_next_hourly(action, now)
|
||||
if schedule_type == "interval":
|
||||
return _calc_next_interval(action, now)
|
||||
if schedule_type == "once":
|
||||
due_date = action.get("due_date")
|
||||
if due_date and not action.get("completed"):
|
||||
return due_date
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def _next_due_interval(action: dict) -> str:
|
||||
"""Human-readable next due string for interval actions."""
|
||||
interval = action.get("interval_minutes", 60)
|
||||
last_run = action.get("last_run")
|
||||
if not last_run:
|
||||
return "now"
|
||||
try:
|
||||
last_dt = datetime.fromisoformat(last_run)
|
||||
next_dt = last_dt + timedelta(minutes=interval)
|
||||
if next_dt <= datetime.now():
|
||||
return "now"
|
||||
return next_dt.strftime("%H:%M")
|
||||
except (ValueError, TypeError) as e:
|
||||
logger.info("[actions_registry] next_due_str interval last_run parse failed: %s", e)
|
||||
return "now"
|
||||
|
||||
|
||||
def next_due_str(action: dict) -> str:
|
||||
"""Human-readable next due string for display."""
|
||||
schedule_type = action.get("schedule_type", "")
|
||||
|
||||
if schedule_type == "daily":
|
||||
return f"daily @ {action.get('time', '00:00')}"
|
||||
if schedule_type == "hourly":
|
||||
m = action.get("time", "0")
|
||||
return f"hourly @ :{int(m):02d}"
|
||||
if schedule_type == "interval":
|
||||
return _next_due_interval(action)
|
||||
if schedule_type == "once":
|
||||
return action.get("due_date", "unknown")
|
||||
return "unknown"
|
||||
|
||||
|
||||
# =============================================
|
||||
# PLUGIN MIGRATION
|
||||
# =============================================
|
||||
|
||||
|
||||
def migrate_plugins() -> int:
|
||||
"""
|
||||
Scan plugins/ directory and auto-register any plugins not yet in the registry.
|
||||
|
||||
Maps PLUGIN_CONFIG fields to action fields. Preserves last_run timestamps
|
||||
from .last_run.json.
|
||||
|
||||
Returns:
|
||||
Number of newly migrated plugins.
|
||||
"""
|
||||
registry = load_registry()
|
||||
existing_plugins = {a["plugin_file"] for a in registry["actions"] if a.get("plugin_file")}
|
||||
|
||||
# Load last_run data for timestamp preservation
|
||||
last_run_file = PLUGINS_DIR / ".last_run.json"
|
||||
last_run_map = {}
|
||||
if last_run_file.exists():
|
||||
try:
|
||||
last_run_map = json.loads(last_run_file.read_text(encoding="utf-8"))
|
||||
except (json.JSONDecodeError, OSError) as e:
|
||||
logger.warning("[actions_registry] Failed to load last_run.json: %s", e)
|
||||
|
||||
# Discover plugins
|
||||
migrated = 0
|
||||
for plugin_path in sorted(PLUGINS_DIR.glob("*.py")):
|
||||
if plugin_path.name.startswith("_"):
|
||||
continue
|
||||
|
||||
plugin_name = plugin_path.stem
|
||||
if plugin_name in existing_plugins:
|
||||
continue
|
||||
|
||||
# Import plugin to read PLUGIN_CONFIG
|
||||
try:
|
||||
import importlib
|
||||
|
||||
# Use absolute package path for plugin import
|
||||
spec_name = f"aipass.daemon.apps.plugins.{plugin_name}"
|
||||
module = importlib.import_module(spec_name)
|
||||
|
||||
if not hasattr(module, "PLUGIN_CONFIG"):
|
||||
continue
|
||||
|
||||
config = module.PLUGIN_CONFIG
|
||||
except Exception as e:
|
||||
logger.warning("[actions_registry] Failed to import plugin %s: %s", plugin_name, e)
|
||||
continue
|
||||
|
||||
# Map PLUGIN_CONFIG to action fields
|
||||
action_id = _get_next_id(registry)
|
||||
action = {
|
||||
"id": action_id,
|
||||
"name": config.get("name", plugin_name),
|
||||
"type": "plugin",
|
||||
"schedule_type": config.get("schedule", "interval"),
|
||||
"time": config.get("time"),
|
||||
"interval_minutes": config.get("interval_minutes"),
|
||||
"due_date": None,
|
||||
"target_branch": config.get("branch", ""),
|
||||
"prompt": config.get("prompt", ""),
|
||||
"fresh": config.get("fresh", True),
|
||||
"max_turns": config.get("max_turns", 50),
|
||||
"enabled": config.get("enabled", False),
|
||||
"self_dispatch": config.get("self_dispatch", False),
|
||||
"plugin_file": plugin_name,
|
||||
"last_run": last_run_map.get(config.get("name", plugin_name)),
|
||||
"next_run": None,
|
||||
"created": datetime.now().isoformat(),
|
||||
"completed": None,
|
||||
}
|
||||
|
||||
# Calculate next_run from last_run
|
||||
action["next_run"] = calc_next_run(action)
|
||||
|
||||
registry["actions"].append(action)
|
||||
migrated += 1
|
||||
logger.info("[actions_registry] Migrated plugin: %s -> action %s", plugin_name, action_id)
|
||||
|
||||
if migrated > 0:
|
||||
save_registry(registry)
|
||||
logger.info("[actions_registry] Migration complete: %d plugin(s) migrated", migrated)
|
||||
|
||||
return migrated
|
||||
@@ -2,10 +2,12 @@
|
||||
# META DATA HEADER
|
||||
# Name: __init__.py - Schedule Handlers Package
|
||||
# Date: 2026-02-04
|
||||
# Version: 1.0.0
|
||||
# Version: 2.0.0
|
||||
# Category: daemon/handlers/schedule
|
||||
#
|
||||
# CHANGELOG (Max 5 entries):
|
||||
# - v2.0.0 (2026-06-25): task_registry archived (TDPLAN-0008); package
|
||||
# now exposes runstate + discovery for the live .daemon/ scheduler.
|
||||
# - v1.0.0 (2026-02-04): Initial package setup
|
||||
#
|
||||
# CODE STANDARDS:
|
||||
@@ -14,25 +16,26 @@
|
||||
# =============================================
|
||||
|
||||
"""
|
||||
Schedule handlers for daemon's scheduled follow-ups system.
|
||||
Schedule handlers for daemon's decentralized .daemon/ scheduler.
|
||||
|
||||
task_registry (fire-and-forget follow-ups) has been archived — superseded
|
||||
by the per-branch .daemon/schedule.json model (DPLAN-0204).
|
||||
"""
|
||||
|
||||
from aipass.daemon.apps.handlers.schedule.task_registry import (
|
||||
load_tasks,
|
||||
save_tasks,
|
||||
create_task,
|
||||
delete_task,
|
||||
get_due_tasks,
|
||||
mark_completed,
|
||||
parse_due_date,
|
||||
from aipass.daemon.apps.handlers.schedule.runstate import (
|
||||
load_runstate,
|
||||
save_runstate,
|
||||
update_job_runstate,
|
||||
is_job_due,
|
||||
job_key,
|
||||
)
|
||||
from aipass.daemon.apps.handlers.schedule.discovery import discover_jobs
|
||||
|
||||
__all__ = [
|
||||
"load_tasks",
|
||||
"save_tasks",
|
||||
"create_task",
|
||||
"delete_task",
|
||||
"get_due_tasks",
|
||||
"mark_completed",
|
||||
"parse_due_date",
|
||||
"load_runstate",
|
||||
"save_runstate",
|
||||
"update_job_runstate",
|
||||
"is_job_due",
|
||||
"job_key",
|
||||
"discover_jobs",
|
||||
]
|
||||
|
||||
@@ -250,7 +250,7 @@ def update_job_runstate(
|
||||
schedule: dict,
|
||||
timestamp: Optional[str] = None,
|
||||
) -> None:
|
||||
"""Update last_run and next_run for a job after firing."""
|
||||
"""Update runstate for a job after successful firing."""
|
||||
if timestamp is None:
|
||||
timestamp = datetime.now().isoformat()
|
||||
|
||||
@@ -258,6 +258,9 @@ def update_job_runstate(
|
||||
entry = runstate.setdefault("jobs", {}).setdefault(key, {})
|
||||
entry["last_run"] = timestamp
|
||||
entry["next_run"] = _calc_next_run(schedule, timestamp)
|
||||
entry["last_status"] = "success"
|
||||
entry["last_success_at"] = timestamp
|
||||
entry["last_error"] = None
|
||||
|
||||
if schedule.get("type") == "once":
|
||||
entry["completed"] = timestamp
|
||||
@@ -265,6 +268,28 @@ def update_job_runstate(
|
||||
json_handler.log_operation("update_job_runstate", {"key": key})
|
||||
|
||||
|
||||
def record_job_failure(
|
||||
runstate: dict,
|
||||
owner: str,
|
||||
job_id: str,
|
||||
error_msg: str,
|
||||
status: str = "failed",
|
||||
timestamp: Optional[str] = None,
|
||||
) -> None:
|
||||
"""Record a failed job firing in runstate."""
|
||||
if timestamp is None:
|
||||
timestamp = datetime.now().isoformat()
|
||||
|
||||
key = job_key(owner, job_id)
|
||||
entry = runstate.setdefault("jobs", {}).setdefault(key, {})
|
||||
entry["last_run"] = timestamp
|
||||
entry["last_status"] = status
|
||||
entry["last_failure_at"] = timestamp
|
||||
entry["last_error"] = error_msg[:500]
|
||||
|
||||
json_handler.log_operation("record_job_failure", {"key": key, "status": status})
|
||||
|
||||
|
||||
def prune_orphans(runstate: dict, active_keys: set) -> int:
|
||||
"""Remove runstate entries for jobs that no longer exist. Returns count pruned."""
|
||||
jobs = runstate.get("jobs", {})
|
||||
|
||||
@@ -1,588 +0,0 @@
|
||||
# =================== AIPass ====================
|
||||
# Name: task_registry.py
|
||||
# Description: DAEMON Scheduled Tasks Registry
|
||||
# Version: 1.0.0
|
||||
# Created: 2026-02-04
|
||||
# Modified: 2026-02-04
|
||||
# =============================================
|
||||
|
||||
"""
|
||||
Handler for scheduled task storage and operations.
|
||||
|
||||
Fire-and-forget follow-up system for DAEMON.
|
||||
Tasks are stored in daemon_json/schedule.json and processed
|
||||
when their due date arrives.
|
||||
"""
|
||||
|
||||
import json
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Dict, List, Any, Optional
|
||||
import re
|
||||
|
||||
from aipass.prax import logger
|
||||
from aipass.daemon.apps.handlers.json import json_handler
|
||||
|
||||
# =============================================
|
||||
# CONSTANTS
|
||||
# =============================================
|
||||
|
||||
_DAEMON_ROOT = Path(__file__).resolve().parents[3] # src/aipass/daemon/
|
||||
SCHEDULE_JSON_PATH = _DAEMON_ROOT / "daemon_json" / "schedule.json"
|
||||
|
||||
DEFAULT_SCHEDULE_DATA: Dict[str, Any] = {"tasks": []}
|
||||
|
||||
# =============================================
|
||||
# JSON FILE OPERATIONS
|
||||
# =============================================
|
||||
|
||||
|
||||
def _ensure_json_exists() -> None:
|
||||
"""Ensure schedule.json exists, create with defaults if missing."""
|
||||
SCHEDULE_JSON_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
if not SCHEDULE_JSON_PATH.exists():
|
||||
with open(SCHEDULE_JSON_PATH, "w", encoding="utf-8") as f:
|
||||
json.dump(DEFAULT_SCHEDULE_DATA, f, indent=2, ensure_ascii=False)
|
||||
|
||||
|
||||
def ensure_lock_dir() -> Dict[str, Any]:
|
||||
"""Ensure the daemon_json directory exists for lock files.
|
||||
|
||||
Returns:
|
||||
Dict with 'path' (str) of the lock file directory.
|
||||
"""
|
||||
lock_dir = SCHEDULE_JSON_PATH.parent
|
||||
lock_dir.mkdir(parents=True, exist_ok=True)
|
||||
return {"path": str(lock_dir)}
|
||||
|
||||
|
||||
def load_tasks() -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Load all tasks from schedule.json.
|
||||
|
||||
Returns:
|
||||
List of task dictionaries
|
||||
"""
|
||||
_ensure_json_exists()
|
||||
|
||||
try:
|
||||
with open(SCHEDULE_JSON_PATH, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
return data.get("tasks", [])
|
||||
except (json.JSONDecodeError, IOError) as e:
|
||||
logger.error("[task_registry] Failed to load schedule.json: %s", e)
|
||||
return []
|
||||
|
||||
|
||||
def save_tasks(tasks: List[Dict[str, Any]]) -> bool:
|
||||
"""
|
||||
Save tasks to schedule.json.
|
||||
|
||||
Args:
|
||||
tasks: List of task dictionaries to save
|
||||
|
||||
Returns:
|
||||
True if successful, False otherwise
|
||||
"""
|
||||
_ensure_json_exists()
|
||||
|
||||
try:
|
||||
data = {"tasks": tasks}
|
||||
with open(SCHEDULE_JSON_PATH, "w", encoding="utf-8") as f:
|
||||
json.dump(data, f, indent=2, ensure_ascii=False)
|
||||
return True
|
||||
except IOError as e:
|
||||
logger.error("[task_registry] Failed to save schedule.json: %s", e)
|
||||
return False
|
||||
|
||||
|
||||
# =============================================
|
||||
# DATE PARSING
|
||||
# =============================================
|
||||
|
||||
|
||||
def parse_due_date(date_str: str) -> str:
|
||||
"""
|
||||
Parse various date formats to ISO 8601 date string.
|
||||
|
||||
Supports:
|
||||
- "7d" -> 7 days from now
|
||||
- "1w" -> 1 week from now
|
||||
- "2w" -> 2 weeks from now
|
||||
- "2026-02-11" -> exact date (ISO 8601)
|
||||
|
||||
Args:
|
||||
date_str: Date string in supported format
|
||||
|
||||
Returns:
|
||||
ISO 8601 date string (YYYY-MM-DD)
|
||||
|
||||
Raises:
|
||||
ValueError: If date format is invalid
|
||||
"""
|
||||
date_str = date_str.strip()
|
||||
today = datetime.now().date()
|
||||
|
||||
# Check for relative day format: "7d", "14d", etc.
|
||||
day_match = re.match(r"^(\d+)d$", date_str, re.IGNORECASE)
|
||||
if day_match:
|
||||
days = int(day_match.group(1))
|
||||
future_date = today + timedelta(days=days)
|
||||
return future_date.isoformat()
|
||||
|
||||
# Check for relative week format: "1w", "2w", etc.
|
||||
week_match = re.match(r"^(\d+)w$", date_str, re.IGNORECASE)
|
||||
if week_match:
|
||||
weeks = int(week_match.group(1))
|
||||
future_date = today + timedelta(weeks=weeks)
|
||||
return future_date.isoformat()
|
||||
|
||||
# Check for ISO 8601 date format: "2026-02-11"
|
||||
iso_match = re.match(r"^(\d{4})-(\d{2})-(\d{2})$", date_str)
|
||||
if iso_match:
|
||||
try:
|
||||
# Validate it's a real date
|
||||
year = int(iso_match.group(1))
|
||||
month = int(iso_match.group(2))
|
||||
day = int(iso_match.group(3))
|
||||
parsed_date = datetime(year, month, day).date()
|
||||
return parsed_date.isoformat()
|
||||
except ValueError as e:
|
||||
raise ValueError(f"Invalid date: {date_str}") from e
|
||||
|
||||
raise ValueError(f"Invalid date format: '{date_str}'. Use '7d' (days), '1w' (weeks), or 'YYYY-MM-DD' (ISO date)")
|
||||
|
||||
|
||||
# =============================================
|
||||
# TASK OPERATIONS
|
||||
# =============================================
|
||||
|
||||
|
||||
def _generate_task_id() -> str:
|
||||
"""Generate 16-character UUID for task ID."""
|
||||
return uuid.uuid4().hex[:16]
|
||||
|
||||
|
||||
def create_task(task: str, due_date: str, recipient: str, message: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Create a new scheduled task.
|
||||
|
||||
Args:
|
||||
task: Brief description of the task/follow-up
|
||||
due_date: When to trigger (supports "7d", "1w", "YYYY-MM-DD")
|
||||
recipient: Target branch (e.g., "@devpulse")
|
||||
message: Message to deliver when due
|
||||
|
||||
Returns:
|
||||
Created task dictionary
|
||||
|
||||
Raises:
|
||||
ValueError: If due_date format is invalid
|
||||
"""
|
||||
json_handler.log_operation("task_created")
|
||||
parsed_due = parse_due_date(due_date)
|
||||
|
||||
new_task: Dict[str, Any] = {
|
||||
"id": _generate_task_id(),
|
||||
"created": datetime.now().date().isoformat(),
|
||||
"due_date": parsed_due,
|
||||
"task": task,
|
||||
"recipient": recipient,
|
||||
"message": message,
|
||||
"status": "pending",
|
||||
}
|
||||
|
||||
tasks = load_tasks()
|
||||
tasks.append(new_task)
|
||||
save_tasks(tasks)
|
||||
|
||||
return new_task
|
||||
|
||||
|
||||
def delete_task(task_id: str) -> bool:
|
||||
"""
|
||||
Delete a task by ID.
|
||||
|
||||
Args:
|
||||
task_id: 8-character task ID
|
||||
|
||||
Returns:
|
||||
True if task was found and deleted, False otherwise
|
||||
"""
|
||||
tasks = load_tasks()
|
||||
original_count = len(tasks)
|
||||
|
||||
tasks = [t for t in tasks if t.get("id") != task_id]
|
||||
|
||||
if len(tasks) < original_count:
|
||||
save_tasks(tasks)
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def get_due_tasks() -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Get all tasks that are due (due_date <= today).
|
||||
|
||||
Only returns tasks with status 'pending' - excludes 'dispatching' and 'completed'.
|
||||
|
||||
Returns:
|
||||
List of tasks that are due for processing
|
||||
"""
|
||||
tasks = load_tasks()
|
||||
today = datetime.now().date().isoformat()
|
||||
|
||||
due_tasks = [t for t in tasks if t.get("status") == "pending" and t.get("due_date", "") <= today]
|
||||
|
||||
return due_tasks
|
||||
|
||||
|
||||
def mark_dispatching(task_id: str) -> bool:
|
||||
"""
|
||||
Mark a task as currently being dispatched.
|
||||
|
||||
Prevents re-dispatch while email is being sent.
|
||||
|
||||
Args:
|
||||
task_id: 8-character task ID
|
||||
|
||||
Returns:
|
||||
True if task was found and marked, False otherwise
|
||||
"""
|
||||
tasks = load_tasks()
|
||||
|
||||
for task in tasks:
|
||||
if task.get("id") == task_id:
|
||||
task["status"] = "dispatching"
|
||||
task["dispatch_started"] = datetime.now().isoformat()
|
||||
save_tasks(tasks)
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def mark_pending(task_id: str) -> bool:
|
||||
"""
|
||||
Reset a task to pending status (for retry after failed dispatch).
|
||||
|
||||
Args:
|
||||
task_id: 8-character task ID
|
||||
|
||||
Returns:
|
||||
True if task was found and reset, False otherwise
|
||||
"""
|
||||
tasks = load_tasks()
|
||||
|
||||
for task in tasks:
|
||||
if task.get("id") == task_id:
|
||||
task["status"] = "pending"
|
||||
task.pop("dispatch_started", None)
|
||||
save_tasks(tasks)
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def _is_stale_dispatch(started: str, cutoff: datetime) -> bool:
|
||||
"""Check if a dispatch_started timestamp is older than the cutoff."""
|
||||
try:
|
||||
start_time = datetime.fromisoformat(started)
|
||||
return start_time < cutoff
|
||||
except ValueError as e:
|
||||
logger.warning("[task_registry] Invalid dispatch_started timestamp, resetting task: %s", e)
|
||||
return True
|
||||
|
||||
|
||||
def recover_stale_dispatches(max_age_minutes: int = 5) -> int:
|
||||
"""
|
||||
Reset tasks stuck in 'dispatching' status for too long.
|
||||
|
||||
Called before processing to recover from crashed dispatches.
|
||||
|
||||
Args:
|
||||
max_age_minutes: Maximum time a task can be in dispatching status
|
||||
|
||||
Returns:
|
||||
Number of tasks recovered
|
||||
"""
|
||||
tasks = load_tasks()
|
||||
recovered = 0
|
||||
cutoff = datetime.now() - timedelta(minutes=max_age_minutes)
|
||||
|
||||
for task in tasks:
|
||||
if task.get("status") != "dispatching":
|
||||
continue
|
||||
started = task.get("dispatch_started")
|
||||
if not started:
|
||||
continue
|
||||
if _is_stale_dispatch(started, cutoff):
|
||||
task["status"] = "pending"
|
||||
task.pop("dispatch_started", None)
|
||||
recovered += 1
|
||||
|
||||
if recovered:
|
||||
save_tasks(tasks)
|
||||
|
||||
return recovered
|
||||
|
||||
|
||||
def mark_completed(task_id: str) -> bool:
|
||||
"""
|
||||
Mark a task as completed.
|
||||
|
||||
Args:
|
||||
task_id: 8-character task ID
|
||||
|
||||
Returns:
|
||||
True if task was found and marked, False otherwise
|
||||
"""
|
||||
tasks = load_tasks()
|
||||
|
||||
for task in tasks:
|
||||
if task.get("id") == task_id:
|
||||
task["status"] = "completed"
|
||||
task["completed_date"] = datetime.now().date().isoformat()
|
||||
save_tasks(tasks)
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def get_task_by_id(task_id: str) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Get a single task by ID.
|
||||
|
||||
Args:
|
||||
task_id: 8-character task ID
|
||||
|
||||
Returns:
|
||||
Task dictionary if found, None otherwise
|
||||
"""
|
||||
tasks = load_tasks()
|
||||
|
||||
for task in tasks:
|
||||
if task.get("id") == task_id:
|
||||
return task
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def get_pending_tasks() -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Get all pending tasks (not yet due or completed).
|
||||
|
||||
Returns:
|
||||
List of pending tasks
|
||||
"""
|
||||
tasks = load_tasks()
|
||||
return [t for t in tasks if t.get("status") == "pending"]
|
||||
|
||||
|
||||
# =============================================
|
||||
# BATCH PROCESSING
|
||||
# =============================================
|
||||
|
||||
|
||||
def _safe_mark_pending(task_id: str) -> None:
|
||||
"""Best-effort reset a task to pending, logging on failure."""
|
||||
try:
|
||||
mark_pending(task_id)
|
||||
except Exception as pending_err:
|
||||
logger.error("[task_registry] Failed to reset task %s to pending: %s", task_id[:8], pending_err)
|
||||
|
||||
|
||||
def process_due_tasks_batch(
|
||||
send_email_fn=None,
|
||||
stale_max_age: int = 5,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Process all due tasks: recover stale, dispatch emails, track results.
|
||||
|
||||
This is the implementation logic for batch task processing.
|
||||
The module layer handles display; this handler returns raw data.
|
||||
|
||||
Args:
|
||||
send_email_fn: Callable to send email (to_branch, subject, message, ...).
|
||||
If None, email dispatch is skipped.
|
||||
stale_max_age: Maximum minutes before a dispatching task is considered stale.
|
||||
|
||||
Returns:
|
||||
Dict with keys: due, success, failed, recovered, errors (list of str),
|
||||
processed_tasks (list of dicts with id, recipient, task, status).
|
||||
"""
|
||||
import time
|
||||
|
||||
results: Dict[str, Any] = {
|
||||
"due": 0,
|
||||
"success": 0,
|
||||
"failed": 0,
|
||||
"recovered": 0,
|
||||
"errors": [],
|
||||
"processed_tasks": [],
|
||||
}
|
||||
|
||||
# Recover any stale dispatches
|
||||
try:
|
||||
recovered = recover_stale_dispatches(max_age_minutes=stale_max_age)
|
||||
results["recovered"] = recovered
|
||||
except Exception as e:
|
||||
logger.warning("[task_registry] Stale dispatch recovery failed: %s", e)
|
||||
results["errors"].append(f"Stale recovery: {e}")
|
||||
|
||||
# Get due tasks
|
||||
try:
|
||||
due_tasks = get_due_tasks()
|
||||
except Exception as e:
|
||||
logger.error("[task_registry] Failed to load due tasks: %s", e)
|
||||
results["errors"].append(f"Load tasks: {e}")
|
||||
return results
|
||||
|
||||
results["due"] = len(due_tasks)
|
||||
|
||||
if not due_tasks:
|
||||
return results
|
||||
|
||||
for task in due_tasks:
|
||||
task_id = task.get("id", "")
|
||||
recipient = task.get("recipient", "")
|
||||
task_desc = task.get("task", "")
|
||||
message = task.get("message", "")
|
||||
|
||||
task_result = {
|
||||
"id": task_id,
|
||||
"recipient": recipient,
|
||||
"task": task_desc,
|
||||
"status": "pending",
|
||||
}
|
||||
|
||||
# Mark as dispatching (prevents re-dispatch)
|
||||
try:
|
||||
mark_dispatching(task_id)
|
||||
except Exception as e:
|
||||
logger.error("[task_registry] Failed to mark task %s as dispatching: %s", task_id[:8], e)
|
||||
results["errors"].append(f"Mark dispatching {task_id[:8]}: {e}")
|
||||
results["failed"] += 1
|
||||
task_result["status"] = "error"
|
||||
task_result["error"] = str(e)
|
||||
results["processed_tasks"].append(task_result)
|
||||
continue
|
||||
|
||||
# Build email body
|
||||
email_body = f"{task_desc}"
|
||||
if message:
|
||||
email_body += f"\n\nDetails:\n{message}"
|
||||
|
||||
# Send the email
|
||||
if send_email_fn is None:
|
||||
mark_pending(task_id)
|
||||
results["failed"] += 1
|
||||
task_result["status"] = "skipped"
|
||||
task_result["error"] = "email function not available"
|
||||
results["errors"].append(f"Email unavailable for {task_id[:8]}")
|
||||
results["processed_tasks"].append(task_result)
|
||||
continue
|
||||
|
||||
try:
|
||||
email_sent = send_email_fn(
|
||||
to_branch=recipient,
|
||||
subject=f"[SCHEDULED] {task_desc}",
|
||||
message=email_body,
|
||||
from_branch="@daemon",
|
||||
auto_execute=True,
|
||||
reply_to="@devpulse",
|
||||
)
|
||||
|
||||
if email_sent:
|
||||
mark_completed(task_id)
|
||||
results["success"] += 1
|
||||
task_result["status"] = "sent"
|
||||
else:
|
||||
mark_pending(task_id)
|
||||
results["failed"] += 1
|
||||
task_result["status"] = "failed"
|
||||
task_result["error"] = "email send returned False"
|
||||
results["errors"].append(f"Email failed: {task_id[:8]} -> {recipient}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error("[task_registry] Email dispatch error for task %s: %s", task_id[:8], e)
|
||||
_safe_mark_pending(task_id)
|
||||
results["failed"] += 1
|
||||
task_result["status"] = "error"
|
||||
task_result["error"] = str(e)
|
||||
results["errors"].append(f"Email error {task_id[:8]}: {e}")
|
||||
|
||||
results["processed_tasks"].append(task_result)
|
||||
|
||||
# Small delay between dispatches (prevents thundering herd)
|
||||
time.sleep(1.0)
|
||||
|
||||
return results
|
||||
|
||||
|
||||
# =============================================
|
||||
# MAIN - Testing
|
||||
# =============================================
|
||||
|
||||
if __name__ == "__main__":
|
||||
from rich.console import Console
|
||||
from rich.panel import Panel
|
||||
from rich.table import Table
|
||||
|
||||
console = Console()
|
||||
|
||||
console.print()
|
||||
console.print(Panel.fit("[bold cyan]TASK REGISTRY - Handler Test[/bold cyan]", border_style="bright_blue"))
|
||||
console.print()
|
||||
|
||||
# Test date parsing
|
||||
console.print("[yellow]Testing date parsing:[/yellow]")
|
||||
test_dates = ["7d", "1w", "2w", "2026-03-15"]
|
||||
for d in test_dates:
|
||||
try:
|
||||
result = parse_due_date(d)
|
||||
console.print(f" {d} -> {result}")
|
||||
except ValueError as e:
|
||||
logger.warning("Date parse test failed for %s: %s", d, e)
|
||||
console.print(f" {d} -> [red]ERROR: {e}[/red]")
|
||||
|
||||
# Test invalid date
|
||||
try:
|
||||
parse_due_date("invalid")
|
||||
except ValueError as e:
|
||||
logger.info("Expected parse failure for 'invalid': %s", e)
|
||||
console.print(f" invalid -> [green]Correctly raised: {e}[/green]")
|
||||
|
||||
console.print()
|
||||
console.print("[yellow]Testing task creation:[/yellow]")
|
||||
|
||||
# Create a test task
|
||||
test_task = create_task(
|
||||
task="Test backup health check",
|
||||
due_date="7d",
|
||||
recipient="@devpulse",
|
||||
message="Please verify backup systems are healthy",
|
||||
)
|
||||
console.print(f" Created task: {test_task['id']}")
|
||||
console.print(f" Due: {test_task['due_date']}")
|
||||
|
||||
# Show all tasks
|
||||
console.print()
|
||||
console.print("[yellow]Current tasks:[/yellow]")
|
||||
all_tasks = load_tasks()
|
||||
|
||||
table = Table(show_header=True)
|
||||
table.add_column("ID", style="cyan")
|
||||
table.add_column("Task", style="white")
|
||||
table.add_column("Due", style="yellow")
|
||||
table.add_column("Status", style="green")
|
||||
|
||||
for t in all_tasks:
|
||||
table.add_row(t.get("id", "?"), t.get("task", "?")[:30], t.get("due_date", "?"), t.get("status", "?"))
|
||||
|
||||
console.print(table)
|
||||
console.print()
|
||||
console.print(f"[dim]Schedule file: {SCHEDULE_JSON_PATH}[/dim]")
|
||||
console.print()
|
||||
@@ -0,0 +1,53 @@
|
||||
# =================== AIPass ====================
|
||||
# Name: telegram_notifier.py
|
||||
# Description: Scheduler lifecycle notifications via Telegram
|
||||
# Version: 3.0.0
|
||||
# Created: 2026-02-15
|
||||
# Modified: 2026-06-25
|
||||
# =============================================
|
||||
|
||||
"""
|
||||
Scheduler lifecycle notifications — emit running/complete/failed pings
|
||||
via the @skills telegram notifier (secret slug: telegram/scheduler).
|
||||
|
||||
Fail-soft: if the secret is missing or the send fails, returns False
|
||||
and never raises. The tick MUST keep firing jobs regardless.
|
||||
"""
|
||||
|
||||
from aipass.prax import logger
|
||||
from aipass.daemon.apps.handlers.json import json_handler # noqa: F401
|
||||
|
||||
|
||||
def _send(message: str) -> bool:
|
||||
"""Send via skills notifier, fail-soft.
|
||||
|
||||
Cross-branch import authorized by TDPLAN-0008 contract —
|
||||
daemon emits lifecycle pings through the skills telegram notifier.
|
||||
"""
|
||||
try:
|
||||
from aipass.skills.lib.telegram.apps.handlers.notifier import ( # noqa: E501
|
||||
send_telegram_notification,
|
||||
)
|
||||
|
||||
return send_telegram_notification(message)
|
||||
except Exception as e:
|
||||
logger.info("[telegram_notifier] Send failed (non-fatal): %s", e)
|
||||
return False
|
||||
|
||||
|
||||
def notify_triggered(owner: str, job_id: str) -> bool:
|
||||
"""Emit 'running' ping when a job fires."""
|
||||
json_handler.log_operation("notify_triggered", {"owner": owner, "job_id": job_id})
|
||||
return _send(f"\U0001f535 {owner}/{job_id} running")
|
||||
|
||||
|
||||
def notify_complete(owner: str, job_id: str, summary: str) -> bool:
|
||||
"""Emit 'complete' ping after successful dispatch."""
|
||||
json_handler.log_operation("notify_complete", {"owner": owner, "job_id": job_id})
|
||||
return _send(f"✅ {owner}/{job_id} dispatched\n{summary}")
|
||||
|
||||
|
||||
def notify_error(owner: str, job_id: str, error: str) -> bool:
|
||||
"""Emit 'failed' ping on dispatch failure."""
|
||||
json_handler.log_operation("notify_error", {"owner": owner, "job_id": job_id})
|
||||
return _send(f"❌ {owner}/{job_id} FAILED\n{error}")
|
||||
@@ -1,560 +1,51 @@
|
||||
# =================== AIPass ====================
|
||||
# Name: actions.py
|
||||
# Description: Action Registry CLI Module
|
||||
# Version: 1.0.0
|
||||
# Description: Action Registry CLI Module (RETIRED)
|
||||
# Version: 2.0.0
|
||||
# Created: 2026-03-02
|
||||
# Modified: 2026-03-02
|
||||
# Modified: 2026-06-25
|
||||
# =============================================
|
||||
|
||||
"""
|
||||
CLI interface for the numbered action registry.
|
||||
RETIRED — the numbered action registry CLI.
|
||||
|
||||
Superseded by the decentralized .daemon/schedule.json model (DPLAN-0204).
|
||||
Author jobs directly in a branch's .daemon/schedule.json file.
|
||||
See: drone @daemon run --help (full schema + schedule types)
|
||||
"""
|
||||
|
||||
# =============================================
|
||||
# IMPORTS
|
||||
# =============================================
|
||||
|
||||
import sys
|
||||
from typing import List
|
||||
|
||||
from aipass.prax import logger
|
||||
|
||||
from aipass.cli.apps.modules import console, error as cli_error
|
||||
from aipass.daemon.apps.handlers.actions.actions_registry import (
|
||||
list_actions,
|
||||
get_action,
|
||||
toggle_action,
|
||||
delete_action,
|
||||
create_action,
|
||||
migrate_plugins,
|
||||
next_due_str,
|
||||
)
|
||||
from aipass.cli.apps.modules import console
|
||||
from aipass.daemon.apps.handlers.json import json_handler
|
||||
|
||||
|
||||
def _header(text):
|
||||
console.print(f"\n[bold cyan]{'=' * 70}[/bold cyan]")
|
||||
console.print(f"[bold cyan] {text}[/bold cyan]")
|
||||
console.print(f"[bold cyan]{'=' * 70}[/bold cyan]")
|
||||
|
||||
|
||||
def _success(text):
|
||||
console.print(f"[green]OK:[/green] {text}")
|
||||
|
||||
|
||||
def _error(text):
|
||||
cli_error(text)
|
||||
|
||||
|
||||
# =============================================
|
||||
# CONSTANTS
|
||||
# =============================================
|
||||
|
||||
MODULE_NAME = "actions"
|
||||
|
||||
|
||||
# =============================================
|
||||
# INTROSPECTION
|
||||
# =============================================
|
||||
|
||||
|
||||
def print_introspection():
|
||||
"""Display module introspection info."""
|
||||
"""Display retirement notice."""
|
||||
console.print()
|
||||
console.print("[bold cyan]actions Module[/bold cyan]")
|
||||
console.print("[bold cyan]actions Module[/bold cyan] [yellow](RETIRED)[/yellow]")
|
||||
console.print()
|
||||
console.print("[dim]CLI interface for the numbered action registry (DPLAN-043)[/dim]")
|
||||
console.print("[dim]This CLI has been retired. Jobs now live in per-branch .daemon/schedule.json files.[/dim]")
|
||||
console.print("[dim]Run [bold]drone @daemon run --help[/bold] for the schema and schedule types.[/dim]")
|
||||
console.print("[dim]Run [bold]drone @daemon queue[/bold] to see the unified job queue.[/dim]")
|
||||
console.print()
|
||||
console.print("[yellow]Connected Handlers:[/yellow]")
|
||||
console.print(" handlers/actions/")
|
||||
console.print(
|
||||
" [cyan]*[/cyan] actions_registry.py"
|
||||
" [dim](list_actions, get_action,"
|
||||
" toggle_action, delete_action, create_action,"
|
||||
" migrate_plugins, next_due_str — registry CRUD)[/dim]"
|
||||
)
|
||||
console.print()
|
||||
|
||||
|
||||
# =============================================
|
||||
# OUTPUT FORMATTING
|
||||
# =============================================
|
||||
|
||||
|
||||
def _format_schedule(action: dict) -> str:
|
||||
"""Build schedule display string for an action."""
|
||||
schedule_type = action.get("schedule_type", "")
|
||||
if schedule_type == "daily":
|
||||
return f"daily @ {action.get('time', '??:??')}"
|
||||
if schedule_type == "hourly":
|
||||
m = action.get("time", "0")
|
||||
return f"hourly @ :{int(m):02d}"
|
||||
if schedule_type == "interval":
|
||||
mins = action.get("interval_minutes", 0)
|
||||
if mins >= 60:
|
||||
return f"every {mins // 60}h"
|
||||
return f"every {mins}m"
|
||||
if schedule_type == "once":
|
||||
return f"once: {action.get('due_date', '?')}"
|
||||
return schedule_type
|
||||
|
||||
|
||||
def _print_actions_table(actions: list) -> None:
|
||||
"""Display formatted action list as a table."""
|
||||
console.print()
|
||||
_header("Action Registry")
|
||||
console.print()
|
||||
|
||||
if not actions:
|
||||
console.print("[dim]No actions registered. Run 'actions migrate' to import plugins.[/dim]")
|
||||
console.print()
|
||||
return
|
||||
|
||||
# Header row
|
||||
console.print(f" {'ID':<6} {'ON':<4} {'NAME':<24} {'TYPE':<10} {'TARGET':<16} {'SCHEDULE':<20} {'NEXT DUE':<16}")
|
||||
console.print(" " + "-" * 96)
|
||||
|
||||
for action in actions:
|
||||
action_id = action.get("id", "????")
|
||||
enabled = "[green]ON[/green] " if action.get("enabled") else "[red]OFF[/red]"
|
||||
name = action.get("name", "")[:22]
|
||||
action_type = action.get("type", "")[:8]
|
||||
target = action.get("target_branch", "")[:14]
|
||||
|
||||
schedule_str = _format_schedule(action)
|
||||
next_due = next_due_str(action)
|
||||
|
||||
console.print(
|
||||
f" {action_id:<6} {enabled:<4} {name:<24} {action_type:<10} "
|
||||
f" {target:<16} {schedule_str:<20} {next_due:<16}"
|
||||
)
|
||||
|
||||
console.print()
|
||||
enabled_count = sum(1 for a in actions if a.get("enabled"))
|
||||
console.print(f" [dim]Total: {len(actions)} actions ({enabled_count} enabled)[/dim]")
|
||||
console.print()
|
||||
|
||||
|
||||
def _print_action_detail(action: dict) -> None:
|
||||
"""Display detailed view of a single action."""
|
||||
console.print()
|
||||
_header(f"Action {action['id']}: {action['name']}")
|
||||
console.print()
|
||||
|
||||
fields = [
|
||||
("ID", action.get("id")),
|
||||
("Name", action.get("name")),
|
||||
("Type", action.get("type")),
|
||||
("Enabled", "[green]ON[/green]" if action.get("enabled") else "[red]OFF[/red]"),
|
||||
("Schedule", action.get("schedule_type")),
|
||||
("Time", action.get("time")),
|
||||
("Interval", f"{action.get('interval_minutes')}m" if action.get("interval_minutes") else None),
|
||||
("Due Date", action.get("due_date")),
|
||||
("Target", action.get("target_branch")),
|
||||
("Fresh", action.get("fresh")),
|
||||
("Max Turns", action.get("max_turns")),
|
||||
("Self Dispatch", action.get("self_dispatch")),
|
||||
("Plugin File", action.get("plugin_file")),
|
||||
("Last Run", action.get("last_run", "never")[:19] if action.get("last_run") else "never"),
|
||||
("Next Run", next_due_str(action)),
|
||||
("Created", action.get("created", "")[:19]),
|
||||
("Completed", action.get("completed")),
|
||||
]
|
||||
|
||||
for label, value in fields:
|
||||
if value is None:
|
||||
continue
|
||||
console.print(f" [cyan]{label:<16}[/cyan] {value}")
|
||||
|
||||
# Show prompt (truncated for readability)
|
||||
prompt = action.get("prompt", "")
|
||||
if prompt:
|
||||
console.print()
|
||||
console.print(" [cyan]Prompt:[/cyan]")
|
||||
# Show first 200 chars
|
||||
display_prompt = prompt[:200]
|
||||
if len(prompt) > 200:
|
||||
display_prompt += "..."
|
||||
for line in display_prompt.split("\n"):
|
||||
console.print(f" [dim]{line}[/dim]")
|
||||
|
||||
console.print()
|
||||
|
||||
|
||||
def print_help() -> None:
|
||||
"""Display help using Rich formatted output."""
|
||||
console.print()
|
||||
_header("Actions -- Numbered Action Registry")
|
||||
console.print()
|
||||
|
||||
console.print("[yellow]USAGE:[/yellow]")
|
||||
console.print(" drone @daemon actions list")
|
||||
console.print(" drone @daemon actions <id> info")
|
||||
console.print(" drone @daemon actions <id> on")
|
||||
console.print(" drone @daemon actions <id> off")
|
||||
console.print(' drone @daemon actions set reminder <date> "message" [--to @branch]')
|
||||
console.print(' drone @daemon actions set schedule @branch "prompt" <type> [time]')
|
||||
console.print(" drone @daemon actions migrate")
|
||||
console.print(" drone @daemon actions delete <id>")
|
||||
console.print()
|
||||
|
||||
console.print("[yellow]COMMANDS:[/yellow]")
|
||||
console.print(" list List all registered actions with status")
|
||||
console.print(" <id> info Show detailed view of a single action")
|
||||
console.print(" <id> on Enable an action")
|
||||
console.print(" <id> off Disable an action")
|
||||
console.print(" set Create a new reminder or schedule")
|
||||
console.print(" migrate Import existing plugins into registry")
|
||||
console.print(" delete <id> Remove an action from the registry")
|
||||
console.print()
|
||||
|
||||
console.print("[yellow]SET REMINDER:[/yellow]")
|
||||
console.print(' set reminder 2026-03-11 "Check VERA progress"')
|
||||
console.print(' set reminder 7d "Follow up on PR review" --to @flow')
|
||||
console.print(" [dim]Date formats: YYYY-MM-DD, 1d, 7d, 1w, 2w[/dim]")
|
||||
console.print()
|
||||
|
||||
console.print("[yellow]SET SCHEDULE:[/yellow]")
|
||||
console.print(' set schedule @seedgo "Run audit" daily 04:00')
|
||||
console.print(' set schedule @daemon "Heartbeat" interval 240')
|
||||
console.print(' set schedule @flow "Check plans" hourly 30')
|
||||
console.print(" [dim]Types: daily HH:MM, hourly MM, interval MINUTES[/dim]")
|
||||
console.print()
|
||||
|
||||
console.print("[yellow]EXAMPLES:[/yellow]")
|
||||
console.print(" actions list # See all actions")
|
||||
console.print(" actions 0003 off # Disable action 3")
|
||||
console.print(" actions 0003 on # Re-enable it")
|
||||
console.print(' actions set reminder 2026-03-11 "check VERA" # One-shot reminder')
|
||||
console.print()
|
||||
|
||||
|
||||
# =============================================
|
||||
# SUBCOMMAND HANDLERS
|
||||
# =============================================
|
||||
|
||||
|
||||
def _handle_list(_args: List[str]) -> bool:
|
||||
"""Handle 'actions list' subcommand."""
|
||||
actions = list_actions()
|
||||
_print_actions_table(actions)
|
||||
logger.info("[DAEMON] actions: Action list displayed")
|
||||
return True
|
||||
|
||||
|
||||
def _handle_toggle(action_id: str, enable: bool) -> bool:
|
||||
"""Handle 'actions <id> on/off' subcommand."""
|
||||
action = get_action(action_id)
|
||||
if action is None:
|
||||
_error(f"Action not found: {action_id}")
|
||||
return True # Error displayed
|
||||
|
||||
toggle_action(action_id, enable)
|
||||
state = "enabled" if enable else "disabled"
|
||||
_success(f"Action {action_id} ({action['name']}) {state}")
|
||||
logger.info("[DAEMON] actions: Action toggled")
|
||||
return True
|
||||
|
||||
|
||||
def _handle_info(action_id: str) -> bool:
|
||||
"""Handle 'actions <id> info' subcommand."""
|
||||
action = get_action(action_id)
|
||||
if action is None:
|
||||
_error(f"Action not found: {action_id}")
|
||||
return True # Error displayed
|
||||
|
||||
_print_action_detail(action)
|
||||
logger.info("[DAEMON] actions: Action info displayed")
|
||||
return True
|
||||
|
||||
|
||||
def _handle_set_reminder(args: List[str]) -> bool:
|
||||
"""Handle 'actions set reminder <date> "message" [--to @branch]'."""
|
||||
if len(args) < 2:
|
||||
_error('Usage: actions set reminder <date> "message" [--to @branch]')
|
||||
return True # Error displayed
|
||||
|
||||
date_str = args[0]
|
||||
message = args[1]
|
||||
target_branch = "@devpulse" # Default reminder target
|
||||
|
||||
# Parse --to flag
|
||||
if "--to" in args:
|
||||
to_idx = args.index("--to")
|
||||
if to_idx + 1 < len(args):
|
||||
target_branch = args[to_idx + 1]
|
||||
|
||||
# Parse date
|
||||
due_date = _parse_date(date_str)
|
||||
if not due_date:
|
||||
_error(f"Invalid date format: {date_str}")
|
||||
console.print("[dim]Valid formats: YYYY-MM-DD, 1d, 7d, 1w, 2w[/dim]")
|
||||
return True # Error displayed
|
||||
|
||||
action = create_action(
|
||||
name=message[:50],
|
||||
action_type="reminder",
|
||||
schedule_type="once",
|
||||
target_branch=target_branch,
|
||||
prompt=message,
|
||||
due_date=due_date,
|
||||
fresh=True,
|
||||
max_turns=10,
|
||||
enabled=True,
|
||||
)
|
||||
|
||||
_success(f"Reminder created: {action['id']}")
|
||||
console.print(f" [dim]Due:[/dim] {due_date}")
|
||||
console.print(f" [dim]To:[/dim] {target_branch}")
|
||||
console.print(f" [dim]Message:[/dim] {message[:60]}")
|
||||
console.print()
|
||||
logger.info("[DAEMON] actions: Reminder set")
|
||||
return True
|
||||
|
||||
|
||||
def _handle_set_schedule(args: List[str]) -> bool:
|
||||
"""Handle 'actions set schedule @branch "prompt" <type> [time_spec]'."""
|
||||
if len(args) < 3:
|
||||
_error('Usage: actions set schedule @branch "prompt" <daily|hourly|interval> [time_spec]')
|
||||
return True # Error displayed
|
||||
|
||||
target_branch = args[0]
|
||||
prompt = args[1]
|
||||
schedule_type = args[2]
|
||||
|
||||
time_val = None
|
||||
interval_minutes = None
|
||||
|
||||
if schedule_type not in ("daily", "hourly", "interval"):
|
||||
_error(f"Unknown schedule type: {schedule_type}")
|
||||
console.print("[dim]Valid types: daily, hourly, interval[/dim]")
|
||||
return True # Error displayed
|
||||
|
||||
if len(args) < 4:
|
||||
_error(f"{schedule_type.title()} schedule requires a time/value argument")
|
||||
return True # Error displayed
|
||||
|
||||
if schedule_type in ("daily", "hourly"):
|
||||
time_val = args[3]
|
||||
else:
|
||||
try:
|
||||
interval_minutes = int(args[3])
|
||||
except ValueError:
|
||||
logger.warning("Invalid interval minutes value: %s", args[3])
|
||||
_error(f"Invalid interval minutes: {args[3]}")
|
||||
return True # Error displayed
|
||||
|
||||
# Generate a name from the prompt
|
||||
name = prompt[:50].replace(" ", "_").lower()
|
||||
|
||||
action = create_action(
|
||||
name=name,
|
||||
action_type="schedule",
|
||||
schedule_type=schedule_type,
|
||||
target_branch=target_branch,
|
||||
prompt=prompt,
|
||||
time=time_val,
|
||||
interval_minutes=interval_minutes,
|
||||
fresh=True,
|
||||
max_turns=50,
|
||||
enabled=True,
|
||||
)
|
||||
|
||||
_success(f"Schedule created: {action['id']}")
|
||||
console.print(f" [dim]Name:[/dim] {action['name']}")
|
||||
console.print(f" [dim]Target:[/dim] {target_branch}")
|
||||
console.print(f" [dim]Type:[/dim] {schedule_type}")
|
||||
if time_val:
|
||||
console.print(f" [dim]Time:[/dim] {time_val}")
|
||||
if interval_minutes:
|
||||
console.print(f" [dim]Every:[/dim] {interval_minutes} minutes")
|
||||
console.print()
|
||||
logger.info("[DAEMON] actions: Schedule set")
|
||||
return True
|
||||
|
||||
|
||||
def _handle_migrate(_args: List[str]) -> bool:
|
||||
"""Handle 'actions migrate' -- import plugins into registry."""
|
||||
console.print()
|
||||
console.print("[dim]Scanning plugins/ for unregistered plugins...[/dim]")
|
||||
|
||||
count = migrate_plugins()
|
||||
|
||||
if count > 0:
|
||||
_success(f"Migrated {count} plugin(s) into the action registry")
|
||||
else:
|
||||
console.print("[dim]All plugins already registered (or none found).[/dim]")
|
||||
|
||||
# Show the updated list
|
||||
actions = list_actions()
|
||||
_print_actions_table(actions)
|
||||
logger.info("[DAEMON] actions: Plugin migration completed")
|
||||
return True
|
||||
|
||||
|
||||
def _handle_delete(args: List[str]) -> bool:
|
||||
"""Handle 'actions delete <id>'."""
|
||||
if not args:
|
||||
_error("Action ID required: actions delete <id>")
|
||||
return True # Error displayed
|
||||
|
||||
action_id = args[0]
|
||||
action = get_action(action_id)
|
||||
if action is None:
|
||||
_error(f"Action not found: {action_id}")
|
||||
return True # Error displayed
|
||||
|
||||
delete_action(action_id)
|
||||
_success(f"Deleted action {action_id}: {action['name']}")
|
||||
logger.info("[DAEMON] actions: Action deleted")
|
||||
return True
|
||||
|
||||
|
||||
# =============================================
|
||||
# DATE PARSING
|
||||
# =============================================
|
||||
|
||||
|
||||
def _parse_date(date_str: str) -> str:
|
||||
"""
|
||||
Parse a date string into ISO format.
|
||||
|
||||
Supports: YYYY-MM-DD, 1d, 7d, 1w, 2w
|
||||
|
||||
Returns:
|
||||
ISO date string or empty string on failure.
|
||||
"""
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
date_str = date_str.strip()
|
||||
|
||||
# Relative dates
|
||||
if date_str.endswith("d"):
|
||||
try:
|
||||
days = int(date_str[:-1])
|
||||
return (datetime.now() + timedelta(days=days)).strftime("%Y-%m-%d")
|
||||
except ValueError:
|
||||
logger.warning("Invalid relative day format: %s", date_str)
|
||||
return ""
|
||||
elif date_str.endswith("w"):
|
||||
try:
|
||||
weeks = int(date_str[:-1])
|
||||
return (datetime.now() + timedelta(weeks=weeks)).strftime("%Y-%m-%d")
|
||||
except ValueError:
|
||||
logger.warning("Invalid relative week format: %s", date_str)
|
||||
return ""
|
||||
|
||||
# ISO date
|
||||
try:
|
||||
datetime.strptime(date_str, "%Y-%m-%d")
|
||||
return date_str
|
||||
except ValueError:
|
||||
logger.warning("Invalid ISO date format: %s", date_str)
|
||||
return ""
|
||||
|
||||
|
||||
# =============================================
|
||||
# ORCHESTRATION
|
||||
# =============================================
|
||||
|
||||
|
||||
def _route_set_subcommand(args: List[str]) -> bool:
|
||||
"""Route 'actions set reminder ...' / 'actions set schedule ...'."""
|
||||
if len(args) < 2:
|
||||
_error("Usage: actions set <reminder|schedule> ...")
|
||||
return True # Error displayed
|
||||
set_type = args[1]
|
||||
if set_type == "reminder":
|
||||
return _handle_set_reminder(args[2:])
|
||||
if set_type == "schedule":
|
||||
return _handle_set_schedule(args[2:])
|
||||
_error(f"Unknown set type: {set_type}. Use 'reminder' or 'schedule'.")
|
||||
return True # Error displayed
|
||||
|
||||
|
||||
def _route_action_id(action_id: str, args: List[str]) -> bool:
|
||||
"""Route 'actions <4-digit-id> [on|off|info]'."""
|
||||
if len(args) < 2:
|
||||
return _handle_info(action_id)
|
||||
sub_action = args[1]
|
||||
if sub_action == "on":
|
||||
return _handle_toggle(action_id, True)
|
||||
if sub_action == "off":
|
||||
return _handle_toggle(action_id, False)
|
||||
if sub_action == "info":
|
||||
return _handle_info(action_id)
|
||||
_error(f"Unknown action command: {sub_action}. Use 'on', 'off', or 'info'.")
|
||||
return True # Error displayed
|
||||
|
||||
|
||||
def handle_command(command: str, args: List[str]) -> bool:
|
||||
"""
|
||||
Handle 'actions' command and route to subcommands.
|
||||
|
||||
Args:
|
||||
command: Command name (should be 'actions')
|
||||
args: Command arguments
|
||||
|
||||
Returns:
|
||||
True if handled, False otherwise
|
||||
"""
|
||||
"""Handle 'actions' command — retired, shows migration notice."""
|
||||
if command != "actions":
|
||||
return False
|
||||
|
||||
try:
|
||||
# No args -- introspection gate
|
||||
if not args:
|
||||
print_introspection()
|
||||
return True
|
||||
if not args:
|
||||
print_introspection()
|
||||
return True
|
||||
|
||||
# Help flag
|
||||
if args[0] in ["--help", "-h", "help"]:
|
||||
print_help()
|
||||
return True
|
||||
if args[0] in ("--help", "-h", "help"):
|
||||
print_introspection()
|
||||
return True
|
||||
|
||||
subcommand = args[0]
|
||||
|
||||
json_handler.log_operation("actions_command", {"subcommand": args[0] if args else "introspection"})
|
||||
|
||||
# Named subcommands
|
||||
if subcommand == "list":
|
||||
return _handle_list(args[1:])
|
||||
if subcommand == "migrate":
|
||||
return _handle_migrate(args[1:])
|
||||
if subcommand == "delete":
|
||||
return _handle_delete(args[1:])
|
||||
if subcommand == "set":
|
||||
return _route_set_subcommand(args)
|
||||
|
||||
# Check if first arg is an action ID (4-digit numeric)
|
||||
if subcommand.isdigit() and len(subcommand) == 4:
|
||||
return _route_action_id(subcommand, args)
|
||||
|
||||
_error(f"Unknown subcommand: {subcommand}")
|
||||
console.print("[dim]Run 'actions --help' for available commands[/dim]")
|
||||
return True # Command was handled (error displayed)
|
||||
|
||||
except Exception as e:
|
||||
logger.error("[actions] Error in actions command: %s", e, exc_info=True)
|
||||
_error(f"Error: {e}")
|
||||
return True # Error displayed
|
||||
|
||||
|
||||
# =============================================
|
||||
# MAIN ENTRY
|
||||
# =============================================
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""Main entry point for direct execution."""
|
||||
args = sys.argv[1:]
|
||||
|
||||
if not args or args[0] in ["--help", "-h", "help"]:
|
||||
print_help()
|
||||
return
|
||||
|
||||
handle_command("actions", args)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
json_handler.log_operation("actions_command_retired", {"args": args[:2]})
|
||||
logger.info("[actions] Retired CLI invoked")
|
||||
print_introspection()
|
||||
return True
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
# =================== AIPass ====================
|
||||
# Name: queue.py
|
||||
# Description: Unified job queue view (drone @daemon queue)
|
||||
# Version: 1.0.0
|
||||
# Created: 2026-06-25
|
||||
# Modified: 2026-06-25
|
||||
# =============================================
|
||||
|
||||
"""
|
||||
Unified queue view — aggregates .daemon/schedule.json jobs joined to runstate.
|
||||
|
||||
Human-readable Rich table (default) or --json matching the frozen contract
|
||||
consumed by @skills' scheduler bot.
|
||||
"""
|
||||
|
||||
import json
|
||||
from datetime import datetime, timezone
|
||||
from typing import List, Optional
|
||||
|
||||
from aipass.prax import logger
|
||||
from aipass.cli.apps.modules import console
|
||||
from aipass.daemon.apps.handlers.json import json_handler
|
||||
from aipass.daemon.apps.handlers.schedule.discovery import discover_jobs
|
||||
from aipass.daemon.apps.handlers.schedule.runstate import (
|
||||
load_runstate,
|
||||
get_job_state,
|
||||
)
|
||||
|
||||
HANDLED_COMMANDS = {"queue"}
|
||||
|
||||
|
||||
def print_introspection():
|
||||
"""Display module introspection info."""
|
||||
console.print()
|
||||
console.print("[bold cyan]queue Module[/bold cyan]")
|
||||
console.print()
|
||||
console.print("[dim]Unified job queue view — .daemon/ jobs joined to runstate[/dim]")
|
||||
console.print()
|
||||
console.print("[yellow]Reads:[/yellow]")
|
||||
console.print(" [cyan]*[/cyan] src/aipass/*/.daemon/*.json [dim](per-branch schedule files)[/dim]")
|
||||
console.print(" [cyan]*[/cyan] daemon_json/daemon_runstate.json [dim](last_run/status state)[/dim]")
|
||||
console.print()
|
||||
|
||||
|
||||
def print_help():
|
||||
"""Display usage information."""
|
||||
console.print("\n[bold cyan]queue — Unified Job Queue View[/bold cyan]")
|
||||
console.print("\n[yellow]USAGE:[/yellow]")
|
||||
console.print(" drone @daemon queue Show job queue (Rich table)")
|
||||
console.print(" drone @daemon queue --json Machine-readable JSON (frozen schema)")
|
||||
console.print(" drone @daemon queue --help Show this help message")
|
||||
console.print()
|
||||
|
||||
|
||||
def _schedule_human(job: dict) -> str:
|
||||
"""Build human-readable schedule string."""
|
||||
sched = job.get("schedule", {})
|
||||
sched_type = sched.get("type", "")
|
||||
if sched_type == "once":
|
||||
return sched.get("due_date", "?")
|
||||
if sched_type == "daily":
|
||||
return f"daily @ {sched.get('time', '??:??')}"
|
||||
if sched_type == "hourly":
|
||||
m = sched.get("time", "0")
|
||||
return f"hourly @ :{int(m):02d}"
|
||||
if sched_type == "interval":
|
||||
mins = sched.get("interval_minutes", 0)
|
||||
if mins >= 60:
|
||||
return f"every {mins // 60}h"
|
||||
return f"every {mins}m"
|
||||
return sched_type
|
||||
|
||||
|
||||
def _compute_next_run(job: dict, state: dict) -> Optional[str]:
|
||||
"""Determine next_run from runstate or schedule."""
|
||||
if state.get("completed"):
|
||||
return None
|
||||
next_run = state.get("next_run")
|
||||
if next_run:
|
||||
return next_run
|
||||
sched = job.get("schedule", {})
|
||||
if sched.get("type") == "once":
|
||||
due = sched.get("due_date")
|
||||
if due and "T" not in due:
|
||||
return f"{due}T09:00:00"
|
||||
return due
|
||||
return None
|
||||
|
||||
|
||||
def _build_queue(jobs: list, runstate: dict) -> list:
|
||||
"""Build unified queue entries from discovered jobs + runstate."""
|
||||
entries = []
|
||||
for job in jobs:
|
||||
state = get_job_state(runstate, job["owner"], job["id"])
|
||||
if state.get("completed"):
|
||||
continue
|
||||
|
||||
owner = job["owner"]
|
||||
if owner.startswith("@"):
|
||||
pass
|
||||
elif "@" in owner:
|
||||
owner = f"@{owner.split('@')[0]}"
|
||||
|
||||
prompt = job.get("prompt", "")
|
||||
flat = " ".join(prompt.split()) # collapse newlines/whitespace → single-line preview
|
||||
preview = flat[:80] + "..." if len(flat) > 80 else flat
|
||||
|
||||
entries.append(
|
||||
{
|
||||
"owner": owner,
|
||||
"id": job["id"],
|
||||
"enabled": job.get("enabled", True),
|
||||
"type": job["schedule"].get("type", ""),
|
||||
"schedule_human": _schedule_human(job),
|
||||
"next_run": _compute_next_run(job, state),
|
||||
"last_run": state.get("last_run"),
|
||||
"last_status": state.get("last_status"),
|
||||
"last_error": state.get("last_error"),
|
||||
"prompt_preview": preview,
|
||||
"wake": job.get("wake", {}),
|
||||
}
|
||||
)
|
||||
return entries
|
||||
|
||||
|
||||
def _print_rich_table(entries: list) -> None:
|
||||
"""Print queue as a Rich table."""
|
||||
console.print()
|
||||
console.print("[bold cyan]Job Queue[/bold cyan]")
|
||||
console.print()
|
||||
|
||||
if not entries:
|
||||
console.print("[dim]No jobs in queue.[/dim]")
|
||||
console.print()
|
||||
return
|
||||
|
||||
console.print(
|
||||
f" {'OWNER':<14} {'ID':<20} {'ON':<4} {'TYPE':<9} {'SCHEDULE':<18} {'LAST STATUS':<12} {'NEXT RUN':<20}"
|
||||
)
|
||||
console.print(" " + "-" * 97)
|
||||
|
||||
for e in entries:
|
||||
enabled = "[green]ON[/green] " if e["enabled"] else "[red]OFF[/red]"
|
||||
last_status = e.get("last_status") or "-"
|
||||
next_run = (e.get("next_run") or "-")[:19]
|
||||
console.print(
|
||||
f" {e['owner']:<14} {e['id']:<20} {enabled:<4} {e['type']:<9} "
|
||||
f"{e['schedule_human']:<18} {last_status:<12} {next_run:<20}"
|
||||
)
|
||||
|
||||
console.print()
|
||||
enabled_count = sum(1 for e in entries if e["enabled"])
|
||||
console.print(f" [dim]Total: {len(entries)} job(s) ({enabled_count} enabled)[/dim]")
|
||||
console.print()
|
||||
|
||||
|
||||
def _build_json_output(entries: list) -> dict:
|
||||
"""Build frozen-schema JSON output for @skills consumption."""
|
||||
return {
|
||||
"generated_at": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
|
||||
"count": len(entries),
|
||||
"jobs": entries,
|
||||
}
|
||||
|
||||
|
||||
def handle_command(command: str, args: List[str]) -> bool:
|
||||
"""Handle 'queue' command from daemon CLI router."""
|
||||
if command not in HANDLED_COMMANDS:
|
||||
return False
|
||||
|
||||
if not args:
|
||||
json_handler.log_operation("queue_command", {"json": False})
|
||||
jobs = discover_jobs()
|
||||
runstate = load_runstate()
|
||||
entries = _build_queue(jobs, runstate)
|
||||
_print_rich_table(entries)
|
||||
logger.info("[queue] Queue displayed (%d jobs)", len(entries))
|
||||
return True
|
||||
|
||||
if args[0] in ("--help", "-h"):
|
||||
print_help()
|
||||
return True
|
||||
|
||||
json_handler.log_operation("queue_command", {"json": "--json" in args})
|
||||
|
||||
jobs = discover_jobs()
|
||||
runstate = load_runstate()
|
||||
entries = _build_queue(jobs, runstate)
|
||||
|
||||
if "--json" in args:
|
||||
output = _build_json_output(entries)
|
||||
# soft_wrap=True + markup=False — default console.print forces width-80
|
||||
# wrapping on non-TTY and parses [] markup, injecting newlines mid-string
|
||||
# that corrupt machine output. These flags emit clean, parseable JSON.
|
||||
console.print(json.dumps(output, indent=2), soft_wrap=True, markup=False)
|
||||
else:
|
||||
_print_rich_table(entries)
|
||||
|
||||
logger.info("[queue] Queue displayed (%d jobs)", len(entries))
|
||||
return True
|
||||
@@ -27,6 +27,7 @@ from aipass.daemon.apps.handlers.schedule.runstate import (
|
||||
save_runstate,
|
||||
is_job_due,
|
||||
update_job_runstate,
|
||||
record_job_failure,
|
||||
job_key,
|
||||
prune_orphans,
|
||||
)
|
||||
@@ -69,6 +70,38 @@ def print_help():
|
||||
console.print("\n[yellow]DESCRIPTION:[/yellow]")
|
||||
console.print(" Sweeps src/aipass/*/.daemon/*.json for scheduled jobs,")
|
||||
console.print(" evaluates due-ness, and wakes each due branch via wake_branch().")
|
||||
console.print("\n[bold cyan]SCHEDULING — How to author a job:[/bold cyan]")
|
||||
console.print(" [bold]File:[/bold] src/aipass/<branch>/.daemon/schedule.json")
|
||||
console.print()
|
||||
console.print(" [bold]Schema:[/bold]")
|
||||
console.print(" {")
|
||||
console.print(' "version": 1,')
|
||||
console.print(' "branch": "@<branch>",')
|
||||
console.print(' "jobs": [')
|
||||
console.print(" {")
|
||||
console.print(' "id": "my-job",')
|
||||
console.print(' "enabled": true,')
|
||||
console.print(' "schedule": { "type": "interval", "interval_minutes": 30 },')
|
||||
console.print(' "wake": { "fresh": true, "model": "haiku" },')
|
||||
console.print(' "prompt": "Do something, then STOP."')
|
||||
console.print(" }")
|
||||
console.print(" ]")
|
||||
console.print(" }")
|
||||
console.print()
|
||||
console.print(" [bold]Schedule types:[/bold]")
|
||||
console.print(" [cyan]interval[/cyan] interval_minutes: N")
|
||||
console.print(" [dim]Fires when elapsed >= N since last_run. Fires immediately if never run.[/dim]")
|
||||
console.print(" [cyan]daily[/cyan] time: HH:MM")
|
||||
console.print(" [dim]+/-15 min window, once per day.[/dim]")
|
||||
console.print(" [cyan]hourly[/cyan] time: M [dim](minute of hour)[/dim]")
|
||||
console.print(" [dim]+/-15 min window, once per hour.[/dim]")
|
||||
console.print(" [cyan]once[/cyan] due_date: YYYY-MM-DD")
|
||||
console.print(" [dim]Fires when date <= today, then marks completed.[/dim]")
|
||||
console.print()
|
||||
console.print(" [bold]wake options:[/bold] fresh (bool), model (haiku/sonnet — use light models)")
|
||||
console.print()
|
||||
console.print(" [bold]Staggering:[/bold] No native offset field. Seed different last_run values")
|
||||
console.print(" in daemon_json/daemon_runstate.json to offset first-fire timing.")
|
||||
console.print()
|
||||
|
||||
|
||||
@@ -80,18 +113,36 @@ def _log(message: str) -> None:
|
||||
console.print(f"[{timestamp}] {message}")
|
||||
|
||||
|
||||
def _fire_job(job: dict) -> bool:
|
||||
"""Fire a single job via direct wake_branch import (DPLAN-0204 path A)."""
|
||||
def _should_notify(job: dict) -> bool:
|
||||
"""Check if this job should emit telegram notifications."""
|
||||
return job.get("notify", True)
|
||||
|
||||
|
||||
def _fire_job(job: dict) -> tuple:
|
||||
"""Fire a single job via direct wake_branch import (DPLAN-0204 path A).
|
||||
|
||||
Returns (ok: bool, error_msg: str).
|
||||
"""
|
||||
# Cross-branch handler import authorized by DPLAN-0204 §2.8
|
||||
from aipass.ai_mail.apps.handlers.dispatch.wake import wake_branch # noqa: E402
|
||||
from aipass.daemon.apps.handlers.schedule.telegram_notifier import (
|
||||
notify_triggered,
|
||||
notify_complete,
|
||||
notify_error,
|
||||
)
|
||||
|
||||
owner = job["owner"]
|
||||
job_id = job["id"]
|
||||
prompt = job["prompt"]
|
||||
wake = job.get("wake", {})
|
||||
fresh = wake.get("fresh", True)
|
||||
model = wake.get("model")
|
||||
notify = _should_notify(job)
|
||||
|
||||
_log(f"FIRE: {owner}/{job['id']} -> wake_branch({owner}, fresh={fresh}, model={model})")
|
||||
_log(f"FIRE: {owner}/{job_id} -> wake_branch({owner}, fresh={fresh}, model={model})")
|
||||
|
||||
if notify:
|
||||
notify_triggered(owner, job_id)
|
||||
|
||||
try:
|
||||
status, ok = wake_branch(
|
||||
@@ -103,16 +154,24 @@ def _fire_job(job: dict) -> bool:
|
||||
model=model,
|
||||
)
|
||||
if ok:
|
||||
_log(f"OK: {owner}/{job['id']} — {status.summary}")
|
||||
logger.info("[run] Fired %s/%s successfully", owner, job["id"])
|
||||
_log(f"OK: {owner}/{job_id} — {status.summary}")
|
||||
logger.info("[run] Fired %s/%s successfully", owner, job_id)
|
||||
if notify:
|
||||
notify_complete(owner, job_id, status.summary)
|
||||
return True, ""
|
||||
else:
|
||||
_log(f"FAIL: {owner}/{job['id']} — {status.summary}")
|
||||
logger.warning("[run] Failed to fire %s/%s: %s", owner, job["id"], status.summary)
|
||||
return ok
|
||||
msg = status.summary
|
||||
_log(f"FAIL: {owner}/{job_id} — {msg}")
|
||||
logger.warning("[run] Failed to fire %s/%s: %s", owner, job_id, msg)
|
||||
if notify:
|
||||
notify_error(owner, job_id, msg)
|
||||
return False, msg
|
||||
except Exception as e:
|
||||
logger.error("[run] Exception firing %s/%s: %s", owner, job["id"], e)
|
||||
_log(f"ERROR: {owner}/{job['id']} — {e}")
|
||||
return False
|
||||
logger.error("[run] Exception firing %s/%s: %s", owner, job_id, e)
|
||||
_log(f"ERROR: {owner}/{job_id} — {e}")
|
||||
if notify:
|
||||
notify_error(owner, job_id, str(e))
|
||||
return False, str(e)
|
||||
|
||||
|
||||
def run_tick(dry_run: bool = False) -> dict:
|
||||
@@ -177,13 +236,14 @@ def run_tick(dry_run: bool = False) -> dict:
|
||||
|
||||
# Step 4: Fire due jobs
|
||||
for job in due_jobs:
|
||||
ok = _fire_job(job)
|
||||
ok, error_msg = _fire_job(job)
|
||||
if ok:
|
||||
results["fired"] += 1
|
||||
update_job_runstate(runstate, job["owner"], job["id"], job["schedule"])
|
||||
save_runstate(runstate)
|
||||
else:
|
||||
results["failed"] += 1
|
||||
record_job_failure(runstate, job["owner"], job["id"], error_msg)
|
||||
save_runstate(runstate)
|
||||
|
||||
if job != due_jobs[-1]:
|
||||
time.sleep(1.0)
|
||||
|
||||
@@ -1,436 +1,51 @@
|
||||
# =================== AIPass ====================
|
||||
# Name: schedule.py
|
||||
# Description: DAEMON Scheduled Follow-ups Module
|
||||
# Version: 1.0.0
|
||||
# Description: DAEMON Scheduled Follow-ups Module (RETIRED)
|
||||
# Version: 2.0.0
|
||||
# Created: 2026-02-04
|
||||
# Modified: 2026-02-04
|
||||
# Modified: 2026-06-25
|
||||
# =============================================
|
||||
|
||||
"""
|
||||
CLI interface for fire-and-forget scheduled follow-ups.
|
||||
RETIRED — the old fire-and-forget follow-up CLI.
|
||||
|
||||
Superseded by the decentralized .daemon/schedule.json model (DPLAN-0204).
|
||||
Author jobs directly in a branch's .daemon/schedule.json file.
|
||||
See: drone @daemon run --help (full schema + schedule types)
|
||||
"""
|
||||
|
||||
# =============================================
|
||||
# IMPORTS
|
||||
# =============================================
|
||||
|
||||
import sys
|
||||
import argparse
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from typing import List
|
||||
|
||||
from aipass.prax import logger
|
||||
|
||||
from aipass.cli.apps.modules import console, error as cli_error
|
||||
from aipass.cli.apps.modules import console
|
||||
from aipass.daemon.apps.handlers.json import json_handler
|
||||
from aipass.daemon.apps.handlers.schedule.task_registry import (
|
||||
load_tasks,
|
||||
create_task,
|
||||
delete_task,
|
||||
parse_due_date,
|
||||
process_due_tasks_batch,
|
||||
ensure_lock_dir,
|
||||
)
|
||||
|
||||
# File lock for single-instance execution
|
||||
try:
|
||||
from filelock import FileLock, Timeout
|
||||
|
||||
FILELOCK_AVAILABLE = True
|
||||
except ImportError:
|
||||
FILELOCK_AVAILABLE = False
|
||||
FileLock = None # type: ignore[assignment,misc]
|
||||
Timeout = None # type: ignore[assignment,misc]
|
||||
logger.info("Optional: filelock not available")
|
||||
|
||||
|
||||
def _header(text):
|
||||
console.print(f"\n[bold cyan]{'=' * 70}[/bold cyan]")
|
||||
console.print(f"[bold cyan] {text}[/bold cyan]")
|
||||
console.print(f"[bold cyan]{'=' * 70}[/bold cyan]")
|
||||
|
||||
|
||||
def _success(text):
|
||||
console.print(f"[green]OK:[/green] {text}")
|
||||
|
||||
|
||||
def _error(text):
|
||||
cli_error(text)
|
||||
|
||||
|
||||
def _send_email_via_drone(
|
||||
to_branch, subject, message, from_branch="@daemon", auto_execute=True, reply_to=None, **kwargs
|
||||
):
|
||||
"""Send email via drone @ai_mail send subprocess."""
|
||||
cmd = ["drone", "@ai_mail", "send", to_branch, subject, message]
|
||||
if auto_execute:
|
||||
cmd.append("--dispatch")
|
||||
try:
|
||||
result = subprocess.run(cmd, capture_output=True, text=True, timeout=DRONE_SUBPROCESS_TIMEOUT)
|
||||
return result.returncode == 0
|
||||
except (subprocess.SubprocessError, OSError) as e:
|
||||
logger.warning("Drone email subprocess failed: %s", e)
|
||||
return False
|
||||
|
||||
|
||||
AI_MAIL_AVAILABLE = True
|
||||
send_email_direct = _send_email_via_drone
|
||||
|
||||
# =============================================
|
||||
# CONSTANTS
|
||||
# =============================================
|
||||
|
||||
MODULE_NAME = "schedule"
|
||||
|
||||
# Constants
|
||||
DRONE_SUBPROCESS_TIMEOUT = 15 # seconds
|
||||
STALE_DISPATCH_MAX_AGE = 5 # minutes
|
||||
LOCK_ACQUIRE_TIMEOUT = 0 # seconds (non-blocking)
|
||||
|
||||
|
||||
# =============================================
|
||||
# INTROSPECTION
|
||||
# =============================================
|
||||
|
||||
|
||||
def print_introspection():
|
||||
"""Display module introspection info."""
|
||||
"""Display retirement notice."""
|
||||
console.print()
|
||||
console.print("[bold cyan]schedule Module[/bold cyan]")
|
||||
console.print("[bold cyan]schedule Module[/bold cyan] [yellow](RETIRED)[/yellow]")
|
||||
console.print()
|
||||
console.print("[dim]CLI interface for fire-and-forget scheduled follow-ups[/dim]")
|
||||
console.print("[dim]This CLI has been retired. Jobs now live in per-branch .daemon/schedule.json files.[/dim]")
|
||||
console.print("[dim]Run [bold]drone @daemon run --help[/bold] for the schema and schedule types.[/dim]")
|
||||
console.print("[dim]Run [bold]drone @daemon queue[/bold] to see the unified job queue.[/dim]")
|
||||
console.print()
|
||||
console.print("[yellow]Connected Handlers:[/yellow]")
|
||||
console.print(" handlers/schedule/")
|
||||
console.print(
|
||||
" [cyan]*[/cyan] task_registry.py"
|
||||
" [dim](load_tasks, create_task, delete_task,"
|
||||
" get_due_tasks, mark_completed, parse_due_date,"
|
||||
" mark_dispatching, mark_pending, recover_stale_dispatches,"
|
||||
" process_due_tasks_batch, ensure_lock_dir"
|
||||
" — task CRUD and processing)[/dim]"
|
||||
)
|
||||
console.print()
|
||||
|
||||
|
||||
_DAEMON_ROOT = Path(__file__).resolve().parents[3] # src/aipass/daemon/
|
||||
JSON_DIR = _DAEMON_ROOT / "daemon_json"
|
||||
|
||||
# =============================================
|
||||
# OUTPUT FORMATTING
|
||||
# =============================================
|
||||
|
||||
|
||||
def _print_task_list(tasks: List[dict]) -> None:
|
||||
"""Print formatted task list to console."""
|
||||
console.print()
|
||||
_header("Scheduled Tasks")
|
||||
console.print()
|
||||
|
||||
pending_tasks = [t for t in tasks if t.get("status") == "pending"]
|
||||
completed_tasks = [t for t in tasks if t.get("status") == "completed"]
|
||||
|
||||
if not pending_tasks:
|
||||
console.print("[dim]No pending scheduled tasks.[/dim]")
|
||||
else:
|
||||
console.print("[bold cyan]PENDING TASKS[/bold cyan]")
|
||||
console.print(f"{'ID':<10} {'DUE':<20} {'TO':<15} {'TASK':<40}")
|
||||
console.print("-" * 85)
|
||||
|
||||
for task in pending_tasks:
|
||||
task_id = task.get("id", "")[:8]
|
||||
due = task.get("due_date", "")
|
||||
recipient = task.get("recipient", "")
|
||||
task_text = task.get("task", "")[:38]
|
||||
console.print(f"{task_id:<10} {due:<20} {recipient:<15} {task_text:<40}")
|
||||
|
||||
console.print()
|
||||
console.print(f"[dim]Total: {len(pending_tasks)} pending, {len(completed_tasks)} completed[/dim]")
|
||||
console.print()
|
||||
|
||||
|
||||
def _print_help() -> None:
|
||||
"""Display help using Rich formatted output."""
|
||||
console.print()
|
||||
_header("Schedule Module - Fire-and-Forget Follow-ups")
|
||||
console.print()
|
||||
|
||||
console.print("[yellow]USAGE:[/yellow]")
|
||||
console.print(' drone @daemon schedule create "task" --due 7d --to @branch --message "details"')
|
||||
console.print(" drone @daemon schedule list")
|
||||
console.print(" drone @daemon schedule delete <id>")
|
||||
console.print(" drone @daemon schedule run-due")
|
||||
console.print()
|
||||
|
||||
console.print("[yellow]COMMANDS:[/yellow]")
|
||||
console.print(" create Create a new scheduled task")
|
||||
console.print(" list List all pending scheduled tasks")
|
||||
console.print(" delete Delete a scheduled task by ID")
|
||||
console.print(" run-due Execute all due tasks (sends emails, marks complete)")
|
||||
console.print()
|
||||
|
||||
console.print("[yellow]CREATE OPTIONS:[/yellow]")
|
||||
console.print(" --due (Required) Due date: 1d, 7d, 2w, 1m, or ISO date (2026-02-15)")
|
||||
console.print(" --to (Required) Recipient branch (e.g., @flow, @seedgo)")
|
||||
console.print(" --message (Optional) Additional details for the follow-up")
|
||||
console.print()
|
||||
|
||||
console.print("[yellow]EXAMPLES:[/yellow]")
|
||||
console.print(" # Remind Flow to check on a plan in 7 days")
|
||||
console.print(' schedule create "Check FPLAN-0290 status" --due 7d --to @flow')
|
||||
console.print()
|
||||
console.print(" # Follow up with Seedgo about code review in 2 weeks")
|
||||
console.print(' schedule create "Code review follow-up" --due 2w --to @seedgo --message "Review PR #45"')
|
||||
console.print()
|
||||
console.print(" # Check all due tasks and send reminder emails")
|
||||
console.print(" schedule run-due")
|
||||
console.print()
|
||||
|
||||
|
||||
# =============================================
|
||||
# SUBCOMMAND HANDLERS
|
||||
# =============================================
|
||||
|
||||
|
||||
def _handle_create(args: List[str]) -> bool:
|
||||
"""Handle schedule create subcommand."""
|
||||
parser = argparse.ArgumentParser(prog="schedule create", add_help=False)
|
||||
parser.add_argument("task", nargs="?", help="Task description")
|
||||
parser.add_argument("--due", required=True, help="Due date (1d, 7d, 2w, 1m, or ISO date)")
|
||||
parser.add_argument("--to", required=True, dest="recipient", help="Recipient branch")
|
||||
parser.add_argument("--message", default="", help="Additional message details")
|
||||
|
||||
try:
|
||||
parsed = parser.parse_args(args)
|
||||
except SystemExit:
|
||||
logger.warning("Invalid arguments for schedule create")
|
||||
_error('Usage: schedule create "task" --due <date> --to @branch [--message "details"]')
|
||||
return False
|
||||
|
||||
if not parsed.task:
|
||||
_error("Task description is required")
|
||||
console.print('[dim]Usage: schedule create "task" --due <date> --to @branch[/dim]')
|
||||
return False
|
||||
|
||||
# Parse and validate due date
|
||||
due_date = parse_due_date(parsed.due)
|
||||
if not due_date:
|
||||
_error(f"Invalid due date format: {parsed.due}")
|
||||
console.print("[dim]Valid formats: 1d, 7d, 2w, 1m, or ISO date (2026-02-15)[/dim]")
|
||||
return False
|
||||
|
||||
# Create the task
|
||||
try:
|
||||
new_task = create_task(task=parsed.task, due_date=due_date, recipient=parsed.recipient, message=parsed.message)
|
||||
task_id = new_task.get("id", "")
|
||||
|
||||
_success(f"Scheduled task created: {task_id[:8]}")
|
||||
console.print(f" [dim]Task:[/dim] {parsed.task}")
|
||||
console.print(f" [dim]Due:[/dim] {due_date}")
|
||||
console.print(f" [dim]To:[/dim] {parsed.recipient}")
|
||||
if parsed.message:
|
||||
console.print(f" [dim]Msg:[/dim] {parsed.message[:50]}...")
|
||||
console.print()
|
||||
|
||||
logger.info(f"[DAEMON] Scheduled task created: {task_id[:8]} -> {parsed.recipient}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
_error(f"Failed to create task: {e}")
|
||||
logger.error(f"[DAEMON] Failed to create scheduled task: {e}", exc_info=True)
|
||||
return False
|
||||
|
||||
|
||||
def _handle_list(_args: List[str]) -> bool:
|
||||
"""Handle schedule list subcommand."""
|
||||
try:
|
||||
tasks = load_tasks()
|
||||
_print_task_list(tasks)
|
||||
logger.info("[DAEMON] schedule: Task list displayed")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
_error(f"Failed to load tasks: {e}")
|
||||
logger.error(f"[DAEMON] Failed to load scheduled tasks: {e}", exc_info=True)
|
||||
return False
|
||||
|
||||
|
||||
def _handle_delete(args: List[str]) -> bool:
|
||||
"""Handle schedule delete subcommand."""
|
||||
if not args:
|
||||
_error("Task ID is required")
|
||||
console.print("[dim]Usage: schedule delete <task_id>[/dim]")
|
||||
return False
|
||||
|
||||
task_id = args[0]
|
||||
|
||||
try:
|
||||
deleted = delete_task(task_id)
|
||||
if deleted:
|
||||
_success(f"Task deleted: {task_id[:8]}")
|
||||
logger.info(f"[DAEMON] Scheduled task deleted: {task_id[:8]}")
|
||||
return True
|
||||
else:
|
||||
_error(f"Task not found: {task_id[:8]}")
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
_error(f"Failed to delete task: {e}")
|
||||
logger.error(f"[DAEMON] Failed to delete scheduled task: {e}", exc_info=True)
|
||||
return False
|
||||
|
||||
|
||||
def _handle_run_due(_args: List[str]) -> bool:
|
||||
"""Handle schedule run-due subcommand with single-instance lock."""
|
||||
if not FILELOCK_AVAILABLE:
|
||||
console.print("[dim]filelock not available, running without lock.[/dim]")
|
||||
return _process_due_tasks()
|
||||
|
||||
lock_file = JSON_DIR / "schedule.lock"
|
||||
ensure_lock_dir()
|
||||
|
||||
# Try to acquire lock (non-blocking)
|
||||
# FILELOCK_AVAILABLE guard above ensures these are not None
|
||||
lock = FileLock(lock_file, timeout=LOCK_ACQUIRE_TIMEOUT) # type: ignore[misc]
|
||||
try:
|
||||
with lock.acquire(timeout=LOCK_ACQUIRE_TIMEOUT):
|
||||
return _process_due_tasks()
|
||||
except Timeout: # type: ignore[misc]
|
||||
logger.warning("Schedule run-due already in progress, skipping")
|
||||
console.print("[dim]Schedule run-due already in progress, skipping.[/dim]")
|
||||
return True
|
||||
|
||||
|
||||
def _display_task_result(task_result: dict) -> None:
|
||||
"""Display a single processed task result."""
|
||||
task_id = task_result.get("id", "")[:8]
|
||||
recipient = task_result.get("recipient", "")
|
||||
task_desc = task_result.get("task", "")[:40]
|
||||
status = task_result.get("status", "")
|
||||
|
||||
if status == "sent":
|
||||
_success(f"Sent to {recipient}: {task_desc}")
|
||||
logger.info(f"[DAEMON] Scheduled email sent: {task_id} -> {recipient}")
|
||||
elif status == "skipped":
|
||||
_error(f"ai_mail not available, cannot send to {recipient}")
|
||||
elif status == "failed":
|
||||
_error(f"Failed to send to {recipient}: {task_desc}")
|
||||
logger.error(f"[DAEMON] Scheduled email failed: {task_id} -> {recipient}")
|
||||
elif status == "error":
|
||||
_error(f"Error sending to {recipient}: {task_result.get('error', '')}")
|
||||
logger.error(f"[DAEMON] Scheduled email error: {task_id} -> {recipient}: {task_result.get('error', '')}")
|
||||
|
||||
|
||||
def _process_due_tasks() -> bool:
|
||||
"""Process due tasks -- delegates to handler, formats output."""
|
||||
try:
|
||||
# Delegate to handler for all implementation logic
|
||||
email_fn = send_email_direct if AI_MAIL_AVAILABLE else None
|
||||
results = process_due_tasks_batch(send_email_fn=email_fn, stale_max_age=STALE_DISPATCH_MAX_AGE)
|
||||
|
||||
# Display results (module responsibility)
|
||||
if results["recovered"]:
|
||||
console.print(f"[dim]Recovered {results['recovered']} stale dispatch(es)[/dim]")
|
||||
|
||||
if results["due"] == 0:
|
||||
console.print("[dim]No tasks due at this time.[/dim]")
|
||||
return True
|
||||
|
||||
console.print()
|
||||
_header(f"Running {results['due']} Due Task(s)")
|
||||
console.print()
|
||||
|
||||
for task_result in results.get("processed_tasks", []):
|
||||
_display_task_result(task_result)
|
||||
|
||||
console.print()
|
||||
console.print(f"[bold]Results:[/bold] {results['success']} sent, {results['failed']} failed")
|
||||
console.print()
|
||||
|
||||
if results["failed"] > 0:
|
||||
logger.warning("[DAEMON] %d scheduled task(s) failed to send", results["failed"])
|
||||
else:
|
||||
logger.info("[DAEMON] schedule: Processed due tasks")
|
||||
return True # Command was handled (failures are logged, not routing errors)
|
||||
|
||||
except Exception as e:
|
||||
_error(f"Failed to run due tasks: {e}")
|
||||
logger.error(f"[DAEMON] Failed to run due tasks: {e}", exc_info=True)
|
||||
return False
|
||||
|
||||
|
||||
# =============================================
|
||||
# ORCHESTRATION
|
||||
# =============================================
|
||||
|
||||
|
||||
def handle_command(command: str, args: List[str]) -> bool:
|
||||
"""
|
||||
Handle 'schedule' command.
|
||||
|
||||
Args:
|
||||
command: Command name (should be 'schedule')
|
||||
args: Command arguments (subcommand + subcommand args)
|
||||
|
||||
Returns:
|
||||
True if handled, False otherwise
|
||||
"""
|
||||
"""Handle 'schedule' command — retired, shows migration notice."""
|
||||
if command != "schedule":
|
||||
return False
|
||||
|
||||
try:
|
||||
# No args -- introspection gate
|
||||
if not args:
|
||||
print_introspection()
|
||||
return True
|
||||
if not args:
|
||||
print_introspection()
|
||||
return True
|
||||
|
||||
# Handle help flag
|
||||
if args[0] in ["--help", "-h", "help"]:
|
||||
_print_help()
|
||||
return True
|
||||
if args[0] in ("--help", "-h", "help"):
|
||||
print_introspection()
|
||||
return True
|
||||
|
||||
subcommand = args[0]
|
||||
subargs = args[1:]
|
||||
|
||||
json_handler.log_operation("schedule_command", {"subcommand": args[0] if args else "list"})
|
||||
|
||||
# Route to subcommand handlers
|
||||
if subcommand == "create":
|
||||
return _handle_create(subargs)
|
||||
if subcommand == "list":
|
||||
return _handle_list(subargs)
|
||||
if subcommand == "delete":
|
||||
return _handle_delete(subargs)
|
||||
if subcommand == "run-due":
|
||||
return _handle_run_due(subargs)
|
||||
|
||||
_error(f"Unknown subcommand: {subcommand}")
|
||||
console.print("[dim]Run 'schedule --help' for available commands[/dim]")
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[DAEMON] Error in schedule command: {e}", exc_info=True)
|
||||
_error(f"Error: {e}")
|
||||
return False
|
||||
|
||||
|
||||
# =============================================
|
||||
# MAIN ENTRY
|
||||
# =============================================
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""Main entry point for direct execution."""
|
||||
args = sys.argv[1:]
|
||||
|
||||
if len(args) == 0 or args[0] in ["--help", "-h", "help"]:
|
||||
_print_help()
|
||||
return
|
||||
|
||||
# First arg is subcommand when called directly
|
||||
handle_command("schedule", args)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
json_handler.log_operation("schedule_command_retired", {"args": args[:2]})
|
||||
logger.info("[schedule] Retired CLI invoked")
|
||||
print_introspection()
|
||||
return True
|
||||
|
||||
@@ -1,133 +0,0 @@
|
||||
# =================== AIPass ====================
|
||||
# Name: scheduler_ops.py
|
||||
# Description: Scheduler Cron Operations Module
|
||||
# Version: 2.0.0
|
||||
# Created: 2026-03-08
|
||||
# Modified: 2026-03-10
|
||||
# =============================================
|
||||
|
||||
"""
|
||||
Scheduler operations module -- facade for cron entry point.
|
||||
|
||||
Provides a clean module-layer interface over handler functions
|
||||
used by scheduler_cron.py.
|
||||
"""
|
||||
|
||||
from aipass.prax import logger
|
||||
|
||||
from aipass.daemon.apps.handlers.json import json_handler
|
||||
|
||||
try:
|
||||
from aipass.cli.apps.modules.display import console
|
||||
except ImportError:
|
||||
from rich.console import Console
|
||||
|
||||
console = Console()
|
||||
logger.info("Optional: aipass.cli.apps.modules.display not available, using rich.console fallback")
|
||||
|
||||
# =============================================
|
||||
# TASK REGISTRY
|
||||
# =============================================
|
||||
|
||||
try:
|
||||
from aipass.daemon.apps.handlers.schedule.task_registry import (
|
||||
get_due_tasks as get_due_tasks,
|
||||
mark_dispatching as mark_dispatching,
|
||||
mark_completed as mark_completed,
|
||||
mark_pending as mark_pending,
|
||||
recover_stale_dispatches as recover_stale_dispatches,
|
||||
)
|
||||
|
||||
TASK_REGISTRY_AVAILABLE = True
|
||||
except ImportError:
|
||||
TASK_REGISTRY_AVAILABLE = False
|
||||
get_due_tasks = None # type: ignore[assignment]
|
||||
mark_dispatching = None # type: ignore[assignment]
|
||||
mark_completed = None # type: ignore[assignment]
|
||||
mark_pending = None # type: ignore[assignment]
|
||||
recover_stale_dispatches = None # type: ignore[assignment]
|
||||
logger.info("Optional: task_registry not available")
|
||||
|
||||
# =============================================
|
||||
# ACTION REGISTRY (DPLAN-043)
|
||||
# =============================================
|
||||
|
||||
try:
|
||||
from aipass.daemon.apps.handlers.actions.actions_registry import (
|
||||
load_registry as load_registry,
|
||||
is_action_due as is_action_due,
|
||||
update_last_run as update_last_run,
|
||||
mark_reminder_completed as mark_reminder_completed,
|
||||
migrate_plugins as migrate_plugins,
|
||||
next_due_str as next_due_str,
|
||||
)
|
||||
|
||||
ACTION_REGISTRY_AVAILABLE = True
|
||||
except ImportError:
|
||||
ACTION_REGISTRY_AVAILABLE = False
|
||||
load_registry = None # type: ignore[assignment]
|
||||
is_action_due = None # type: ignore[assignment]
|
||||
update_last_run = None # type: ignore[assignment]
|
||||
mark_reminder_completed = None # type: ignore[assignment]
|
||||
migrate_plugins = None # type: ignore[assignment]
|
||||
next_due_str = None # type: ignore[assignment]
|
||||
logger.info("Optional: actions_registry not available")
|
||||
|
||||
|
||||
# =============================================
|
||||
# INTROSPECTION
|
||||
# =============================================
|
||||
|
||||
|
||||
def print_introspection():
|
||||
"""Display module introspection info."""
|
||||
console.print()
|
||||
console.print("[bold cyan]scheduler_ops Module[/bold cyan]")
|
||||
console.print()
|
||||
console.print("[dim]Facade for scheduler_cron.py — re-exports handler functions for cron entry point[/dim]")
|
||||
console.print()
|
||||
console.print("[yellow]Connected Handlers:[/yellow]")
|
||||
console.print(" handlers/schedule/")
|
||||
console.print(
|
||||
" [cyan]*[/cyan] task_registry.py"
|
||||
" [dim](get_due_tasks, mark_dispatching,"
|
||||
" mark_completed, mark_pending,"
|
||||
" recover_stale_dispatches — task lifecycle)[/dim]"
|
||||
)
|
||||
console.print()
|
||||
console.print(" handlers/actions/")
|
||||
console.print(
|
||||
" [cyan]*[/cyan] actions_registry.py"
|
||||
" [dim](load_registry, is_action_due,"
|
||||
" update_last_run, mark_reminder_completed,"
|
||||
" migrate_plugins, next_due_str — action registry)[/dim]"
|
||||
)
|
||||
console.print()
|
||||
|
||||
|
||||
# =============================================
|
||||
# DRONE ROUTING
|
||||
# =============================================
|
||||
|
||||
|
||||
def handle_command(command: str, args: list) -> bool:
|
||||
"""Handle commands routed by the entry point."""
|
||||
if command == "scheduler-ops":
|
||||
if not args:
|
||||
print_introspection()
|
||||
return True
|
||||
if args[0] in ("--help", "-h", "help"):
|
||||
print_introspection()
|
||||
return True
|
||||
json_handler.log_operation("scheduler_ops_status")
|
||||
console.print()
|
||||
console.print("[bold cyan]Scheduler Ops[/bold cyan] - Cron operations facade")
|
||||
console.print()
|
||||
console.print(" [dim]Notifications:[/dim] archived (Telegram removed)")
|
||||
console.print(f" [dim]Task registry:[/dim] {TASK_REGISTRY_AVAILABLE}")
|
||||
console.print(f" [dim]Action registry:[/dim] {ACTION_REGISTRY_AVAILABLE}")
|
||||
console.print()
|
||||
console.print("[dim]This module is a facade used by scheduler_cron.py.[/dim]")
|
||||
console.print()
|
||||
return True
|
||||
return False
|
||||
@@ -0,0 +1,183 @@
|
||||
# =================== AIPass ====================
|
||||
# Name: timer_install.py
|
||||
# Description: Idempotent systemd user timer installer for daemon scheduler
|
||||
# Version: 1.0.0
|
||||
# Created: 2026-06-25
|
||||
# Modified: 2026-06-25
|
||||
# =============================================
|
||||
|
||||
"""
|
||||
Timer installer — idempotent install/uninstall of daemon-tick systemd user units.
|
||||
|
||||
Handles 'drone @daemon install-timer' and 'drone @daemon uninstall-timer'.
|
||||
Copies daemon-tick.service + daemon-tick.timer to ~/.config/systemd/user/,
|
||||
reloads systemd, and enables/starts the timer.
|
||||
"""
|
||||
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import List
|
||||
|
||||
from aipass.prax import logger
|
||||
from aipass.cli.apps.modules import console, error
|
||||
from aipass.daemon.apps.handlers.json import json_handler
|
||||
|
||||
_DAEMON_ROOT = Path(__file__).resolve().parents[2]
|
||||
_UNIT_DIR = Path.home() / ".config" / "systemd" / "user"
|
||||
_SERVICE_NAME = "daemon-tick.service"
|
||||
_TIMER_NAME = "daemon-tick.timer"
|
||||
|
||||
HANDLED_COMMANDS = {"install-timer", "uninstall-timer"}
|
||||
|
||||
|
||||
def print_introspection():
|
||||
"""Display module introspection info."""
|
||||
console.print()
|
||||
console.print("[bold cyan]timer_install Module[/bold cyan]")
|
||||
console.print()
|
||||
console.print("[dim]Idempotent systemd user timer installer for daemon scheduler[/dim]")
|
||||
console.print()
|
||||
console.print("[yellow]Unit files:[/yellow]")
|
||||
console.print(f" [cyan]*[/cyan] {_DAEMON_ROOT / _SERVICE_NAME}")
|
||||
console.print(f" [cyan]*[/cyan] {_DAEMON_ROOT / _TIMER_NAME}")
|
||||
console.print(f" [cyan]*[/cyan] Installs to: {_UNIT_DIR}/")
|
||||
console.print()
|
||||
|
||||
|
||||
def print_help():
|
||||
"""Display usage information."""
|
||||
console.print("\n[bold cyan]install-timer / uninstall-timer — Daemon Scheduler Timer[/bold cyan]")
|
||||
console.print("\n[yellow]USAGE:[/yellow]")
|
||||
console.print(" drone @daemon install-timer Install + enable daemon-tick timer")
|
||||
console.print(" drone @daemon uninstall-timer Stop + remove daemon-tick timer")
|
||||
console.print(" drone @daemon install-timer --help")
|
||||
console.print("\n[yellow]DESCRIPTION:[/yellow]")
|
||||
console.print(" Copies daemon-tick.service and daemon-tick.timer to")
|
||||
console.print(" ~/.config/systemd/user/")
|
||||
console.print(" Then reloads systemd and enables+starts the timer.")
|
||||
console.print(" Idempotent — safe to run multiple times.")
|
||||
console.print()
|
||||
|
||||
|
||||
def _run_systemctl(*args: str) -> bool:
|
||||
"""Run a systemctl --user command. Returns True on success."""
|
||||
cmd = ["systemctl", "--user", *args]
|
||||
try:
|
||||
result = subprocess.run(cmd, capture_output=True, text=True, timeout=15)
|
||||
if result.returncode != 0:
|
||||
logger.warning("[timer_install] systemctl --user %s failed: %s", " ".join(args), result.stderr.strip())
|
||||
console.print(f" [red]FAIL:[/red] systemctl --user {' '.join(args)}")
|
||||
if result.stderr.strip():
|
||||
console.print(f" [dim]{result.stderr.strip()}[/dim]")
|
||||
return False
|
||||
return True
|
||||
except FileNotFoundError:
|
||||
logger.error("[timer_install] systemctl not found — systemd not available")
|
||||
error("systemctl not found — systemd not available")
|
||||
return False
|
||||
except subprocess.TimeoutExpired:
|
||||
logger.error("[timer_install] systemctl --user %s timed out", " ".join(args))
|
||||
console.print(" [red]systemctl timed out[/red]")
|
||||
return False
|
||||
|
||||
|
||||
def _install() -> int:
|
||||
"""Install and enable the daemon-tick timer."""
|
||||
service_src = _DAEMON_ROOT / _SERVICE_NAME
|
||||
timer_src = _DAEMON_ROOT / _TIMER_NAME
|
||||
|
||||
for src in (service_src, timer_src):
|
||||
if not src.exists():
|
||||
logger.error("[timer_install] Missing unit file: %s", src)
|
||||
return 1
|
||||
|
||||
_UNIT_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
json_handler.log_operation("install_timer", {"target": str(_UNIT_DIR)})
|
||||
|
||||
console.print("[bold cyan]Installing daemon-tick units...[/bold cyan]")
|
||||
console.print()
|
||||
|
||||
for src in (service_src, timer_src):
|
||||
dst = _UNIT_DIR / src.name
|
||||
shutil.copy2(src, dst)
|
||||
console.print(f" [green]Copied:[/green] {src.name} -> {dst}")
|
||||
|
||||
console.print()
|
||||
|
||||
console.print(" Reloading systemd user daemon...")
|
||||
if not _run_systemctl("daemon-reload"):
|
||||
return 1
|
||||
|
||||
console.print(" Enabling daemon-tick.timer...")
|
||||
if not _run_systemctl("enable", _TIMER_NAME):
|
||||
return 1
|
||||
|
||||
console.print(" Starting daemon-tick.timer...")
|
||||
if not _run_systemctl("start", _TIMER_NAME):
|
||||
return 1
|
||||
|
||||
console.print()
|
||||
console.print("[bold green]daemon-tick.timer installed and active.[/bold green]")
|
||||
console.print("[dim]Verify: systemctl --user list-timers | grep daemon[/dim]")
|
||||
console.print()
|
||||
|
||||
Path.home().joinpath(".aipass").mkdir(parents=True, exist_ok=True)
|
||||
|
||||
logger.info("[timer_install] daemon-tick timer installed and started")
|
||||
return 0
|
||||
|
||||
|
||||
def _uninstall() -> int:
|
||||
"""Stop, disable, and remove the daemon-tick timer."""
|
||||
console.print("[bold cyan]Uninstalling daemon-tick units...[/bold cyan]")
|
||||
console.print()
|
||||
|
||||
_run_systemctl("stop", _TIMER_NAME)
|
||||
_run_systemctl("disable", _TIMER_NAME)
|
||||
|
||||
for name in (_SERVICE_NAME, _TIMER_NAME):
|
||||
dst = _UNIT_DIR / name
|
||||
if dst.exists():
|
||||
dst.unlink()
|
||||
try:
|
||||
from aipass.trigger.apps.modules.core import trigger
|
||||
|
||||
trigger.fire("file_deleted", path=str(dst), source="timer_install")
|
||||
except ImportError:
|
||||
logger.info("[timer_install] Trigger module not available, skipping event fire")
|
||||
except Exception as e:
|
||||
logger.warning("[timer_install] Trigger fire failed (non-critical): %s", e)
|
||||
console.print(f" [yellow]Removed:[/yellow] {dst}")
|
||||
else:
|
||||
console.print(f" [dim]Not found:[/dim] {dst}")
|
||||
|
||||
_run_systemctl("daemon-reload")
|
||||
|
||||
console.print()
|
||||
console.print("[bold green]daemon-tick units removed.[/bold green]")
|
||||
console.print()
|
||||
|
||||
logger.info("[timer_install] daemon-tick timer uninstalled")
|
||||
return 0
|
||||
|
||||
|
||||
def handle_command(command: str, args: List[str]) -> bool:
|
||||
"""Handle install-timer / uninstall-timer commands."""
|
||||
if command not in HANDLED_COMMANDS:
|
||||
return False
|
||||
|
||||
if args and args[0] in ("--help", "-h"):
|
||||
print_help()
|
||||
return True
|
||||
|
||||
if command == "install-timer":
|
||||
exit_code = _install()
|
||||
else:
|
||||
exit_code = _uninstall()
|
||||
|
||||
if exit_code != 0:
|
||||
sys.exit(exit_code)
|
||||
return True
|
||||
@@ -1,402 +0,0 @@
|
||||
# =================== AIPass ====================
|
||||
# Name: scheduler_cron.py
|
||||
# Description: DAEMON Scheduler Cron Trigger
|
||||
# Version: 2.0.0
|
||||
# Created: 2026-02-15
|
||||
# Modified: 2026-03-24
|
||||
# =============================================
|
||||
|
||||
"""
|
||||
Cron trigger script for the DAEMON scheduled task system.
|
||||
|
||||
Called periodically by cron. Standalone script -- not imported as a module.
|
||||
|
||||
Flow:
|
||||
1. Acquire single-instance lock
|
||||
2. Recover stale dispatches
|
||||
3. Process all due tasks (send emails, mark complete)
|
||||
4. Process actions from registry
|
||||
5. Log summary
|
||||
"""
|
||||
|
||||
# =============================================
|
||||
# IMPORTS
|
||||
# =============================================
|
||||
|
||||
import sys
|
||||
import time
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from datetime import datetime
|
||||
|
||||
from aipass.prax.apps.modules.logger import system_logger as logger
|
||||
from aipass.cli.apps.modules import console
|
||||
from aipass.daemon.apps.handlers.json import json_handler
|
||||
|
||||
# action_processor retired — new tick uses .daemon/ discovery (DPLAN-0204)
|
||||
from aipass.daemon.apps.modules.run import run_tick
|
||||
|
||||
try:
|
||||
import fcntl
|
||||
except ImportError:
|
||||
fcntl = None # type: ignore[assignment]
|
||||
logger.info("[DAEMON] scheduler_cron: fcntl unavailable (Windows)")
|
||||
|
||||
# =============================================
|
||||
# OPTIONAL IMPORTS (via module layer)
|
||||
# =============================================
|
||||
|
||||
# Task registry (via module layer)
|
||||
try:
|
||||
from aipass.daemon.apps.modules.scheduler_ops import (
|
||||
get_due_tasks,
|
||||
mark_dispatching,
|
||||
mark_completed,
|
||||
mark_pending,
|
||||
recover_stale_dispatches,
|
||||
TASK_REGISTRY_AVAILABLE,
|
||||
)
|
||||
except ImportError as e:
|
||||
logger.info(f"Optional dependency not available: scheduler_ops task registry ({e})")
|
||||
TASK_REGISTRY_AVAILABLE = False
|
||||
get_due_tasks = None
|
||||
mark_dispatching = None
|
||||
mark_completed = None
|
||||
mark_pending = None
|
||||
recover_stale_dispatches = None
|
||||
|
||||
|
||||
# Email integration via drone subprocess
|
||||
def _send_email_via_drone(
|
||||
to_branch, subject, message, from_branch="@daemon", auto_execute=True, reply_to=None, **kwargs
|
||||
):
|
||||
"""Send email via drone @ai_mail send subprocess."""
|
||||
cmd = ["drone", "@ai_mail", "send", to_branch, subject, message]
|
||||
if auto_execute:
|
||||
cmd.append("--dispatch")
|
||||
try:
|
||||
result = subprocess.run(cmd, capture_output=True, text=True, timeout=15)
|
||||
return result.returncode == 0
|
||||
except (subprocess.SubprocessError, OSError) as e:
|
||||
logger.warning(f"Drone email subprocess failed: {e}")
|
||||
return False
|
||||
|
||||
|
||||
AI_MAIL_AVAILABLE = True
|
||||
send_email_direct = _send_email_via_drone
|
||||
|
||||
|
||||
# =============================================
|
||||
# CONSTANTS
|
||||
# =============================================
|
||||
|
||||
_DAEMON_ROOT = Path(__file__).resolve().parents[2] # src/aipass/daemon/
|
||||
JSON_DIR = _DAEMON_ROOT / "daemon_json"
|
||||
|
||||
EVENT_NAME = "cron-run"
|
||||
LOCK_FILE = JSON_DIR / "schedule.lock"
|
||||
STALE_DISPATCH_MAX_AGE = 5 # minutes
|
||||
|
||||
|
||||
# =============================================
|
||||
# LOGGING
|
||||
# =============================================
|
||||
|
||||
|
||||
def print_introspection():
|
||||
"""Display module introspection info."""
|
||||
console.print()
|
||||
console.print("[bold cyan]scheduler_cron Module[/bold cyan]")
|
||||
console.print()
|
||||
console.print("[dim]Cron trigger for scheduled tasks and action registry processing[/dim]")
|
||||
console.print()
|
||||
console.print("[yellow]Connected Handlers:[/yellow]")
|
||||
console.print(" modules/")
|
||||
console.print(
|
||||
" [cyan]*[/cyan] scheduler_ops.py [dim](task registry ops + action registry ops, notifications archived)[/dim]"
|
||||
)
|
||||
console.print()
|
||||
console.print(" plugins/")
|
||||
console.print(" [cyan]*[/cyan] discover_plugins [dim](plugin discovery and scheduled execution)[/dim]")
|
||||
console.print()
|
||||
|
||||
|
||||
def print_help() -> None:
|
||||
"""Display usage information for scheduler_cron."""
|
||||
console.print("\n[bold cyan]scheduler_cron.py - DAEMON Scheduler Cron Trigger[/bold cyan]")
|
||||
console.print("\n[yellow]USAGE:[/yellow]")
|
||||
console.print(" drone @daemon scheduler_cron Run the cron scheduler")
|
||||
console.print(" drone @daemon scheduler_cron --help Show this help message")
|
||||
console.print("\n[yellow]DESCRIPTION:[/yellow]")
|
||||
console.print(" Processes due scheduled tasks and actions from the registry.")
|
||||
console.print(" Intended to be called periodically by cron.")
|
||||
console.print()
|
||||
|
||||
|
||||
def log(message: str) -> None:
|
||||
"""Print timestamped log line to stdout (captured by cron redirect)."""
|
||||
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
console.print(f"[{timestamp}] {message}")
|
||||
|
||||
|
||||
# =============================================
|
||||
# TASK PROCESSING
|
||||
# =============================================
|
||||
|
||||
|
||||
def process_due_tasks() -> dict:
|
||||
"""
|
||||
Process all due scheduled tasks.
|
||||
|
||||
Recovers stale dispatches, then iterates due tasks:
|
||||
mark dispatching -> send email -> mark completed or reset to pending.
|
||||
|
||||
Returns:
|
||||
Dict with keys: due, success, failed, errors (list of error strings)
|
||||
"""
|
||||
results = {
|
||||
"due": 0,
|
||||
"success": 0,
|
||||
"failed": 0,
|
||||
"recovered": 0,
|
||||
"errors": [],
|
||||
}
|
||||
|
||||
if not TASK_REGISTRY_AVAILABLE:
|
||||
log("WARNING: Task registry not available, skipping task processing")
|
||||
return results
|
||||
|
||||
# Recover any stale dispatches (stuck > 5 minutes)
|
||||
try:
|
||||
recovered = recover_stale_dispatches(max_age_minutes=STALE_DISPATCH_MAX_AGE) # type: ignore[misc]
|
||||
results["recovered"] = recovered
|
||||
if recovered:
|
||||
log(f"Recovered {recovered} stale dispatch(es)")
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to recover stale dispatches: {e}")
|
||||
log(f"WARNING: Failed to recover stale dispatches: {e}")
|
||||
results["errors"].append(f"Stale recovery: {e}")
|
||||
|
||||
# Get due tasks
|
||||
try:
|
||||
due_tasks = get_due_tasks() # type: ignore[misc]
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to load due tasks: {e}")
|
||||
log(f"ERROR: Failed to load due tasks: {e}")
|
||||
results["errors"].append(f"Load tasks: {e}")
|
||||
return results
|
||||
|
||||
results["due"] = len(due_tasks)
|
||||
|
||||
if not due_tasks:
|
||||
log("No tasks due at this time.")
|
||||
return results
|
||||
|
||||
log(f"Found {len(due_tasks)} due task(s)")
|
||||
|
||||
# Process each due task
|
||||
for task in due_tasks:
|
||||
_process_single_task(task, results)
|
||||
# Small delay between dispatches (prevents thundering herd)
|
||||
time.sleep(1.0)
|
||||
|
||||
return results
|
||||
|
||||
|
||||
def _process_single_task(task: dict, results: dict) -> None:
|
||||
"""Process a single due task: mark dispatching, send email, update results."""
|
||||
task_id = task.get("id", "")
|
||||
recipient = task.get("recipient", "")
|
||||
task_desc = task.get("task", "")
|
||||
message = task.get("message", "")
|
||||
|
||||
log(f"Processing: {task_id[:8]} -> {recipient}: {task_desc[:50]}")
|
||||
|
||||
# Mark as dispatching (prevents re-dispatch)
|
||||
try:
|
||||
mark_dispatching(task_id) # type: ignore[misc]
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to mark dispatching {task_id[:8]}: {e}")
|
||||
log(f"WARNING: Failed to mark dispatching {task_id[:8]}: {e}")
|
||||
results["errors"].append(f"Mark dispatching {task_id[:8]}: {e}")
|
||||
results["failed"] += 1
|
||||
return
|
||||
|
||||
# Build email body
|
||||
email_body = f"{task_desc}"
|
||||
if message:
|
||||
email_body += f"\n\nDetails:\n{message}"
|
||||
|
||||
# Send the email
|
||||
if not AI_MAIL_AVAILABLE:
|
||||
log(f"SKIP: ai_mail not available, cannot send to {recipient}")
|
||||
mark_pending(task_id) # type: ignore[misc]
|
||||
results["failed"] += 1
|
||||
results["errors"].append(f"ai_mail unavailable for {task_id[:8]}")
|
||||
return
|
||||
|
||||
try:
|
||||
email_sent = send_email_direct(
|
||||
to_branch=recipient,
|
||||
subject=f"[SCHEDULED] {task_desc}",
|
||||
message=email_body,
|
||||
from_branch="@daemon",
|
||||
auto_execute=True,
|
||||
reply_to="@devpulse",
|
||||
)
|
||||
|
||||
if email_sent:
|
||||
mark_completed(task_id) # type: ignore[misc]
|
||||
log(f"OK: Sent to {recipient}: {task_desc[:40]}")
|
||||
results["success"] += 1
|
||||
else:
|
||||
mark_pending(task_id) # type: ignore[misc]
|
||||
log(f"FAIL: Email returned False for {recipient}: {task_desc[:40]}")
|
||||
results["failed"] += 1
|
||||
results["errors"].append(f"Email failed: {task_id[:8]} -> {recipient}")
|
||||
|
||||
except Exception as e:
|
||||
# Reset to pending for retry on next run
|
||||
try:
|
||||
mark_pending(task_id) # type: ignore[misc]
|
||||
except Exception as reset_err:
|
||||
logger.warning(f"Best-effort reset to pending failed for {task_id[:8]}: {reset_err}")
|
||||
logger.error(f"Exception sending to {recipient}: {e}")
|
||||
log(f"ERROR: Exception sending to {recipient}: {e}")
|
||||
results["failed"] += 1
|
||||
results["errors"].append(f"Email error {task_id[:8]}: {e}")
|
||||
|
||||
|
||||
def _next_cron_run() -> str:
|
||||
"""Calculate approximate next scheduler cron run time."""
|
||||
now = datetime.now()
|
||||
if now.minute < 30:
|
||||
next_min = 30
|
||||
next_hour = now.hour
|
||||
else:
|
||||
next_min = 0
|
||||
next_hour = (now.hour + 1) % 24
|
||||
return f"{next_hour:02d}:{next_min:02d}"
|
||||
|
||||
|
||||
# =============================================
|
||||
# MAIN
|
||||
# =============================================
|
||||
|
||||
|
||||
def main() -> int:
|
||||
"""
|
||||
Main cron entry point.
|
||||
|
||||
Returns:
|
||||
0 on success, 1 on error
|
||||
"""
|
||||
args = sys.argv[1:]
|
||||
|
||||
if not args:
|
||||
print_introspection()
|
||||
return 0
|
||||
|
||||
if args[0] in ["--version", "-V"]:
|
||||
console.print("scheduler_cron v2.0.0")
|
||||
return 0
|
||||
|
||||
if args[0] in ["--help", "-h"]:
|
||||
print_help()
|
||||
sys.exit(0)
|
||||
|
||||
json_handler.log_operation("cron_run")
|
||||
log("=" * 60)
|
||||
log("Scheduler cron triggered")
|
||||
|
||||
# Ensure lock directory exists
|
||||
LOCK_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Acquire single-instance lock (non-blocking, stdlib fcntl)
|
||||
if fcntl is None:
|
||||
log("fcntl not available (non-Unix platform), skipping lock.")
|
||||
return _run_locked()
|
||||
|
||||
lock_fd = open(LOCK_FILE, "w", encoding="utf-8") # noqa: SIM115
|
||||
try:
|
||||
fcntl.flock(lock_fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
except OSError as e:
|
||||
logger.warning(f"Scheduler lock acquisition failed (another instance running): {e}")
|
||||
log("Another instance already running, skipping.")
|
||||
lock_fd.close()
|
||||
return 0
|
||||
|
||||
try:
|
||||
return _run_locked()
|
||||
finally:
|
||||
fcntl.flock(lock_fd, fcntl.LOCK_UN)
|
||||
lock_fd.close()
|
||||
|
||||
|
||||
def _run_locked() -> int:
|
||||
"""Execute the cron job while holding the lock."""
|
||||
exit_code = 0
|
||||
|
||||
# Step 1: Process due tasks
|
||||
try:
|
||||
results = process_due_tasks()
|
||||
except Exception as e:
|
||||
logger.error(f"Unhandled error in process_due_tasks: {e}", exc_info=True)
|
||||
log(f"CRITICAL: Unhandled error in process_due_tasks: {e}")
|
||||
return 1
|
||||
|
||||
# Step 2: Run decentralized .daemon/ scheduler tick
|
||||
tick_results = {"fired": 0, "failed": 0}
|
||||
try:
|
||||
tick_results = run_tick()
|
||||
except Exception as e:
|
||||
logger.warning(f"Unhandled error in run_tick: {e}")
|
||||
log(f"WARNING: Unhandled error in scheduler tick: {e}")
|
||||
|
||||
# Step 3: Build summary
|
||||
lines = []
|
||||
|
||||
# Tasks section
|
||||
if results["recovered"]:
|
||||
lines.append(f"Recovered {results['recovered']} stale dispatch(es)")
|
||||
if results["due"] or results["success"]:
|
||||
task_line = f"Tasks: {results['due']} due | {results['success']} sent"
|
||||
if results["failed"]:
|
||||
task_line += f" | {results['failed']} failed"
|
||||
lines.append(task_line)
|
||||
else:
|
||||
lines.append("Tasks: none due")
|
||||
|
||||
# Scheduler tick section
|
||||
if tick_results.get("fired") or tick_results.get("failed"):
|
||||
lines.append(f"Scheduler: {tick_results.get('fired', 0)} fired, {tick_results.get('failed', 0)} failed")
|
||||
else:
|
||||
lines.append(f"Scheduler: {tick_results.get('discovered', 0)} discovered, none due")
|
||||
|
||||
# Next run
|
||||
lines.append(f"Next: ~{_next_cron_run()}")
|
||||
|
||||
summary = "\n".join(lines)
|
||||
|
||||
log(f"Results: {summary}")
|
||||
|
||||
# Step 4: Determine exit code
|
||||
if results["failed"] > 0 or results["errors"] or tick_results.get("failed", 0) > 0:
|
||||
exit_code = 1
|
||||
|
||||
log("Scheduler cron finished")
|
||||
if exit_code == 0:
|
||||
logger.info("[DAEMON] scheduler_cron: Cron cycle completed successfully")
|
||||
log("=" * 60)
|
||||
return exit_code
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
sys.exit(main())
|
||||
except Exception as e:
|
||||
# Last-resort catch -- never crash silently
|
||||
logger.error(f"FATAL scheduler_cron exception: {e}", exc_info=True)
|
||||
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
console.print(f"[{timestamp}] FATAL: Unhandled exception: {e}")
|
||||
sys.exit(1)
|
||||
@@ -0,0 +1,22 @@
|
||||
# Systemd user service for the daemon scheduler tick (oneshot).
|
||||
#
|
||||
# Install:
|
||||
# cp daemon-tick.service daemon-tick.timer ~/.config/systemd/user/
|
||||
# systemctl --user daemon-reload
|
||||
# systemctl --user enable --now daemon-tick.timer
|
||||
#
|
||||
# Usage:
|
||||
# systemctl --user start daemon-tick # manual single tick
|
||||
# systemctl --user list-timers # check timer schedule
|
||||
# systemctl --user status daemon-tick # last tick result
|
||||
# journalctl --user -u daemon-tick # journal entries
|
||||
|
||||
[Unit]
|
||||
Description=AIPass Daemon Scheduler Tick — discover and fire due .daemon/ jobs
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
ExecStart=%h/Projects/AIPass/.venv/bin/python3 -m aipass.daemon.apps.daemon run
|
||||
WorkingDirectory=%h/Projects/AIPass
|
||||
StandardOutput=append:%h/.aipass/daemon-tick.log
|
||||
StandardError=append:%h/.aipass/daemon-tick.log
|
||||
@@ -0,0 +1,20 @@
|
||||
# Systemd user timer for the daemon scheduler tick (~2 min cadence).
|
||||
#
|
||||
# Install:
|
||||
# cp daemon-tick.service daemon-tick.timer ~/.config/systemd/user/
|
||||
# systemctl --user daemon-reload
|
||||
# systemctl --user enable --now daemon-tick.timer
|
||||
#
|
||||
# Disable:
|
||||
# systemctl --user disable --now daemon-tick.timer
|
||||
|
||||
[Unit]
|
||||
Description=AIPass Daemon Scheduler Timer — fires daemon-tick.service every ~2 min
|
||||
|
||||
[Timer]
|
||||
OnActiveSec=30s
|
||||
OnUnitActiveSec=2min
|
||||
Persistent=true
|
||||
|
||||
[Install]
|
||||
WantedBy=timers.target
|
||||
@@ -1,456 +0,0 @@
|
||||
# =================== AIPass ====================
|
||||
# Name: test_actions_module.py
|
||||
# Description: Tests for the actions CLI module
|
||||
# Version: 1.0.0
|
||||
# Created: 2026-04-02
|
||||
# Modified: 2026-04-02
|
||||
# =============================================
|
||||
|
||||
"""Tests for the actions CLI module (apps/modules/actions.py)."""
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
from unittest.mock import patch
|
||||
|
||||
MODULE = "aipass.daemon.apps.modules.actions"
|
||||
|
||||
|
||||
# =============================================
|
||||
# FIXTURES
|
||||
# =============================================
|
||||
|
||||
|
||||
def _make_action(
|
||||
action_id: str = "0001",
|
||||
name: str = "test_action",
|
||||
enabled: bool = True,
|
||||
schedule_type: str = "daily",
|
||||
time: str = "08:00",
|
||||
action_type: str = "schedule",
|
||||
target_branch: str = "@seedgo",
|
||||
interval_minutes: int | None = None,
|
||||
due_date: str | None = None,
|
||||
prompt: str = "Run tests",
|
||||
) -> dict:
|
||||
"""Build a sample action dict for tests."""
|
||||
action: dict = {
|
||||
"id": action_id,
|
||||
"name": name,
|
||||
"enabled": enabled,
|
||||
"schedule_type": schedule_type,
|
||||
"time": time,
|
||||
"type": action_type,
|
||||
"target_branch": target_branch,
|
||||
"prompt": prompt,
|
||||
"created": "2026-03-01T00:00:00",
|
||||
"last_run": None,
|
||||
}
|
||||
if interval_minutes is not None:
|
||||
action["interval_minutes"] = interval_minutes
|
||||
if due_date is not None:
|
||||
action["due_date"] = due_date
|
||||
return action
|
||||
|
||||
|
||||
# =============================================
|
||||
# handle_command — routing
|
||||
# =============================================
|
||||
|
||||
|
||||
@patch(f"{MODULE}.json_handler")
|
||||
@patch(f"{MODULE}.console")
|
||||
@patch(f"{MODULE}.cli_error")
|
||||
class TestHandleCommand:
|
||||
"""Tests for handle_command routing."""
|
||||
|
||||
def test_wrong_command_returns_false(self, _err, _con, _jh):
|
||||
from aipass.daemon.apps.modules.actions import handle_command
|
||||
|
||||
assert handle_command("not_actions", []) is False
|
||||
|
||||
def test_no_args_shows_introspection(self, _err, mock_console, _jh):
|
||||
from aipass.daemon.apps.modules.actions import handle_command
|
||||
|
||||
result = handle_command("actions", [])
|
||||
assert result is True
|
||||
# introspection prints "actions Module"
|
||||
calls = [str(c) for c in mock_console.print.call_args_list]
|
||||
assert any("actions Module" in c for c in calls)
|
||||
|
||||
def test_help_flag(self, _err, mock_console, _jh):
|
||||
from aipass.daemon.apps.modules.actions import handle_command
|
||||
|
||||
assert handle_command("actions", ["--help"]) is True
|
||||
calls = [str(c) for c in mock_console.print.call_args_list]
|
||||
assert any("USAGE" in c for c in calls)
|
||||
|
||||
def test_help_word(self, _err, mock_console, _jh):
|
||||
from aipass.daemon.apps.modules.actions import handle_command
|
||||
|
||||
assert handle_command("actions", ["help"]) is True
|
||||
|
||||
@patch(f"{MODULE}.list_actions", return_value=[])
|
||||
@patch(f"{MODULE}.next_due_str", return_value="--")
|
||||
def test_list_subcommand(self, _nds, _la, _err, mock_console, mock_jh):
|
||||
from aipass.daemon.apps.modules.actions import handle_command
|
||||
|
||||
assert handle_command("actions", ["list"]) is True
|
||||
mock_jh.log_operation.assert_called_once()
|
||||
|
||||
@patch(f"{MODULE}.migrate_plugins", return_value=2)
|
||||
@patch(f"{MODULE}.list_actions", return_value=[])
|
||||
@patch(f"{MODULE}.next_due_str", return_value="--")
|
||||
def test_migrate_subcommand(self, _nds, _la, mock_migrate, _err, _con, mock_jh):
|
||||
from aipass.daemon.apps.modules.actions import handle_command
|
||||
|
||||
assert handle_command("actions", ["migrate"]) is True
|
||||
mock_migrate.assert_called_once()
|
||||
|
||||
@patch(f"{MODULE}.get_action")
|
||||
@patch(f"{MODULE}.delete_action")
|
||||
def test_delete_with_valid_id(self, mock_del, mock_get, _err, _con, _jh):
|
||||
from aipass.daemon.apps.modules.actions import handle_command
|
||||
|
||||
mock_get.return_value = _make_action()
|
||||
assert handle_command("actions", ["delete", "0001"]) is True
|
||||
mock_del.assert_called_once_with("0001")
|
||||
|
||||
def test_delete_missing_id(self, mock_err, _con, _jh):
|
||||
from aipass.daemon.apps.modules.actions import handle_command
|
||||
|
||||
assert handle_command("actions", ["delete"]) is True
|
||||
mock_err.assert_called()
|
||||
|
||||
@patch(f"{MODULE}.create_action")
|
||||
@patch(f"{MODULE}._parse_date", return_value="2026-04-09")
|
||||
def test_set_reminder_valid(self, _pd, mock_create, _err, _con, _jh):
|
||||
from aipass.daemon.apps.modules.actions import handle_command
|
||||
|
||||
mock_create.return_value = _make_action(action_id="0099")
|
||||
assert handle_command("actions", ["set", "reminder", "7d", "Check PR"]) is True
|
||||
mock_create.assert_called_once()
|
||||
|
||||
@patch(f"{MODULE}.create_action")
|
||||
@patch(f"{MODULE}._parse_date", return_value="2026-04-09")
|
||||
def test_set_schedule_valid(self, _pd, mock_create, _err, _con, _jh):
|
||||
from aipass.daemon.apps.modules.actions import handle_command
|
||||
|
||||
mock_create.return_value = _make_action(action_id="0088")
|
||||
assert handle_command("actions", ["set", "schedule", "@seedgo", "Run audit", "daily", "04:00"]) is True
|
||||
mock_create.assert_called_once()
|
||||
|
||||
@patch(f"{MODULE}.get_action")
|
||||
@patch(f"{MODULE}.next_due_str", return_value="--")
|
||||
def test_action_id_routes(self, _nds, mock_get, _err, _con, _jh):
|
||||
from aipass.daemon.apps.modules.actions import handle_command
|
||||
|
||||
mock_get.return_value = _make_action(action_id="0003")
|
||||
assert handle_command("actions", ["0003", "info"]) is True
|
||||
mock_get.assert_called_with("0003")
|
||||
|
||||
def test_unknown_subcommand(self, mock_err, mock_console, _jh):
|
||||
from aipass.daemon.apps.modules.actions import handle_command
|
||||
|
||||
assert handle_command("actions", ["foobar"]) is True
|
||||
mock_err.assert_called()
|
||||
|
||||
|
||||
# =============================================
|
||||
# _handle_toggle
|
||||
# =============================================
|
||||
|
||||
|
||||
@patch(f"{MODULE}.console")
|
||||
@patch(f"{MODULE}.cli_error")
|
||||
class TestHandleToggle:
|
||||
@patch(f"{MODULE}.toggle_action")
|
||||
@patch(f"{MODULE}.get_action")
|
||||
def test_enable_success(self, mock_get, mock_toggle, _err, _con):
|
||||
from aipass.daemon.apps.modules.actions import _handle_toggle
|
||||
|
||||
mock_get.return_value = _make_action()
|
||||
assert _handle_toggle("0001", True) is True
|
||||
mock_toggle.assert_called_once_with("0001", True)
|
||||
|
||||
@patch(f"{MODULE}.toggle_action")
|
||||
@patch(f"{MODULE}.get_action")
|
||||
def test_disable_success(self, mock_get, mock_toggle, _err, _con):
|
||||
from aipass.daemon.apps.modules.actions import _handle_toggle
|
||||
|
||||
mock_get.return_value = _make_action()
|
||||
assert _handle_toggle("0001", False) is True
|
||||
mock_toggle.assert_called_once_with("0001", False)
|
||||
|
||||
@patch(f"{MODULE}.get_action", return_value=None)
|
||||
def test_not_found(self, _get, mock_err, _con):
|
||||
from aipass.daemon.apps.modules.actions import _handle_toggle
|
||||
|
||||
assert _handle_toggle("9999", True) is True
|
||||
mock_err.assert_called()
|
||||
|
||||
|
||||
# =============================================
|
||||
# _handle_info
|
||||
# =============================================
|
||||
|
||||
|
||||
@patch(f"{MODULE}.console")
|
||||
@patch(f"{MODULE}.cli_error")
|
||||
class TestHandleInfo:
|
||||
@patch(f"{MODULE}.next_due_str", return_value="--")
|
||||
@patch(f"{MODULE}.get_action")
|
||||
def test_info_success(self, mock_get, _nds, _err, mock_console):
|
||||
from aipass.daemon.apps.modules.actions import _handle_info
|
||||
|
||||
mock_get.return_value = _make_action()
|
||||
assert _handle_info("0001") is True
|
||||
# Should print detail header containing the action name
|
||||
calls = [str(c) for c in mock_console.print.call_args_list]
|
||||
assert any("test_action" in c for c in calls)
|
||||
|
||||
@patch(f"{MODULE}.get_action", return_value=None)
|
||||
def test_info_not_found(self, _get, mock_err, _con):
|
||||
from aipass.daemon.apps.modules.actions import _handle_info
|
||||
|
||||
assert _handle_info("9999") is True
|
||||
mock_err.assert_called()
|
||||
|
||||
|
||||
# =============================================
|
||||
# _handle_set_reminder
|
||||
# =============================================
|
||||
|
||||
|
||||
@patch(f"{MODULE}.console")
|
||||
@patch(f"{MODULE}.cli_error")
|
||||
class TestHandleSetReminder:
|
||||
def test_missing_args(self, mock_err, _con):
|
||||
from aipass.daemon.apps.modules.actions import _handle_set_reminder
|
||||
|
||||
assert _handle_set_reminder(["7d"]) is True
|
||||
mock_err.assert_called()
|
||||
|
||||
@patch(f"{MODULE}.create_action")
|
||||
@patch(f"{MODULE}._parse_date", return_value="2026-04-09")
|
||||
def test_with_to_flag(self, _pd, mock_create, _err, _con):
|
||||
from aipass.daemon.apps.modules.actions import _handle_set_reminder
|
||||
|
||||
mock_create.return_value = _make_action(action_id="0050")
|
||||
assert _handle_set_reminder(["7d", "Follow up", "--to", "@flow"]) is True
|
||||
call_kwargs = mock_create.call_args[1]
|
||||
assert call_kwargs["target_branch"] == "@flow"
|
||||
|
||||
@patch(f"{MODULE}._parse_date", return_value="")
|
||||
def test_invalid_date(self, _pd, mock_err, _con):
|
||||
from aipass.daemon.apps.modules.actions import _handle_set_reminder
|
||||
|
||||
assert _handle_set_reminder(["xyz", "Some msg"]) is True
|
||||
mock_err.assert_called()
|
||||
|
||||
|
||||
# =============================================
|
||||
# _handle_set_schedule
|
||||
# =============================================
|
||||
|
||||
|
||||
@patch(f"{MODULE}.console")
|
||||
@patch(f"{MODULE}.cli_error")
|
||||
@patch(f"{MODULE}.logger")
|
||||
class TestHandleSetSchedule:
|
||||
def test_invalid_type(self, _log, mock_err, _con):
|
||||
from aipass.daemon.apps.modules.actions import _handle_set_schedule
|
||||
|
||||
assert _handle_set_schedule(["@branch", "prompt", "weekly"]) is True
|
||||
mock_err.assert_called()
|
||||
|
||||
def test_missing_time_arg(self, _log, mock_err, _con):
|
||||
from aipass.daemon.apps.modules.actions import _handle_set_schedule
|
||||
|
||||
assert _handle_set_schedule(["@branch", "prompt", "daily"]) is True
|
||||
mock_err.assert_called()
|
||||
|
||||
def test_interval_non_numeric(self, _log, mock_err, _con):
|
||||
from aipass.daemon.apps.modules.actions import _handle_set_schedule
|
||||
|
||||
assert _handle_set_schedule(["@b", "prompt", "interval", "abc"]) is True
|
||||
mock_err.assert_called()
|
||||
|
||||
@patch(f"{MODULE}.create_action")
|
||||
def test_daily_success(self, mock_create, _log, _err, _con):
|
||||
from aipass.daemon.apps.modules.actions import _handle_set_schedule
|
||||
|
||||
mock_create.return_value = _make_action(action_id="0070")
|
||||
assert _handle_set_schedule(["@seedgo", "Run audit", "daily", "04:00"]) is True
|
||||
kw = mock_create.call_args[1]
|
||||
assert kw["schedule_type"] == "daily"
|
||||
assert kw["time"] == "04:00"
|
||||
|
||||
@patch(f"{MODULE}.create_action")
|
||||
def test_hourly_success(self, mock_create, _log, _err, _con):
|
||||
from aipass.daemon.apps.modules.actions import _handle_set_schedule
|
||||
|
||||
mock_create.return_value = _make_action(action_id="0071")
|
||||
assert _handle_set_schedule(["@flow", "Check plans", "hourly", "30"]) is True
|
||||
kw = mock_create.call_args[1]
|
||||
assert kw["schedule_type"] == "hourly"
|
||||
|
||||
@patch(f"{MODULE}.create_action")
|
||||
def test_interval_success(self, mock_create, _log, _err, _con):
|
||||
from aipass.daemon.apps.modules.actions import _handle_set_schedule
|
||||
|
||||
mock_create.return_value = _make_action(action_id="0072")
|
||||
assert _handle_set_schedule(["@daemon", "Heartbeat", "interval", "240"]) is True
|
||||
kw = mock_create.call_args[1]
|
||||
assert kw["interval_minutes"] == 240
|
||||
|
||||
|
||||
# =============================================
|
||||
# _handle_delete
|
||||
# =============================================
|
||||
|
||||
|
||||
@patch(f"{MODULE}.console")
|
||||
@patch(f"{MODULE}.cli_error")
|
||||
class TestHandleDelete:
|
||||
def test_no_args(self, mock_err, _con):
|
||||
from aipass.daemon.apps.modules.actions import _handle_delete
|
||||
|
||||
assert _handle_delete([]) is True
|
||||
mock_err.assert_called()
|
||||
|
||||
@patch(f"{MODULE}.get_action", return_value=None)
|
||||
def test_not_found(self, _get, mock_err, _con):
|
||||
from aipass.daemon.apps.modules.actions import _handle_delete
|
||||
|
||||
assert _handle_delete(["9999"]) is True
|
||||
mock_err.assert_called()
|
||||
|
||||
@patch(f"{MODULE}.delete_action")
|
||||
@patch(f"{MODULE}.get_action")
|
||||
def test_success(self, mock_get, mock_del, _err, _con):
|
||||
from aipass.daemon.apps.modules.actions import _handle_delete
|
||||
|
||||
mock_get.return_value = _make_action(action_id="0005")
|
||||
assert _handle_delete(["0005"]) is True
|
||||
mock_del.assert_called_once_with("0005")
|
||||
|
||||
|
||||
# =============================================
|
||||
# _parse_date
|
||||
# =============================================
|
||||
|
||||
|
||||
@patch(f"{MODULE}.logger")
|
||||
class TestParseDate:
|
||||
def test_relative_days(self, _log):
|
||||
from aipass.daemon.apps.modules.actions import _parse_date
|
||||
|
||||
result = _parse_date("7d")
|
||||
expected = (datetime.now() + timedelta(days=7)).strftime("%Y-%m-%d")
|
||||
assert result == expected
|
||||
|
||||
def test_relative_weeks(self, _log):
|
||||
from aipass.daemon.apps.modules.actions import _parse_date
|
||||
|
||||
result = _parse_date("2w")
|
||||
expected = (datetime.now() + timedelta(weeks=2)).strftime("%Y-%m-%d")
|
||||
assert result == expected
|
||||
|
||||
def test_iso_format(self, _log):
|
||||
from aipass.daemon.apps.modules.actions import _parse_date
|
||||
|
||||
assert _parse_date("2026-04-15") == "2026-04-15"
|
||||
|
||||
def test_invalid_format(self, _log):
|
||||
from aipass.daemon.apps.modules.actions import _parse_date
|
||||
|
||||
assert _parse_date("not-a-date") == ""
|
||||
|
||||
def test_invalid_relative_day(self, _log):
|
||||
from aipass.daemon.apps.modules.actions import _parse_date
|
||||
|
||||
assert _parse_date("xd") == ""
|
||||
|
||||
def test_invalid_relative_week(self, _log):
|
||||
from aipass.daemon.apps.modules.actions import _parse_date
|
||||
|
||||
assert _parse_date("xw") == ""
|
||||
|
||||
|
||||
# =============================================
|
||||
# _format_schedule
|
||||
# =============================================
|
||||
|
||||
|
||||
class TestFormatSchedule:
|
||||
def test_daily(self):
|
||||
from aipass.daemon.apps.modules.actions import _format_schedule
|
||||
|
||||
assert _format_schedule({"schedule_type": "daily", "time": "08:00"}) == "daily @ 08:00"
|
||||
|
||||
def test_hourly(self):
|
||||
from aipass.daemon.apps.modules.actions import _format_schedule
|
||||
|
||||
assert _format_schedule({"schedule_type": "hourly", "time": "30"}) == "hourly @ :30"
|
||||
|
||||
def test_interval_minutes(self):
|
||||
from aipass.daemon.apps.modules.actions import _format_schedule
|
||||
|
||||
assert _format_schedule({"schedule_type": "interval", "interval_minutes": 45}) == "every 45m"
|
||||
|
||||
def test_interval_hours(self):
|
||||
from aipass.daemon.apps.modules.actions import _format_schedule
|
||||
|
||||
assert _format_schedule({"schedule_type": "interval", "interval_minutes": 120}) == "every 2h"
|
||||
|
||||
def test_once(self):
|
||||
from aipass.daemon.apps.modules.actions import _format_schedule
|
||||
|
||||
assert _format_schedule({"schedule_type": "once", "due_date": "2026-04-10"}) == "once: 2026-04-10"
|
||||
|
||||
def test_unknown_type(self):
|
||||
from aipass.daemon.apps.modules.actions import _format_schedule
|
||||
|
||||
assert _format_schedule({"schedule_type": "custom"}) == "custom"
|
||||
|
||||
|
||||
# =============================================
|
||||
# _route_set_subcommand / _route_action_id
|
||||
# =============================================
|
||||
|
||||
|
||||
@patch(f"{MODULE}.console")
|
||||
@patch(f"{MODULE}.cli_error")
|
||||
class TestRouting:
|
||||
def test_route_set_too_few_args(self, mock_err, _con):
|
||||
from aipass.daemon.apps.modules.actions import _route_set_subcommand
|
||||
|
||||
assert _route_set_subcommand(["set"]) is True
|
||||
mock_err.assert_called()
|
||||
|
||||
def test_route_set_unknown_type(self, mock_err, _con):
|
||||
from aipass.daemon.apps.modules.actions import _route_set_subcommand
|
||||
|
||||
assert _route_set_subcommand(["set", "bogus"]) is True
|
||||
mock_err.assert_called()
|
||||
|
||||
@patch(f"{MODULE}.get_action", return_value=None)
|
||||
def test_route_action_id_no_sub_defaults_to_info(self, mock_get, mock_err, _con):
|
||||
from aipass.daemon.apps.modules.actions import _route_action_id
|
||||
|
||||
assert _route_action_id("0001", ["0001"]) is True
|
||||
mock_get.assert_called_with("0001")
|
||||
|
||||
@patch(f"{MODULE}.get_action")
|
||||
@patch(f"{MODULE}.toggle_action")
|
||||
def test_route_action_id_on(self, mock_toggle, mock_get, _err, _con):
|
||||
from aipass.daemon.apps.modules.actions import _route_action_id
|
||||
|
||||
mock_get.return_value = _make_action()
|
||||
assert _route_action_id("0001", ["0001", "on"]) is True
|
||||
mock_toggle.assert_called_once_with("0001", True)
|
||||
|
||||
def test_route_action_id_unknown_sub(self, mock_err, _con):
|
||||
from aipass.daemon.apps.modules.actions import _route_action_id
|
||||
|
||||
assert _route_action_id("0001", ["0001", "banana"]) is True
|
||||
mock_err.assert_called()
|
||||
@@ -1,360 +0,0 @@
|
||||
# ===================AIPASS====================
|
||||
# META DATA HEADER
|
||||
# Name: test_actions_registry.py - Action Registry Tests
|
||||
# Date: 2026-03-02
|
||||
# Version: 1.1.0
|
||||
# Category: daemon/tests
|
||||
#
|
||||
# CHANGELOG (Max 5 entries):
|
||||
# - v1.1.0 (2026-03-07): Adapted for AIPass public repo
|
||||
# * Removed sys.path manipulation, uses package imports
|
||||
# - v1.0.0 (2026-03-02): Initial creation - DPLAN-043 tests
|
||||
#
|
||||
# CODE STANDARDS:
|
||||
# - Pytest conventions
|
||||
# - Temp dir isolation (no writes to real registry)
|
||||
# =============================================
|
||||
|
||||
"""Tests for the action registry handler."""
|
||||
|
||||
import json
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
import pytest
|
||||
|
||||
from aipass.daemon.apps.handlers.actions import actions_registry as _reg_mod
|
||||
|
||||
create_action = _reg_mod.create_action
|
||||
get_action = _reg_mod.get_action
|
||||
list_actions = _reg_mod.list_actions
|
||||
toggle_action = _reg_mod.toggle_action
|
||||
delete_action = _reg_mod.delete_action
|
||||
update_last_run = _reg_mod.update_last_run
|
||||
mark_reminder_completed = _reg_mod.mark_reminder_completed
|
||||
is_action_due = _reg_mod.is_action_due
|
||||
calc_next_run = _reg_mod.calc_next_run
|
||||
next_due_str = _reg_mod.next_due_str
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def clean_registry(tmp_path):
|
||||
"""Isolate REGISTRY_FILE to a temp dir for every test."""
|
||||
test_registry = tmp_path / "actions_registry.json"
|
||||
original = _reg_mod.REGISTRY_FILE
|
||||
_reg_mod.REGISTRY_FILE = test_registry
|
||||
yield test_registry
|
||||
_reg_mod.REGISTRY_FILE = original
|
||||
|
||||
|
||||
# =============================================
|
||||
# CRUD TESTS
|
||||
# =============================================
|
||||
|
||||
|
||||
class TestCreate:
|
||||
def test_create_action_basic(self, clean_registry):
|
||||
"""Create a simple schedule action and verify fields."""
|
||||
action = create_action(
|
||||
name="test_audit",
|
||||
action_type="schedule",
|
||||
schedule_type="daily",
|
||||
target_branch="@seedgo",
|
||||
prompt="Run audit",
|
||||
time="04:00",
|
||||
fresh=True,
|
||||
max_turns=20,
|
||||
)
|
||||
assert action["id"] == "0001"
|
||||
assert action["name"] == "test_audit"
|
||||
assert action["type"] == "schedule"
|
||||
assert action["schedule_type"] == "daily"
|
||||
assert action["time"] == "04:00"
|
||||
assert action["target_branch"] == "@seedgo"
|
||||
assert action["enabled"] is True
|
||||
assert action["last_run"] is None
|
||||
assert action["completed"] is None
|
||||
|
||||
def test_create_sequential_ids(self, clean_registry):
|
||||
"""IDs should be sequential: 0001, 0002, 0003..."""
|
||||
a1 = create_action(name="first", action_type="schedule", schedule_type="daily")
|
||||
a2 = create_action(name="second", action_type="schedule", schedule_type="daily")
|
||||
a3 = create_action(name="third", action_type="reminder", schedule_type="once")
|
||||
assert a1["id"] == "0001"
|
||||
assert a2["id"] == "0002"
|
||||
assert a3["id"] == "0003"
|
||||
|
||||
def test_create_reminder(self, clean_registry):
|
||||
"""Create a one-shot reminder action."""
|
||||
action = create_action(
|
||||
name="Check VERA progress",
|
||||
action_type="reminder",
|
||||
schedule_type="once",
|
||||
target_branch="@devpulse",
|
||||
prompt="Check VERA progress",
|
||||
due_date="2026-03-11",
|
||||
)
|
||||
assert action["type"] == "reminder"
|
||||
assert action["schedule_type"] == "once"
|
||||
assert action["due_date"] == "2026-03-11"
|
||||
|
||||
def test_create_persists_to_json(self, clean_registry):
|
||||
"""Action should be persisted to the JSON file."""
|
||||
create_action(name="persisted", action_type="schedule", schedule_type="daily")
|
||||
data = json.loads(clean_registry.read_text())
|
||||
assert len(data["actions"]) == 1
|
||||
assert data["actions"][0]["name"] == "persisted"
|
||||
assert data["next_id"] == 2
|
||||
|
||||
|
||||
class TestGet:
|
||||
def test_get_existing(self, clean_registry):
|
||||
"""Get an action by ID."""
|
||||
create_action(name="findme", action_type="schedule", schedule_type="daily")
|
||||
action = get_action("0001")
|
||||
assert action is not None
|
||||
assert action["name"] == "findme"
|
||||
|
||||
def test_get_missing(self, clean_registry):
|
||||
"""Get returns None for nonexistent ID."""
|
||||
assert get_action("9999") is None
|
||||
|
||||
|
||||
class TestList:
|
||||
def test_list_all(self, clean_registry):
|
||||
"""List returns all non-completed actions."""
|
||||
create_action(name="a", action_type="schedule", schedule_type="daily")
|
||||
create_action(name="b", action_type="schedule", schedule_type="hourly")
|
||||
actions = list_actions()
|
||||
assert len(actions) == 2
|
||||
|
||||
def test_list_excludes_completed(self, clean_registry):
|
||||
"""Completed reminders should be excluded by default."""
|
||||
create_action(name="done", action_type="reminder", schedule_type="once", due_date="2026-01-01")
|
||||
mark_reminder_completed("0001")
|
||||
assert len(list_actions()) == 0
|
||||
assert len(list_actions(include_completed=True)) == 1
|
||||
|
||||
|
||||
class TestToggle:
|
||||
def test_toggle_off(self, clean_registry):
|
||||
"""Toggle an action off."""
|
||||
create_action(name="toggleme", action_type="schedule", schedule_type="daily")
|
||||
assert toggle_action("0001", False) is True
|
||||
action = get_action("0001")
|
||||
assert action is not None
|
||||
assert action["enabled"] is False
|
||||
|
||||
def test_toggle_on(self, clean_registry):
|
||||
"""Toggle an action back on."""
|
||||
create_action(name="toggleme", action_type="schedule", schedule_type="daily", enabled=False)
|
||||
assert toggle_action("0001", True) is True
|
||||
action = get_action("0001")
|
||||
assert action is not None
|
||||
assert action["enabled"] is True
|
||||
|
||||
def test_toggle_missing(self, clean_registry):
|
||||
"""Toggle returns False for nonexistent ID."""
|
||||
assert toggle_action("9999", True) is False
|
||||
|
||||
|
||||
class TestDelete:
|
||||
def test_delete_existing(self, clean_registry):
|
||||
"""Delete an action by ID."""
|
||||
create_action(name="deleteme", action_type="schedule", schedule_type="daily")
|
||||
assert delete_action("0001") is True
|
||||
assert get_action("0001") is None
|
||||
|
||||
def test_delete_missing(self, clean_registry):
|
||||
"""Delete returns False for nonexistent ID."""
|
||||
assert delete_action("9999") is False
|
||||
|
||||
|
||||
# =============================================
|
||||
# DUE CHECKING TESTS
|
||||
# =============================================
|
||||
|
||||
|
||||
class TestIsDue:
|
||||
def test_daily_due_at_correct_time(self, clean_registry):
|
||||
"""Daily action is due when current time matches."""
|
||||
now = datetime.now()
|
||||
action = {
|
||||
"enabled": True,
|
||||
"completed": None,
|
||||
"schedule_type": "daily",
|
||||
"time": f"{now.hour:02d}:{now.minute:02d}",
|
||||
"last_run": None,
|
||||
}
|
||||
assert is_action_due(action) is True
|
||||
|
||||
def test_daily_not_due_wrong_time(self, clean_registry):
|
||||
"""Daily action is not due at wrong time (12 hours away from now)."""
|
||||
from datetime import datetime
|
||||
|
||||
now = datetime.now()
|
||||
# Pick a time 12 hours away — always outside the 15-min fuzzy window
|
||||
far_hour = (now.hour + 12) % 24
|
||||
action = {
|
||||
"enabled": True,
|
||||
"completed": None,
|
||||
"schedule_type": "daily",
|
||||
"time": f"{far_hour:02d}:00",
|
||||
"last_run": None,
|
||||
}
|
||||
assert is_action_due(action) is False
|
||||
|
||||
def test_daily_not_due_already_ran_today(self, clean_registry):
|
||||
"""Daily action not due if already ran today."""
|
||||
now = datetime.now()
|
||||
action = {
|
||||
"enabled": True,
|
||||
"completed": None,
|
||||
"schedule_type": "daily",
|
||||
"time": f"{now.hour:02d}:{now.minute:02d}",
|
||||
"last_run": now.isoformat(),
|
||||
}
|
||||
assert is_action_due(action) is False
|
||||
|
||||
def test_interval_due_never_run(self, clean_registry):
|
||||
"""Interval action is due if never run before."""
|
||||
action = {
|
||||
"enabled": True,
|
||||
"completed": None,
|
||||
"schedule_type": "interval",
|
||||
"interval_minutes": 60,
|
||||
"last_run": None,
|
||||
}
|
||||
assert is_action_due(action) is True
|
||||
|
||||
def test_interval_due_enough_time_elapsed(self, clean_registry):
|
||||
"""Interval action is due when enough time has passed."""
|
||||
past = (datetime.now() - timedelta(minutes=120)).isoformat()
|
||||
action = {
|
||||
"enabled": True,
|
||||
"completed": None,
|
||||
"schedule_type": "interval",
|
||||
"interval_minutes": 60,
|
||||
"last_run": past,
|
||||
}
|
||||
assert is_action_due(action) is True
|
||||
|
||||
def test_interval_not_due_too_soon(self, clean_registry):
|
||||
"""Interval action is not due when too little time has passed."""
|
||||
recent = (datetime.now() - timedelta(minutes=5)).isoformat()
|
||||
action = {
|
||||
"enabled": True,
|
||||
"completed": None,
|
||||
"schedule_type": "interval",
|
||||
"interval_minutes": 60,
|
||||
"last_run": recent,
|
||||
}
|
||||
assert is_action_due(action) is False
|
||||
|
||||
def test_once_due_past_date(self, clean_registry):
|
||||
"""Reminder is due when due_date is in the past."""
|
||||
action = {
|
||||
"enabled": True,
|
||||
"completed": None,
|
||||
"schedule_type": "once",
|
||||
"due_date": "2026-01-01",
|
||||
}
|
||||
assert is_action_due(action) is True
|
||||
|
||||
def test_once_not_due_future_date(self, clean_registry):
|
||||
"""Reminder is not due when due_date is in the future."""
|
||||
action = {
|
||||
"enabled": True,
|
||||
"completed": None,
|
||||
"schedule_type": "once",
|
||||
"due_date": "2099-12-31",
|
||||
}
|
||||
assert is_action_due(action) is False
|
||||
|
||||
def test_disabled_never_due(self, clean_registry):
|
||||
"""Disabled action is never due."""
|
||||
action = {
|
||||
"enabled": False,
|
||||
"completed": None,
|
||||
"schedule_type": "interval",
|
||||
"interval_minutes": 1,
|
||||
"last_run": None,
|
||||
}
|
||||
assert is_action_due(action) is False
|
||||
|
||||
def test_completed_never_due(self, clean_registry):
|
||||
"""Completed action is never due."""
|
||||
action = {
|
||||
"enabled": True,
|
||||
"completed": "2026-03-01T12:00:00",
|
||||
"schedule_type": "once",
|
||||
"due_date": "2026-01-01",
|
||||
}
|
||||
assert is_action_due(action) is False
|
||||
|
||||
|
||||
# =============================================
|
||||
# NEXT RUN TESTS
|
||||
# =============================================
|
||||
|
||||
|
||||
class TestCalcNextRun:
|
||||
def test_daily_next_run(self, clean_registry):
|
||||
"""Daily action calculates next run correctly."""
|
||||
action = {"schedule_type": "daily", "time": "04:00", "last_run": None}
|
||||
result = calc_next_run(action)
|
||||
assert result is not None
|
||||
assert "04:00:00" in result
|
||||
|
||||
def test_interval_next_run(self, clean_registry):
|
||||
"""Interval action calculates next run from last_run + interval."""
|
||||
last = datetime.now().isoformat()
|
||||
action = {"schedule_type": "interval", "interval_minutes": 60, "last_run": last}
|
||||
result = calc_next_run(action)
|
||||
assert result is not None
|
||||
|
||||
def test_once_next_run(self, clean_registry):
|
||||
"""Reminder returns due_date as next run."""
|
||||
action = {"schedule_type": "once", "due_date": "2026-03-11", "completed": None}
|
||||
assert calc_next_run(action) == "2026-03-11"
|
||||
|
||||
|
||||
class TestNextDueStr:
|
||||
def test_daily_str(self, clean_registry):
|
||||
action = {"schedule_type": "daily", "time": "04:00"}
|
||||
assert next_due_str(action) == "daily @ 04:00"
|
||||
|
||||
def test_hourly_str(self, clean_registry):
|
||||
action = {"schedule_type": "hourly", "time": "30"}
|
||||
assert next_due_str(action) == "hourly @ :30"
|
||||
|
||||
def test_once_str(self, clean_registry):
|
||||
action = {"schedule_type": "once", "due_date": "2026-03-11"}
|
||||
assert next_due_str(action) == "2026-03-11"
|
||||
|
||||
|
||||
# =============================================
|
||||
# UPDATE TESTS
|
||||
# =============================================
|
||||
|
||||
|
||||
class TestUpdateLastRun:
|
||||
def test_update_last_run(self, clean_registry):
|
||||
"""Update last_run sets timestamp and recalculates next_run."""
|
||||
create_action(name="test", action_type="schedule", schedule_type="interval", interval_minutes=60)
|
||||
ts = "2026-03-02T12:00:00"
|
||||
assert update_last_run("0001", ts) is True
|
||||
action = get_action("0001")
|
||||
assert action is not None
|
||||
assert action["last_run"] == ts
|
||||
assert action["next_run"] is not None
|
||||
|
||||
|
||||
class TestMarkCompleted:
|
||||
def test_mark_reminder_completed(self, clean_registry):
|
||||
"""Marking a reminder completed sets completed timestamp and disables it."""
|
||||
create_action(name="reminder", action_type="reminder", schedule_type="once", due_date="2026-03-01")
|
||||
assert mark_reminder_completed("0001") is True
|
||||
action = get_action("0001")
|
||||
assert action is not None
|
||||
assert action["completed"] is not None
|
||||
assert action["enabled"] is False
|
||||
@@ -265,6 +265,12 @@ def test_help_preempts() -> None:
|
||||
assert result == 0, "--help must return 0 before any module routing"
|
||||
|
||||
|
||||
def test_invalid_mode_raises() -> None:
|
||||
"""invalid_mode_raises: save_json with invalid_type raises ValueError."""
|
||||
with pytest.raises(ValueError):
|
||||
json_handler.save_json("contract_mod", "invalid_type_xyz", {"bad": True})
|
||||
|
||||
|
||||
def test_no_args_triggers() -> None:
|
||||
"""no_args_triggers: no arguments triggers introspection display."""
|
||||
from aipass.daemon.apps import daemon as _daemon_mod
|
||||
|
||||
@@ -1,3 +1,11 @@
|
||||
# =================== AIPass ====================
|
||||
# Name: test_run_module.py
|
||||
# Description: Tests for the drone @daemon run module
|
||||
# Version: 1.1.0
|
||||
# Created: 2026-06-15
|
||||
# Modified: 2026-06-25
|
||||
# =============================================
|
||||
|
||||
"""Tests for the drone @daemon run module (decentralized scheduler tick)."""
|
||||
|
||||
from unittest.mock import patch
|
||||
@@ -63,7 +71,7 @@ class TestRunTick:
|
||||
assert results["due"] == 0
|
||||
|
||||
@patch("aipass.daemon.apps.modules.run.save_runstate")
|
||||
@patch("aipass.daemon.apps.modules.run._fire_job", return_value=True)
|
||||
@patch("aipass.daemon.apps.modules.run._fire_job", return_value=(True, ""))
|
||||
@patch("aipass.daemon.apps.modules.run.discover_jobs")
|
||||
@patch("aipass.daemon.apps.modules.run.load_runstate", return_value={"jobs": {}})
|
||||
def test_fires_due_job(self, mock_rs, mock_discover, mock_fire, mock_save):
|
||||
@@ -84,7 +92,7 @@ class TestRunTick:
|
||||
mock_save.assert_called()
|
||||
|
||||
@patch("aipass.daemon.apps.modules.run.save_runstate")
|
||||
@patch("aipass.daemon.apps.modules.run._fire_job", return_value=False)
|
||||
@patch("aipass.daemon.apps.modules.run._fire_job", return_value=(False, "wake failed"))
|
||||
@patch("aipass.daemon.apps.modules.run.discover_jobs")
|
||||
@patch("aipass.daemon.apps.modules.run.load_runstate", return_value={"jobs": {}})
|
||||
def test_failed_fire_counted(self, mock_rs, mock_discover, mock_fire, mock_save):
|
||||
|
||||
@@ -1,352 +0,0 @@
|
||||
# =================== AIPass ====================
|
||||
# Name: test_schedule_module.py
|
||||
# Description: Tests for the schedule CLI module
|
||||
# Version: 1.0.0
|
||||
# Created: 2026-04-03
|
||||
# Modified: 2026-04-03
|
||||
# =============================================
|
||||
|
||||
"""Tests for the schedule CLI module (apps/modules/schedule.py)."""
|
||||
|
||||
from unittest.mock import patch, MagicMock
|
||||
|
||||
MODULE = "aipass.daemon.apps.modules.schedule"
|
||||
|
||||
|
||||
# =============================================
|
||||
# handle_command -- routing basics
|
||||
# =============================================
|
||||
|
||||
|
||||
@patch(f"{MODULE}.json_handler")
|
||||
@patch(f"{MODULE}.console")
|
||||
@patch(f"{MODULE}.cli_error")
|
||||
@patch(f"{MODULE}.logger")
|
||||
class TestHandleCommandRouting:
|
||||
"""Tests for handle_command routing."""
|
||||
|
||||
def test_wrong_command_returns_false(self, _log, _err, _con, _jh):
|
||||
from aipass.daemon.apps.modules.schedule import handle_command
|
||||
|
||||
assert handle_command("not_schedule", []) is False
|
||||
|
||||
def test_no_args_shows_introspection(self, _log, _err, mock_con, _jh):
|
||||
from aipass.daemon.apps.modules.schedule import handle_command
|
||||
|
||||
result = handle_command("schedule", [])
|
||||
assert result is True
|
||||
calls = [str(c) for c in mock_con.print.call_args_list]
|
||||
assert any("schedule Module" in c for c in calls)
|
||||
|
||||
def test_help_flag(self, _log, _err, mock_con, _jh):
|
||||
from aipass.daemon.apps.modules.schedule import handle_command
|
||||
|
||||
result = handle_command("schedule", ["--help"])
|
||||
assert result is True
|
||||
calls = [str(c) for c in mock_con.print.call_args_list]
|
||||
assert any("USAGE" in c for c in calls)
|
||||
|
||||
def test_unknown_subcommand(self, _log, mock_err, _con, _jh):
|
||||
from aipass.daemon.apps.modules.schedule import handle_command
|
||||
|
||||
result = handle_command("schedule", ["foobar"])
|
||||
assert result is False
|
||||
mock_err.assert_called()
|
||||
|
||||
|
||||
# =============================================
|
||||
# handle_command -- list subcommand
|
||||
# =============================================
|
||||
|
||||
|
||||
@patch(f"{MODULE}.json_handler")
|
||||
@patch(f"{MODULE}.console")
|
||||
@patch(f"{MODULE}.cli_error")
|
||||
@patch(f"{MODULE}.logger")
|
||||
class TestListSubcommand:
|
||||
"""Tests for 'schedule list' subcommand."""
|
||||
|
||||
@patch(f"{MODULE}.load_tasks", return_value=[])
|
||||
def test_list_success(self, mock_load, _log, _err, mock_con, mock_jh):
|
||||
from aipass.daemon.apps.modules.schedule import handle_command
|
||||
|
||||
result = handle_command("schedule", ["list"])
|
||||
assert result is True
|
||||
mock_load.assert_called_once()
|
||||
|
||||
@patch(f"{MODULE}.load_tasks", side_effect=RuntimeError("disk error"))
|
||||
def test_list_exception(self, _load, _log, mock_err, _con, _jh):
|
||||
from aipass.daemon.apps.modules.schedule import handle_command
|
||||
|
||||
result = handle_command("schedule", ["list"])
|
||||
assert result is False
|
||||
mock_err.assert_called()
|
||||
|
||||
|
||||
# =============================================
|
||||
# handle_command -- delete subcommand
|
||||
# =============================================
|
||||
|
||||
|
||||
@patch(f"{MODULE}.json_handler")
|
||||
@patch(f"{MODULE}.console")
|
||||
@patch(f"{MODULE}.cli_error")
|
||||
@patch(f"{MODULE}.logger")
|
||||
class TestDeleteSubcommand:
|
||||
"""Tests for 'schedule delete' subcommand."""
|
||||
|
||||
def test_delete_no_args_shows_error(self, _log, mock_err, _con, _jh):
|
||||
from aipass.daemon.apps.modules.schedule import handle_command
|
||||
|
||||
result = handle_command("schedule", ["delete"])
|
||||
assert result is False
|
||||
mock_err.assert_called()
|
||||
|
||||
@patch(f"{MODULE}.delete_task", return_value=True)
|
||||
def test_delete_success(self, mock_del, _log, _err, _con, _jh):
|
||||
from aipass.daemon.apps.modules.schedule import handle_command
|
||||
|
||||
result = handle_command("schedule", ["delete", "abc123"])
|
||||
assert result is True
|
||||
mock_del.assert_called_once_with("abc123")
|
||||
|
||||
@patch(f"{MODULE}.delete_task", return_value=False)
|
||||
def test_delete_not_found(self, mock_del, _log, mock_err, _con, _jh):
|
||||
from aipass.daemon.apps.modules.schedule import handle_command
|
||||
|
||||
result = handle_command("schedule", ["delete", "abc123"])
|
||||
assert result is False
|
||||
mock_err.assert_called()
|
||||
|
||||
|
||||
# =============================================
|
||||
# handle_command -- run-due subcommand
|
||||
# =============================================
|
||||
|
||||
|
||||
@patch(f"{MODULE}.json_handler")
|
||||
@patch(f"{MODULE}.console")
|
||||
@patch(f"{MODULE}.cli_error")
|
||||
@patch(f"{MODULE}.logger")
|
||||
class TestRunDueSubcommand:
|
||||
"""Tests for 'schedule run-due' subcommand."""
|
||||
|
||||
@patch(
|
||||
f"{MODULE}.process_due_tasks_batch",
|
||||
return_value={
|
||||
"recovered": 0,
|
||||
"due": 0,
|
||||
"success": 0,
|
||||
"failed": 0,
|
||||
"processed_tasks": [],
|
||||
},
|
||||
)
|
||||
@patch(f"{MODULE}.FILELOCK_AVAILABLE", False)
|
||||
def test_run_due_without_lock(self, mock_batch, _log, _err, mock_con, _jh):
|
||||
from aipass.daemon.apps.modules.schedule import handle_command
|
||||
|
||||
result = handle_command("schedule", ["run-due"])
|
||||
assert result is True
|
||||
mock_batch.assert_called_once()
|
||||
|
||||
@patch(
|
||||
f"{MODULE}.process_due_tasks_batch",
|
||||
return_value={
|
||||
"recovered": 1,
|
||||
"due": 2,
|
||||
"success": 1,
|
||||
"failed": 1,
|
||||
"processed_tasks": [
|
||||
{"id": "a1", "recipient": "@flow", "task": "Check plan", "status": "sent"},
|
||||
{"id": "a2", "recipient": "@seedgo", "task": "Audit", "status": "failed"},
|
||||
],
|
||||
},
|
||||
)
|
||||
@patch(f"{MODULE}.FILELOCK_AVAILABLE", False)
|
||||
def test_run_due_processes_tasks(self, mock_batch, _log, _err, mock_con, _jh):
|
||||
from aipass.daemon.apps.modules.schedule import handle_command
|
||||
|
||||
result = handle_command("schedule", ["run-due"])
|
||||
assert result is True
|
||||
mock_batch.assert_called_once()
|
||||
calls = " ".join(str(c) for c in mock_con.print.call_args_list)
|
||||
assert "1 sent" in calls
|
||||
assert "1 failed" in calls
|
||||
|
||||
|
||||
# =============================================
|
||||
# _handle_create
|
||||
# =============================================
|
||||
|
||||
|
||||
@patch(f"{MODULE}.console")
|
||||
@patch(f"{MODULE}.cli_error")
|
||||
@patch(f"{MODULE}.logger")
|
||||
class TestHandleCreate:
|
||||
"""Tests for _handle_create."""
|
||||
|
||||
@patch(f"{MODULE}.create_task", return_value={"id": "task-001"})
|
||||
@patch(f"{MODULE}.parse_due_date", return_value="2026-04-10")
|
||||
def test_create_valid(self, _due, mock_create, _log, _err, _con):
|
||||
from aipass.daemon.apps.modules.schedule import _handle_create
|
||||
|
||||
result = _handle_create(["Follow up", "--due", "7d", "--to", "@flow"])
|
||||
assert result is True
|
||||
mock_create.assert_called_once()
|
||||
|
||||
def test_create_missing_task(self, _log, mock_err, _con):
|
||||
from aipass.daemon.apps.modules.schedule import _handle_create
|
||||
|
||||
result = _handle_create(["--due", "7d", "--to", "@flow"])
|
||||
assert result is False
|
||||
mock_err.assert_called()
|
||||
|
||||
@patch(f"{MODULE}.parse_due_date", return_value=None)
|
||||
def test_create_invalid_due(self, _due, _log, mock_err, _con):
|
||||
from aipass.daemon.apps.modules.schedule import _handle_create
|
||||
|
||||
result = _handle_create(["Task text", "--due", "xyz", "--to", "@flow"])
|
||||
assert result is False
|
||||
mock_err.assert_called()
|
||||
|
||||
|
||||
# =============================================
|
||||
# _process_due_tasks
|
||||
# =============================================
|
||||
|
||||
|
||||
@patch(f"{MODULE}.console")
|
||||
@patch(f"{MODULE}.cli_error")
|
||||
@patch(f"{MODULE}.logger")
|
||||
class TestProcessDueTasks:
|
||||
"""Tests for _process_due_tasks."""
|
||||
|
||||
@patch(
|
||||
f"{MODULE}.process_due_tasks_batch",
|
||||
return_value={
|
||||
"recovered": 0,
|
||||
"due": 0,
|
||||
"success": 0,
|
||||
"failed": 0,
|
||||
"processed_tasks": [],
|
||||
},
|
||||
)
|
||||
def test_no_tasks_due(self, mock_batch, _log, _err, mock_con):
|
||||
from aipass.daemon.apps.modules.schedule import _process_due_tasks
|
||||
|
||||
result = _process_due_tasks()
|
||||
assert result is True
|
||||
calls = " ".join(str(c) for c in mock_con.print.call_args_list)
|
||||
assert "No tasks due" in calls
|
||||
|
||||
@patch(
|
||||
f"{MODULE}.process_due_tasks_batch",
|
||||
return_value={
|
||||
"recovered": 0,
|
||||
"due": 2,
|
||||
"success": 1,
|
||||
"failed": 1,
|
||||
"processed_tasks": [
|
||||
{"id": "t1", "recipient": "@flow", "task": "Check", "status": "sent"},
|
||||
{"id": "t2", "recipient": "@seedgo", "task": "Audit", "status": "failed"},
|
||||
],
|
||||
},
|
||||
)
|
||||
def test_mix_sent_failed(self, mock_batch, _log, _err, mock_con):
|
||||
from aipass.daemon.apps.modules.schedule import _process_due_tasks
|
||||
|
||||
result = _process_due_tasks()
|
||||
assert result is True
|
||||
calls = " ".join(str(c) for c in mock_con.print.call_args_list)
|
||||
assert "1 sent" in calls
|
||||
assert "1 failed" in calls
|
||||
|
||||
|
||||
# =============================================
|
||||
# _display_task_result
|
||||
# =============================================
|
||||
|
||||
|
||||
@patch(f"{MODULE}.console")
|
||||
@patch(f"{MODULE}.cli_error")
|
||||
@patch(f"{MODULE}.logger")
|
||||
class TestDisplayTaskResult:
|
||||
"""Tests for _display_task_result per-status output."""
|
||||
|
||||
def test_status_sent(self, mock_log, _err, mock_con):
|
||||
from aipass.daemon.apps.modules.schedule import _display_task_result
|
||||
|
||||
_display_task_result(
|
||||
{
|
||||
"id": "t1",
|
||||
"recipient": "@flow",
|
||||
"task": "Check plan",
|
||||
"status": "sent",
|
||||
}
|
||||
)
|
||||
calls = " ".join(str(c) for c in mock_con.print.call_args_list)
|
||||
assert "OK" in calls or "Sent" in calls or "@flow" in calls
|
||||
|
||||
def test_status_skipped(self, _log, mock_err, _con):
|
||||
from aipass.daemon.apps.modules.schedule import _display_task_result
|
||||
|
||||
_display_task_result(
|
||||
{
|
||||
"id": "t2",
|
||||
"recipient": "@seedgo",
|
||||
"task": "Audit",
|
||||
"status": "skipped",
|
||||
}
|
||||
)
|
||||
mock_err.assert_called()
|
||||
|
||||
def test_status_failed(self, _log, mock_err, _con):
|
||||
from aipass.daemon.apps.modules.schedule import _display_task_result
|
||||
|
||||
_display_task_result(
|
||||
{
|
||||
"id": "t3",
|
||||
"recipient": "@daemon",
|
||||
"task": "Heartbeat",
|
||||
"status": "failed",
|
||||
}
|
||||
)
|
||||
mock_err.assert_called()
|
||||
|
||||
def test_status_error(self, _log, mock_err, _con):
|
||||
from aipass.daemon.apps.modules.schedule import _display_task_result
|
||||
|
||||
_display_task_result(
|
||||
{
|
||||
"id": "t4",
|
||||
"recipient": "@drone",
|
||||
"task": "Ping",
|
||||
"status": "error",
|
||||
"error": "timeout",
|
||||
}
|
||||
)
|
||||
mock_err.assert_called()
|
||||
|
||||
|
||||
# =============================================
|
||||
# _send_email_via_drone
|
||||
# =============================================
|
||||
|
||||
|
||||
@patch(f"{MODULE}.logger")
|
||||
class TestSendEmailViaDrone:
|
||||
"""Tests for _send_email_via_drone subprocess wrapper."""
|
||||
|
||||
@patch("subprocess.run")
|
||||
def test_success(self, mock_run, _log):
|
||||
from aipass.daemon.apps.modules.schedule import _send_email_via_drone
|
||||
|
||||
mock_run.return_value = MagicMock(returncode=0)
|
||||
assert _send_email_via_drone("@flow", "subj", "body") is True
|
||||
mock_run.assert_called_once()
|
||||
|
||||
@patch("subprocess.run", side_effect=OSError("no drone"))
|
||||
def test_failure(self, _run, _log):
|
||||
from aipass.daemon.apps.modules.schedule import _send_email_via_drone
|
||||
|
||||
assert _send_email_via_drone("@flow", "subj", "body") is False
|
||||
@@ -0,0 +1,351 @@
|
||||
# =================== AIPass ====================
|
||||
# Name: test_scheduler_bot.py
|
||||
# Description: Tests for TDPLAN-0008 Phase 1 — scheduler bot daemon layer
|
||||
# Version: 1.0.0
|
||||
# Created: 2026-06-25
|
||||
# Modified: 2026-06-25
|
||||
# =============================================
|
||||
|
||||
"""Tests for TDPLAN-0008 Phase 1: status capture, queue view, lifecycle notifications, archive."""
|
||||
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from aipass.daemon.apps.handlers.schedule.runstate import (
|
||||
update_job_runstate,
|
||||
record_job_failure,
|
||||
)
|
||||
from aipass.daemon.apps.handlers.schedule.telegram_notifier import (
|
||||
notify_triggered,
|
||||
notify_complete,
|
||||
notify_error,
|
||||
)
|
||||
from aipass.daemon.apps.modules.queue import (
|
||||
_build_queue,
|
||||
_build_json_output,
|
||||
_schedule_human,
|
||||
handle_command,
|
||||
)
|
||||
|
||||
|
||||
# ── Fixtures ──────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def once_job():
|
||||
"""A once-type job that fires on due_date."""
|
||||
return {
|
||||
"owner": "@api",
|
||||
"id": "data-check",
|
||||
"enabled": True,
|
||||
"schedule": {"type": "once", "due_date": datetime.now().strftime("%Y-%m-%d")},
|
||||
"wake": {"fresh": True, "model": "haiku"},
|
||||
"prompt": "Check the live data and report back.",
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def interval_job():
|
||||
"""An interval-type job."""
|
||||
return {
|
||||
"owner": "@commons",
|
||||
"id": "rotation",
|
||||
"enabled": True,
|
||||
"schedule": {"type": "interval", "interval_minutes": 120},
|
||||
"wake": {"fresh": True},
|
||||
"prompt": "Rotate community content.",
|
||||
}
|
||||
|
||||
|
||||
# ── Test 1: once job fires on due_date, then marks completed ───
|
||||
|
||||
|
||||
class TestOnceJobLifecycle:
|
||||
"""Verify once jobs fire on/after due_date and mark completed."""
|
||||
|
||||
def test_fires_on_due_date(self, once_job):
|
||||
"""Once job with today's due_date gets last_status=success + completed."""
|
||||
runstate = {"jobs": {}}
|
||||
update_job_runstate(runstate, "@api", "data-check", once_job["schedule"])
|
||||
entry = runstate["jobs"]["@api/data-check"]
|
||||
assert entry["last_status"] == "success"
|
||||
assert "completed" in entry
|
||||
assert entry["last_success_at"] is not None
|
||||
assert entry["last_error"] is None
|
||||
|
||||
def test_completed_once_excluded_from_queue(self, once_job):
|
||||
"""Completed once jobs are filtered out of the queue view."""
|
||||
runstate = {
|
||||
"jobs": {
|
||||
"@api/data-check": {
|
||||
"completed": "2026-06-25T10:00:00",
|
||||
"last_run": "2026-06-25T10:00:00",
|
||||
}
|
||||
}
|
||||
}
|
||||
entries = _build_queue([once_job], runstate)
|
||||
assert len(entries) == 0
|
||||
|
||||
|
||||
# ── Test 2: fire emits notify_triggered then notify_complete; failed emits notify_error ──
|
||||
|
||||
|
||||
class TestLifecycleNotifications:
|
||||
"""Verify telegram notifications fire on real job events."""
|
||||
|
||||
@patch("aipass.daemon.apps.handlers.schedule.telegram_notifier._send")
|
||||
def test_triggered_sends_running(self, mock_send):
|
||||
"""notify_triggered sends running ping."""
|
||||
mock_send.return_value = True
|
||||
result = notify_triggered("@api", "data-check")
|
||||
assert result is True
|
||||
call_msg = mock_send.call_args[0][0]
|
||||
assert "@api/data-check" in call_msg
|
||||
assert "running" in call_msg
|
||||
|
||||
@patch("aipass.daemon.apps.handlers.schedule.telegram_notifier._send")
|
||||
def test_complete_sends_dispatched(self, mock_send):
|
||||
"""notify_complete sends dispatched ping with summary."""
|
||||
mock_send.return_value = True
|
||||
result = notify_complete("@api", "data-check", "Agent spawned OK")
|
||||
assert result is True
|
||||
call_msg = mock_send.call_args[0][0]
|
||||
assert "dispatched" in call_msg
|
||||
assert "Agent spawned OK" in call_msg
|
||||
|
||||
@patch("aipass.daemon.apps.handlers.schedule.telegram_notifier._send")
|
||||
def test_error_sends_failed(self, mock_send):
|
||||
"""notify_error sends failed ping with error detail."""
|
||||
mock_send.return_value = True
|
||||
result = notify_error("@api", "data-check", "timeout")
|
||||
assert result is True
|
||||
call_msg = mock_send.call_args[0][0]
|
||||
assert "FAILED" in call_msg
|
||||
assert "timeout" in call_msg
|
||||
|
||||
|
||||
# ── Test 3: runstate records last_status + last_error on both paths ──
|
||||
|
||||
|
||||
class TestRunstateStatusCapture:
|
||||
"""Verify runstate tracks status on success AND failure."""
|
||||
|
||||
def test_success_records_status(self):
|
||||
"""Successful fire sets last_status=success, last_success_at, clears error."""
|
||||
runstate = {"jobs": {}}
|
||||
schedule = {"type": "interval", "interval_minutes": 60}
|
||||
update_job_runstate(runstate, "@commons", "test", schedule)
|
||||
entry = runstate["jobs"]["@commons/test"]
|
||||
assert entry["last_status"] == "success"
|
||||
assert entry["last_success_at"] is not None
|
||||
assert entry["last_error"] is None
|
||||
|
||||
def test_failure_records_status(self):
|
||||
"""Failed fire sets last_status=failed, last_failure_at, last_error."""
|
||||
runstate = {"jobs": {}}
|
||||
record_job_failure(runstate, "@commons", "test", "branch locked")
|
||||
entry = runstate["jobs"]["@commons/test"]
|
||||
assert entry["last_status"] == "failed"
|
||||
assert entry["last_failure_at"] is not None
|
||||
assert entry["last_error"] == "branch locked"
|
||||
|
||||
def test_failure_preserves_last_run(self):
|
||||
"""Failure path still records last_run timestamp."""
|
||||
runstate = {"jobs": {}}
|
||||
record_job_failure(runstate, "@x", "y", "err")
|
||||
entry = runstate["jobs"]["@x/y"]
|
||||
assert "last_run" in entry
|
||||
|
||||
def test_error_truncated(self):
|
||||
"""Long error messages are truncated to 500 chars."""
|
||||
runstate = {"jobs": {}}
|
||||
record_job_failure(runstate, "@x", "y", "x" * 1000)
|
||||
entry = runstate["jobs"]["@x/y"]
|
||||
assert len(entry["last_error"]) == 500
|
||||
|
||||
|
||||
# ── Test 4: queue --json returns frozen schema ──
|
||||
|
||||
|
||||
class TestQueueJsonSchema:
|
||||
"""Verify queue --json output matches the frozen contract."""
|
||||
|
||||
def test_schema_structure(self, interval_job):
|
||||
"""JSON output has generated_at, count, jobs array."""
|
||||
runstate = {"jobs": {}}
|
||||
entries = _build_queue([interval_job], runstate)
|
||||
output = _build_json_output(entries)
|
||||
assert "generated_at" in output
|
||||
assert "count" in output
|
||||
assert isinstance(output["jobs"], list)
|
||||
assert output["count"] == len(output["jobs"])
|
||||
|
||||
def test_job_fields(self, interval_job):
|
||||
"""Each job in output has all frozen-schema fields."""
|
||||
runstate = {"jobs": {}}
|
||||
entries = _build_queue([interval_job], runstate)
|
||||
job_out = entries[0]
|
||||
required_fields = [
|
||||
"owner",
|
||||
"id",
|
||||
"enabled",
|
||||
"type",
|
||||
"schedule_human",
|
||||
"next_run",
|
||||
"last_run",
|
||||
"last_status",
|
||||
"last_error",
|
||||
"prompt_preview",
|
||||
"wake",
|
||||
]
|
||||
for field in required_fields:
|
||||
assert field in job_out, f"Missing field: {field}"
|
||||
|
||||
def test_type_values(self, once_job, interval_job):
|
||||
"""Type field matches schedule type."""
|
||||
runstate = {"jobs": {}}
|
||||
once_entries = _build_queue([once_job], runstate)
|
||||
assert once_entries[0]["type"] == "once"
|
||||
interval_entries = _build_queue([interval_job], runstate)
|
||||
assert interval_entries[0]["type"] == "interval"
|
||||
|
||||
def test_schedule_human_formats(self):
|
||||
"""schedule_human renders each type correctly."""
|
||||
assert _schedule_human({"schedule": {"type": "once", "due_date": "2026-07-02"}}) == "2026-07-02"
|
||||
assert _schedule_human({"schedule": {"type": "daily", "time": "04:00"}}) == "daily @ 04:00"
|
||||
assert _schedule_human({"schedule": {"type": "hourly", "time": "30"}}) == "hourly @ :30"
|
||||
assert _schedule_human({"schedule": {"type": "interval", "interval_minutes": 120}}) == "every 2h"
|
||||
assert _schedule_human({"schedule": {"type": "interval", "interval_minutes": 30}}) == "every 30m"
|
||||
|
||||
|
||||
# ── Test 5: empty tick emits ZERO telegram calls ──
|
||||
|
||||
|
||||
class TestEmptyTickNoNotify:
|
||||
"""Verify no telegram calls on empty ticks (no due jobs)."""
|
||||
|
||||
@patch("aipass.daemon.apps.modules.run.discover_jobs", return_value=[])
|
||||
@patch("aipass.daemon.apps.handlers.schedule.telegram_notifier._send")
|
||||
def test_no_jobs_no_send(self, mock_send, mock_discover):
|
||||
"""Empty tick with no discovered jobs makes zero telegram calls."""
|
||||
from aipass.daemon.apps.modules.run import run_tick
|
||||
|
||||
run_tick(dry_run=True)
|
||||
mock_send.assert_not_called()
|
||||
|
||||
@patch("aipass.daemon.apps.modules.run.discover_jobs")
|
||||
@patch("aipass.daemon.apps.modules.run.load_runstate")
|
||||
@patch("aipass.daemon.apps.handlers.schedule.telegram_notifier._send")
|
||||
def test_no_due_jobs_no_send(self, mock_send, mock_rs, mock_discover):
|
||||
"""Tick with jobs but none due makes zero telegram calls."""
|
||||
from aipass.daemon.apps.modules.run import run_tick
|
||||
|
||||
mock_discover.return_value = [
|
||||
{
|
||||
"owner": "@commons",
|
||||
"id": "test",
|
||||
"enabled": True,
|
||||
"schedule": {"type": "interval", "interval_minutes": 9999},
|
||||
"wake": {},
|
||||
"prompt": "test",
|
||||
}
|
||||
]
|
||||
mock_rs.return_value = {"jobs": {"@commons/test": {"last_run": datetime.now().isoformat()}}}
|
||||
run_tick()
|
||||
mock_send.assert_not_called()
|
||||
|
||||
|
||||
# ── Test 6: send is fail-soft ──
|
||||
|
||||
|
||||
class TestFailSoft:
|
||||
"""Verify telegram send failures don't block job firing."""
|
||||
|
||||
@patch("aipass.daemon.apps.handlers.schedule.telegram_notifier._send")
|
||||
def test_send_returns_false_on_failure(self, mock_send):
|
||||
"""When _send fails, notify functions return False gracefully."""
|
||||
mock_send.return_value = False
|
||||
assert notify_triggered("@x", "y") is False
|
||||
assert notify_complete("@x", "y", "s") is False
|
||||
assert notify_error("@x", "y", "e") is False
|
||||
|
||||
@patch(
|
||||
"aipass.skills.lib.telegram.apps.handlers.notifier.send_telegram_notification",
|
||||
side_effect=Exception("connection refused"),
|
||||
)
|
||||
def test_exception_caught(self, mock_notifier):
|
||||
"""Exception in send_telegram_notification is caught, returns False."""
|
||||
from aipass.daemon.apps.handlers.schedule.telegram_notifier import _send
|
||||
|
||||
result = _send("test message")
|
||||
assert result is False
|
||||
|
||||
@patch("aipass.daemon.apps.modules.run.save_runstate")
|
||||
@patch("aipass.daemon.apps.modules.run.record_job_failure")
|
||||
@patch("aipass.daemon.apps.modules.run.update_job_runstate")
|
||||
@patch("aipass.daemon.apps.modules.run.discover_jobs")
|
||||
@patch("aipass.daemon.apps.modules.run.load_runstate", return_value={"jobs": {}})
|
||||
def test_fire_continues_when_notify_fails(self, mock_rs, mock_discover, mock_update, mock_fail, mock_save):
|
||||
"""Job fires and records status even when telegram is down."""
|
||||
from aipass.daemon.apps.modules.run import run_tick
|
||||
|
||||
mock_discover.return_value = [
|
||||
{
|
||||
"owner": "@commons",
|
||||
"id": "test",
|
||||
"enabled": True,
|
||||
"schedule": {"type": "interval", "interval_minutes": 1},
|
||||
"wake": {"fresh": True},
|
||||
"prompt": "test",
|
||||
"notify": True,
|
||||
}
|
||||
]
|
||||
|
||||
with patch("aipass.daemon.apps.modules.run._fire_job", return_value=(True, "")):
|
||||
results = run_tick()
|
||||
assert results["fired"] == 1
|
||||
|
||||
|
||||
# ── Test 7: dormant registries archived ──
|
||||
|
||||
|
||||
class TestDormantArchived:
|
||||
"""Verify dormant registries are archived and no live code references them."""
|
||||
|
||||
def test_original_data_files_gone(self):
|
||||
"""Original data files no longer at daemon_json/ root."""
|
||||
daemon_root = Path(__file__).resolve().parents[1]
|
||||
assert not (daemon_root / "daemon_json" / "schedule.json").exists()
|
||||
assert not (daemon_root / "daemon_json" / "actions_registry.json").exists()
|
||||
|
||||
def test_task_registry_not_importable(self):
|
||||
"""task_registry handler is gone from the live import path."""
|
||||
with pytest.raises(ImportError):
|
||||
from aipass.daemon.apps.handlers.schedule.task_registry import load_tasks # type: ignore[import-not-found] # noqa: F401
|
||||
|
||||
def test_actions_registry_not_importable(self):
|
||||
"""actions_registry handler is gone from the live import path."""
|
||||
with pytest.raises(ImportError):
|
||||
from aipass.daemon.apps.handlers.actions.actions_registry import load_registry # type: ignore[import-not-found] # noqa: F401
|
||||
|
||||
def test_schedule_module_retired(self):
|
||||
"""Schedule module handle_command shows retirement notice, not old CRUD."""
|
||||
from aipass.daemon.apps.modules.schedule import handle_command as sched_cmd
|
||||
|
||||
assert sched_cmd("schedule", []) is True
|
||||
assert sched_cmd("schedule", ["create", "test"]) is True
|
||||
|
||||
def test_actions_module_retired(self):
|
||||
"""Actions module handle_command shows retirement notice, not old CRUD."""
|
||||
from aipass.daemon.apps.modules.actions import handle_command as act_cmd
|
||||
|
||||
assert act_cmd("actions", []) is True
|
||||
assert act_cmd("actions", ["list"]) is True
|
||||
|
||||
def test_queue_command_wired(self):
|
||||
"""drone @daemon queue is routable."""
|
||||
assert handle_command("queue", []) is True
|
||||
assert handle_command("notqueue", []) is False
|
||||
@@ -1,488 +0,0 @@
|
||||
# ===================AIPASS====================
|
||||
# META DATA HEADER
|
||||
# Name: test_scheduler_cron.py - Scheduler Cron Tests
|
||||
# Date: 2026-04-02
|
||||
# Version: 1.0.0
|
||||
# Category: daemon/tests
|
||||
#
|
||||
# CHANGELOG (Max 5 entries):
|
||||
# - v1.0.0 (2026-04-02): Initial creation - scheduler_cron dispatch path tests
|
||||
#
|
||||
# CODE STANDARDS:
|
||||
# - Pytest conventions
|
||||
# - Full mock isolation (no real subprocesses or locks)
|
||||
# =============================================
|
||||
|
||||
"""Tests for scheduler_cron dispatch paths."""
|
||||
|
||||
import subprocess
|
||||
import sys
|
||||
from datetime import datetime
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
MODULE = "aipass.daemon.apps.scheduler_cron"
|
||||
|
||||
|
||||
# =============================================
|
||||
# FIXTURES
|
||||
# =============================================
|
||||
|
||||
|
||||
def _make_task(
|
||||
task_id: str = "abc12345-6789",
|
||||
recipient: str = "@devpulse",
|
||||
task: str = "Run morning briefing",
|
||||
message: str = "Details here",
|
||||
) -> dict:
|
||||
"""Build a minimal task dict for testing."""
|
||||
return {
|
||||
"id": task_id,
|
||||
"recipient": recipient,
|
||||
"task": task,
|
||||
"message": message,
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _silence_logging():
|
||||
"""Suppress logger and console output for all tests."""
|
||||
with (
|
||||
patch(f"{MODULE}.logger"),
|
||||
patch(f"{MODULE}.console"),
|
||||
patch(f"{MODULE}.log"),
|
||||
):
|
||||
yield
|
||||
|
||||
|
||||
# =============================================
|
||||
# _send_email_via_drone
|
||||
# =============================================
|
||||
|
||||
|
||||
class TestSendEmailViaDrone:
|
||||
"""Tests for _send_email_via_drone subprocess wrapper."""
|
||||
|
||||
def test_success(self):
|
||||
from aipass.daemon.apps.scheduler_cron import _send_email_via_drone
|
||||
|
||||
mock_result = MagicMock(returncode=0)
|
||||
with patch(f"{MODULE}.subprocess.run", return_value=mock_result) as mock_run:
|
||||
result = _send_email_via_drone("@devpulse", "Subject", "Body")
|
||||
assert result is True
|
||||
mock_run.assert_called_once()
|
||||
cmd = mock_run.call_args[0][0]
|
||||
assert cmd[:3] == ["drone", "@ai_mail", "send"]
|
||||
assert "--dispatch" in cmd
|
||||
|
||||
def test_no_auto_execute(self):
|
||||
from aipass.daemon.apps.scheduler_cron import _send_email_via_drone
|
||||
|
||||
mock_result = MagicMock(returncode=0)
|
||||
with patch(f"{MODULE}.subprocess.run", return_value=mock_result) as mock_run:
|
||||
_send_email_via_drone("@devpulse", "Subj", "Msg", auto_execute=False)
|
||||
cmd = mock_run.call_args[0][0]
|
||||
assert "--dispatch" not in cmd
|
||||
|
||||
def test_nonzero_returncode(self):
|
||||
from aipass.daemon.apps.scheduler_cron import _send_email_via_drone
|
||||
|
||||
mock_result = MagicMock(returncode=1)
|
||||
with patch(f"{MODULE}.subprocess.run", return_value=mock_result):
|
||||
result = _send_email_via_drone("@devpulse", "Subj", "Msg")
|
||||
assert result is False
|
||||
|
||||
def test_subprocess_error(self):
|
||||
from aipass.daemon.apps.scheduler_cron import _send_email_via_drone
|
||||
|
||||
with patch(f"{MODULE}.subprocess.run", side_effect=subprocess.TimeoutExpired(cmd="drone", timeout=15)):
|
||||
result = _send_email_via_drone("@devpulse", "Subj", "Msg")
|
||||
assert result is False
|
||||
|
||||
def test_os_error(self):
|
||||
from aipass.daemon.apps.scheduler_cron import _send_email_via_drone
|
||||
|
||||
with patch(f"{MODULE}.subprocess.run", side_effect=OSError("drone not found")):
|
||||
result = _send_email_via_drone("@devpulse", "Subj", "Msg")
|
||||
assert result is False
|
||||
|
||||
|
||||
# =============================================
|
||||
# _next_cron_run
|
||||
# =============================================
|
||||
|
||||
|
||||
class TestNextCronRun:
|
||||
"""Tests for next cron run time calculation."""
|
||||
|
||||
def test_before_half_hour(self):
|
||||
from aipass.daemon.apps.scheduler_cron import _next_cron_run
|
||||
|
||||
fake_now = datetime(2026, 4, 2, 10, 15, 0)
|
||||
with patch(f"{MODULE}.datetime") as mock_dt:
|
||||
mock_dt.now.return_value = fake_now
|
||||
mock_dt.side_effect = lambda *a, **kw: datetime(*a, **kw)
|
||||
result = _next_cron_run()
|
||||
assert result == "10:30"
|
||||
|
||||
def test_after_half_hour(self):
|
||||
from aipass.daemon.apps.scheduler_cron import _next_cron_run
|
||||
|
||||
fake_now = datetime(2026, 4, 2, 10, 45, 0)
|
||||
with patch(f"{MODULE}.datetime") as mock_dt:
|
||||
mock_dt.now.return_value = fake_now
|
||||
mock_dt.side_effect = lambda *a, **kw: datetime(*a, **kw)
|
||||
result = _next_cron_run()
|
||||
assert result == "11:00"
|
||||
|
||||
def test_before_midnight_rollover(self):
|
||||
from aipass.daemon.apps.scheduler_cron import _next_cron_run
|
||||
|
||||
fake_now = datetime(2026, 4, 2, 23, 45, 0)
|
||||
with patch(f"{MODULE}.datetime") as mock_dt:
|
||||
mock_dt.now.return_value = fake_now
|
||||
mock_dt.side_effect = lambda *a, **kw: datetime(*a, **kw)
|
||||
result = _next_cron_run()
|
||||
assert result == "00:00"
|
||||
|
||||
|
||||
# =============================================
|
||||
# _process_single_task
|
||||
# =============================================
|
||||
|
||||
|
||||
class TestProcessSingleTask:
|
||||
"""Tests for the single-task dispatch function."""
|
||||
|
||||
def test_success_path(self):
|
||||
from aipass.daemon.apps.scheduler_cron import _process_single_task
|
||||
|
||||
results = {"success": 0, "failed": 0, "errors": []}
|
||||
task = _make_task()
|
||||
|
||||
with (
|
||||
patch(f"{MODULE}.mark_dispatching") as mock_dispatch,
|
||||
patch(f"{MODULE}.send_email_direct", return_value=True) as mock_send,
|
||||
patch(f"{MODULE}.mark_completed") as mock_complete,
|
||||
patch(f"{MODULE}.AI_MAIL_AVAILABLE", True),
|
||||
):
|
||||
_process_single_task(task, results)
|
||||
|
||||
mock_dispatch.assert_called_once_with(task["id"])
|
||||
mock_send.assert_called_once()
|
||||
mock_complete.assert_called_once_with(task["id"])
|
||||
assert results["success"] == 1
|
||||
assert results["failed"] == 0
|
||||
|
||||
def test_mark_dispatching_failure(self):
|
||||
from aipass.daemon.apps.scheduler_cron import _process_single_task
|
||||
|
||||
results = {"success": 0, "failed": 0, "errors": []}
|
||||
task = _make_task()
|
||||
|
||||
with (
|
||||
patch(f"{MODULE}.mark_dispatching", side_effect=RuntimeError("lock error")),
|
||||
patch(f"{MODULE}.send_email_direct") as mock_send,
|
||||
):
|
||||
_process_single_task(task, results)
|
||||
|
||||
mock_send.assert_not_called()
|
||||
assert results["failed"] == 1
|
||||
assert len(results["errors"]) == 1
|
||||
|
||||
def test_email_unavailable(self):
|
||||
from aipass.daemon.apps.scheduler_cron import _process_single_task
|
||||
|
||||
results = {"success": 0, "failed": 0, "errors": []}
|
||||
task = _make_task()
|
||||
|
||||
with (
|
||||
patch(f"{MODULE}.mark_dispatching"),
|
||||
patch(f"{MODULE}.AI_MAIL_AVAILABLE", False),
|
||||
patch(f"{MODULE}.mark_pending") as mock_pending,
|
||||
):
|
||||
_process_single_task(task, results)
|
||||
|
||||
mock_pending.assert_called_once_with(task["id"])
|
||||
assert results["failed"] == 1
|
||||
|
||||
def test_email_send_returns_false(self):
|
||||
from aipass.daemon.apps.scheduler_cron import _process_single_task
|
||||
|
||||
results = {"success": 0, "failed": 0, "errors": []}
|
||||
task = _make_task()
|
||||
|
||||
with (
|
||||
patch(f"{MODULE}.mark_dispatching"),
|
||||
patch(f"{MODULE}.send_email_direct", return_value=False),
|
||||
patch(f"{MODULE}.mark_pending") as mock_pending,
|
||||
patch(f"{MODULE}.AI_MAIL_AVAILABLE", True),
|
||||
):
|
||||
_process_single_task(task, results)
|
||||
|
||||
mock_pending.assert_called_once_with(task["id"])
|
||||
assert results["failed"] == 1
|
||||
assert results["success"] == 0
|
||||
|
||||
def test_email_exception_resets_to_pending(self):
|
||||
from aipass.daemon.apps.scheduler_cron import _process_single_task
|
||||
|
||||
results = {"success": 0, "failed": 0, "errors": []}
|
||||
task = _make_task()
|
||||
|
||||
with (
|
||||
patch(f"{MODULE}.mark_dispatching"),
|
||||
patch(f"{MODULE}.send_email_direct", side_effect=ConnectionError("timeout")),
|
||||
patch(f"{MODULE}.mark_pending") as mock_pending,
|
||||
patch(f"{MODULE}.AI_MAIL_AVAILABLE", True),
|
||||
):
|
||||
_process_single_task(task, results)
|
||||
|
||||
mock_pending.assert_called_once_with(task["id"])
|
||||
assert results["failed"] == 1
|
||||
|
||||
|
||||
# =============================================
|
||||
# process_due_tasks
|
||||
# =============================================
|
||||
|
||||
|
||||
class TestProcessDueTasks:
|
||||
"""Tests for the top-level due-task processor."""
|
||||
|
||||
def test_no_tasks_due(self):
|
||||
from aipass.daemon.apps.scheduler_cron import process_due_tasks
|
||||
|
||||
with (
|
||||
patch(f"{MODULE}.TASK_REGISTRY_AVAILABLE", True),
|
||||
patch(f"{MODULE}.recover_stale_dispatches", return_value=0),
|
||||
patch(f"{MODULE}.get_due_tasks", return_value=[]),
|
||||
):
|
||||
results = process_due_tasks()
|
||||
|
||||
assert results["due"] == 0
|
||||
assert results["success"] == 0
|
||||
|
||||
def test_task_registry_unavailable(self):
|
||||
from aipass.daemon.apps.scheduler_cron import process_due_tasks
|
||||
|
||||
with patch(f"{MODULE}.TASK_REGISTRY_AVAILABLE", False):
|
||||
results = process_due_tasks()
|
||||
|
||||
assert results["due"] == 0
|
||||
assert results["success"] == 0
|
||||
|
||||
def test_stale_dispatch_recovery(self):
|
||||
from aipass.daemon.apps.scheduler_cron import process_due_tasks
|
||||
|
||||
with (
|
||||
patch(f"{MODULE}.TASK_REGISTRY_AVAILABLE", True),
|
||||
patch(f"{MODULE}.recover_stale_dispatches", return_value=3) as mock_recover,
|
||||
patch(f"{MODULE}.get_due_tasks", return_value=[]),
|
||||
):
|
||||
results = process_due_tasks()
|
||||
|
||||
mock_recover.assert_called_once_with(max_age_minutes=5)
|
||||
assert results["recovered"] == 3
|
||||
|
||||
def test_stale_recovery_exception(self):
|
||||
from aipass.daemon.apps.scheduler_cron import process_due_tasks
|
||||
|
||||
with (
|
||||
patch(f"{MODULE}.TASK_REGISTRY_AVAILABLE", True),
|
||||
patch(f"{MODULE}.recover_stale_dispatches", side_effect=RuntimeError("fs error")),
|
||||
patch(f"{MODULE}.get_due_tasks", return_value=[]),
|
||||
):
|
||||
results = process_due_tasks()
|
||||
|
||||
assert len(results["errors"]) == 1
|
||||
assert "Stale recovery" in results["errors"][0]
|
||||
|
||||
def test_get_due_tasks_exception(self):
|
||||
from aipass.daemon.apps.scheduler_cron import process_due_tasks
|
||||
|
||||
with (
|
||||
patch(f"{MODULE}.TASK_REGISTRY_AVAILABLE", True),
|
||||
patch(f"{MODULE}.recover_stale_dispatches", return_value=0),
|
||||
patch(f"{MODULE}.get_due_tasks", side_effect=RuntimeError("corrupt JSON")),
|
||||
):
|
||||
results = process_due_tasks()
|
||||
|
||||
assert "Load tasks" in results["errors"][0]
|
||||
|
||||
@patch(f"{MODULE}.time.sleep")
|
||||
def test_successful_send(self, _mock_sleep):
|
||||
from aipass.daemon.apps.scheduler_cron import process_due_tasks
|
||||
|
||||
task = _make_task()
|
||||
with (
|
||||
patch(f"{MODULE}.TASK_REGISTRY_AVAILABLE", True),
|
||||
patch(f"{MODULE}.recover_stale_dispatches", return_value=0),
|
||||
patch(f"{MODULE}.get_due_tasks", return_value=[task]),
|
||||
patch(f"{MODULE}.mark_dispatching"),
|
||||
patch(f"{MODULE}.send_email_direct", return_value=True),
|
||||
patch(f"{MODULE}.mark_completed"),
|
||||
patch(f"{MODULE}.AI_MAIL_AVAILABLE", True),
|
||||
):
|
||||
results = process_due_tasks()
|
||||
|
||||
assert results["due"] == 1
|
||||
assert results["success"] == 1
|
||||
assert results["failed"] == 0
|
||||
|
||||
@patch(f"{MODULE}.time.sleep")
|
||||
def test_send_failure_marks_pending(self, _mock_sleep):
|
||||
from aipass.daemon.apps.scheduler_cron import process_due_tasks
|
||||
|
||||
task = _make_task()
|
||||
with (
|
||||
patch(f"{MODULE}.TASK_REGISTRY_AVAILABLE", True),
|
||||
patch(f"{MODULE}.recover_stale_dispatches", return_value=0),
|
||||
patch(f"{MODULE}.get_due_tasks", return_value=[task]),
|
||||
patch(f"{MODULE}.mark_dispatching"),
|
||||
patch(f"{MODULE}.send_email_direct", return_value=False),
|
||||
patch(f"{MODULE}.mark_pending") as mock_pending,
|
||||
patch(f"{MODULE}.AI_MAIL_AVAILABLE", True),
|
||||
):
|
||||
results = process_due_tasks()
|
||||
|
||||
mock_pending.assert_called_once()
|
||||
assert results["failed"] == 1
|
||||
|
||||
|
||||
# =============================================
|
||||
# _run_locked
|
||||
# =============================================
|
||||
|
||||
|
||||
class TestRunLocked:
|
||||
"""Tests for the locked orchestration function."""
|
||||
|
||||
def test_success_no_errors(self):
|
||||
from aipass.daemon.apps.scheduler_cron import _run_locked
|
||||
|
||||
task_results = {"due": 0, "success": 0, "failed": 0, "recovered": 0, "errors": []}
|
||||
tick_results = {"discovered": 0, "enabled": 0, "due": 0, "fired": 0, "failed": 0, "skipped": 0}
|
||||
|
||||
with (
|
||||
patch(f"{MODULE}.process_due_tasks", return_value=task_results),
|
||||
patch(f"{MODULE}.run_tick", return_value=tick_results),
|
||||
patch(f"{MODULE}._next_cron_run", return_value="10:30"),
|
||||
):
|
||||
code = _run_locked()
|
||||
|
||||
assert code == 0
|
||||
|
||||
def test_returns_1_on_task_failures(self):
|
||||
from aipass.daemon.apps.scheduler_cron import _run_locked
|
||||
|
||||
task_results = {"due": 1, "success": 0, "failed": 1, "recovered": 0, "errors": ["fail"]}
|
||||
tick_results = {"discovered": 0, "enabled": 0, "due": 0, "fired": 0, "failed": 0, "skipped": 0}
|
||||
|
||||
with (
|
||||
patch(f"{MODULE}.process_due_tasks", return_value=task_results),
|
||||
patch(f"{MODULE}.run_tick", return_value=tick_results),
|
||||
patch(f"{MODULE}._next_cron_run", return_value="10:30"),
|
||||
):
|
||||
code = _run_locked()
|
||||
|
||||
assert code == 1
|
||||
|
||||
def test_process_due_tasks_unhandled_exception(self):
|
||||
from aipass.daemon.apps.scheduler_cron import _run_locked
|
||||
|
||||
with patch(f"{MODULE}.process_due_tasks", side_effect=RuntimeError("boom")):
|
||||
code = _run_locked()
|
||||
|
||||
assert code == 1
|
||||
|
||||
def test_run_tick_exception_handled(self):
|
||||
from aipass.daemon.apps.scheduler_cron import _run_locked
|
||||
|
||||
task_results = {"due": 0, "success": 0, "failed": 0, "recovered": 0, "errors": []}
|
||||
|
||||
with (
|
||||
patch(f"{MODULE}.process_due_tasks", return_value=task_results),
|
||||
patch(f"{MODULE}.run_tick", side_effect=RuntimeError("tick boom")),
|
||||
patch(f"{MODULE}._next_cron_run", return_value="10:30"),
|
||||
):
|
||||
code = _run_locked()
|
||||
|
||||
assert code == 0
|
||||
|
||||
|
||||
# =============================================
|
||||
# main
|
||||
# =============================================
|
||||
|
||||
|
||||
class TestMain:
|
||||
"""Tests for the main entry point."""
|
||||
|
||||
def test_no_args_introspection(self):
|
||||
from aipass.daemon.apps.scheduler_cron import main
|
||||
|
||||
with (
|
||||
patch(f"{MODULE}.sys.argv", ["scheduler_cron.py"]),
|
||||
patch(f"{MODULE}.print_introspection") as mock_intro,
|
||||
):
|
||||
code = main()
|
||||
|
||||
mock_intro.assert_called_once()
|
||||
assert code == 0
|
||||
|
||||
def test_help_flag(self):
|
||||
from aipass.daemon.apps.scheduler_cron import main
|
||||
|
||||
with (
|
||||
patch(f"{MODULE}.sys.argv", ["scheduler_cron.py", "--help"]),
|
||||
patch(f"{MODULE}.print_help") as mock_help,
|
||||
):
|
||||
with pytest.raises(SystemExit) as exc_info:
|
||||
main()
|
||||
mock_help.assert_called_once()
|
||||
assert exc_info.value.code == 0
|
||||
|
||||
@pytest.mark.skipif(
|
||||
sys.platform == "win32",
|
||||
reason="patches fcntl.flock; fcntl is Unix-only (None on Windows). Scheduler skips locking on non-Unix.",
|
||||
)
|
||||
def test_lock_acquisition_failure(self, tmp_path):
|
||||
from aipass.daemon.apps.scheduler_cron import main
|
||||
|
||||
lock_file = tmp_path / "schedule.lock"
|
||||
mock_fd = MagicMock()
|
||||
with (
|
||||
patch(f"{MODULE}.sys.argv", ["scheduler_cron.py", "run"]),
|
||||
patch(f"{MODULE}.json_handler"),
|
||||
patch(f"{MODULE}.LOCK_FILE", lock_file),
|
||||
patch("builtins.open", return_value=mock_fd),
|
||||
patch(f"{MODULE}.fcntl.flock", side_effect=OSError("locked")),
|
||||
):
|
||||
code = main()
|
||||
|
||||
assert code == 0 # graceful skip when another instance is running
|
||||
mock_fd.close.assert_called()
|
||||
|
||||
@pytest.mark.skipif(
|
||||
sys.platform == "win32",
|
||||
reason="patches fcntl.flock; fcntl is Unix-only (None on Windows). Scheduler skips locking on non-Unix.",
|
||||
)
|
||||
def test_lock_acquired_runs_locked(self, tmp_path):
|
||||
from aipass.daemon.apps.scheduler_cron import main
|
||||
|
||||
lock_file = tmp_path / "schedule.lock"
|
||||
mock_fd = MagicMock()
|
||||
with (
|
||||
patch(f"{MODULE}.sys.argv", ["scheduler_cron.py", "run"]),
|
||||
patch(f"{MODULE}.json_handler"),
|
||||
patch(f"{MODULE}.LOCK_FILE", lock_file),
|
||||
patch("builtins.open", return_value=mock_fd),
|
||||
patch(f"{MODULE}.fcntl.flock"),
|
||||
patch(f"{MODULE}._run_locked", return_value=0) as mock_run,
|
||||
):
|
||||
code = main()
|
||||
|
||||
mock_run.assert_called_once()
|
||||
assert code == 0
|
||||
@@ -1,117 +0,0 @@
|
||||
# =================== AIPass ====================
|
||||
# Name: test_scheduler_ops.py
|
||||
# Description: Tests for the scheduler_ops facade module
|
||||
# Version: 1.0.0
|
||||
# Created: 2026-04-03
|
||||
# Modified: 2026-04-03
|
||||
# =============================================
|
||||
|
||||
"""Tests for the scheduler_ops facade module (apps/modules/scheduler_ops.py)."""
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
MODULE = "aipass.daemon.apps.modules.scheduler_ops"
|
||||
|
||||
|
||||
# =============================================
|
||||
# handle_command — routing
|
||||
# =============================================
|
||||
|
||||
|
||||
@patch(f"{MODULE}.json_handler")
|
||||
@patch(f"{MODULE}.console")
|
||||
@patch(f"{MODULE}.logger")
|
||||
class TestHandleCommand:
|
||||
"""Tests for handle_command routing."""
|
||||
|
||||
def test_wrong_command_returns_false(self, _log, _con, _jh):
|
||||
from aipass.daemon.apps.modules.scheduler_ops import handle_command
|
||||
|
||||
assert handle_command("not-scheduler-ops", []) is False
|
||||
|
||||
def test_no_args_shows_introspection(self, _log, mock_console, _jh):
|
||||
from aipass.daemon.apps.modules.scheduler_ops import handle_command
|
||||
|
||||
result = handle_command("scheduler-ops", [])
|
||||
assert result is True
|
||||
calls = [str(c) for c in mock_console.print.call_args_list]
|
||||
assert any("scheduler_ops Module" in c for c in calls)
|
||||
|
||||
def test_help_flag_shows_introspection(self, _log, mock_console, _jh):
|
||||
from aipass.daemon.apps.modules.scheduler_ops import handle_command
|
||||
|
||||
assert handle_command("scheduler-ops", ["--help"]) is True
|
||||
calls = [str(c) for c in mock_console.print.call_args_list]
|
||||
assert any("scheduler_ops Module" in c for c in calls)
|
||||
|
||||
def test_h_flag_shows_introspection(self, _log, mock_console, _jh):
|
||||
from aipass.daemon.apps.modules.scheduler_ops import handle_command
|
||||
|
||||
assert handle_command("scheduler-ops", ["-h"]) is True
|
||||
calls = [str(c) for c in mock_console.print.call_args_list]
|
||||
assert any("scheduler_ops Module" in c for c in calls)
|
||||
|
||||
def test_help_word_shows_introspection(self, _log, mock_console, _jh):
|
||||
from aipass.daemon.apps.modules.scheduler_ops import handle_command
|
||||
|
||||
assert handle_command("scheduler-ops", ["help"]) is True
|
||||
calls = [str(c) for c in mock_console.print.call_args_list]
|
||||
assert any("scheduler_ops Module" in c for c in calls)
|
||||
|
||||
def test_status_arg_shows_registry_info(self, _log, mock_console, mock_jh):
|
||||
from aipass.daemon.apps.modules.scheduler_ops import handle_command
|
||||
|
||||
assert handle_command("scheduler-ops", ["status"]) is True
|
||||
mock_jh.log_operation.assert_called_once_with("scheduler_ops_status")
|
||||
calls = [str(c) for c in mock_console.print.call_args_list]
|
||||
assert any("Scheduler Ops" in c for c in calls)
|
||||
|
||||
def test_status_prints_task_registry_availability(self, _log, mock_console, mock_jh):
|
||||
from aipass.daemon.apps.modules.scheduler_ops import handle_command
|
||||
|
||||
handle_command("scheduler-ops", ["status"])
|
||||
calls = [str(c) for c in mock_console.print.call_args_list]
|
||||
assert any("Task registry" in c for c in calls)
|
||||
|
||||
def test_status_prints_action_registry_availability(self, _log, mock_console, mock_jh):
|
||||
from aipass.daemon.apps.modules.scheduler_ops import handle_command
|
||||
|
||||
handle_command("scheduler-ops", ["status"])
|
||||
calls = [str(c) for c in mock_console.print.call_args_list]
|
||||
assert any("Action registry" in c for c in calls)
|
||||
|
||||
|
||||
# =============================================
|
||||
# Module-level availability flags
|
||||
# =============================================
|
||||
|
||||
|
||||
class TestRegistryAvailability:
|
||||
"""Verify that registry imports succeed in the test environment."""
|
||||
|
||||
def test_task_registry_available(self):
|
||||
from aipass.daemon.apps.modules.scheduler_ops import TASK_REGISTRY_AVAILABLE
|
||||
|
||||
assert TASK_REGISTRY_AVAILABLE is True
|
||||
|
||||
def test_action_registry_available(self):
|
||||
from aipass.daemon.apps.modules.scheduler_ops import ACTION_REGISTRY_AVAILABLE
|
||||
|
||||
assert ACTION_REGISTRY_AVAILABLE is True
|
||||
|
||||
|
||||
# =============================================
|
||||
# print_introspection
|
||||
# =============================================
|
||||
|
||||
|
||||
@patch(f"{MODULE}.console")
|
||||
class TestPrintIntrospection:
|
||||
"""Tests for print_introspection output."""
|
||||
|
||||
def test_prints_module_header(self, mock_console):
|
||||
from aipass.daemon.apps.modules.scheduler_ops import print_introspection
|
||||
|
||||
print_introspection()
|
||||
calls = [str(c) for c in mock_console.print.call_args_list]
|
||||
assert any("scheduler_ops Module" in c for c in calls)
|
||||
@@ -1,588 +0,0 @@
|
||||
# ===================AIPASS====================
|
||||
# META DATA HEADER
|
||||
# Name: test_task_registry.py - Task Registry Tests
|
||||
# Date: 2026-03-24
|
||||
# Version: 1.0.0
|
||||
# Category: daemon/tests
|
||||
#
|
||||
# CHANGELOG (Max 5 entries):
|
||||
# - v1.0.0 (2026-03-24): Initial creation - task_registry handler tests
|
||||
#
|
||||
# CODE STANDARDS:
|
||||
# - Pytest conventions
|
||||
# - Temp dir isolation (no writes to real registry)
|
||||
# =============================================
|
||||
|
||||
"""Tests for the scheduled task registry handler."""
|
||||
|
||||
import json
|
||||
from datetime import datetime, timedelta
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from aipass.daemon.apps.handlers.schedule import task_registry as _mod
|
||||
|
||||
parse_due_date = _mod.parse_due_date
|
||||
create_task = _mod.create_task
|
||||
load_tasks = _mod.load_tasks
|
||||
save_tasks = _mod.save_tasks
|
||||
get_due_tasks = _mod.get_due_tasks
|
||||
mark_dispatching = _mod.mark_dispatching
|
||||
mark_completed = _mod.mark_completed
|
||||
mark_pending = _mod.mark_pending
|
||||
recover_stale_dispatches = _mod.recover_stale_dispatches
|
||||
delete_task = _mod.delete_task
|
||||
get_task_by_id = _mod.get_task_by_id
|
||||
get_pending_tasks = _mod.get_pending_tasks
|
||||
ensure_lock_dir = _mod.ensure_lock_dir
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def isolate_registry(tmp_path):
|
||||
"""Redirect SCHEDULE_JSON_PATH to a temp dir for every test."""
|
||||
test_file = tmp_path / "schedule.json"
|
||||
original = _mod.SCHEDULE_JSON_PATH
|
||||
_mod.SCHEDULE_JSON_PATH = test_file
|
||||
yield test_file
|
||||
_mod.SCHEDULE_JSON_PATH = original
|
||||
|
||||
|
||||
# =============================================
|
||||
# DATE PARSING TESTS
|
||||
# =============================================
|
||||
|
||||
|
||||
class TestParseDueDate:
|
||||
def test_days_format(self):
|
||||
"""'7d' should resolve to 7 days from today."""
|
||||
result = parse_due_date("7d")
|
||||
expected = (datetime.now().date() + timedelta(days=7)).isoformat()
|
||||
assert result == expected
|
||||
|
||||
def test_days_format_single_digit(self):
|
||||
"""'1d' should resolve to tomorrow."""
|
||||
result = parse_due_date("1d")
|
||||
expected = (datetime.now().date() + timedelta(days=1)).isoformat()
|
||||
assert result == expected
|
||||
|
||||
def test_weeks_format(self):
|
||||
"""'1w' should resolve to 1 week from today."""
|
||||
result = parse_due_date("1w")
|
||||
expected = (datetime.now().date() + timedelta(weeks=1)).isoformat()
|
||||
assert result == expected
|
||||
|
||||
def test_weeks_format_multiple(self):
|
||||
"""'2w' should resolve to 2 weeks from today."""
|
||||
result = parse_due_date("2w")
|
||||
expected = (datetime.now().date() + timedelta(weeks=2)).isoformat()
|
||||
assert result == expected
|
||||
|
||||
def test_iso_date_format(self):
|
||||
"""'2026-06-15' should pass through as-is."""
|
||||
result = parse_due_date("2026-06-15")
|
||||
assert result == "2026-06-15"
|
||||
|
||||
def test_whitespace_stripped(self):
|
||||
"""Leading/trailing whitespace should be stripped."""
|
||||
result = parse_due_date(" 7d ")
|
||||
expected = (datetime.now().date() + timedelta(days=7)).isoformat()
|
||||
assert result == expected
|
||||
|
||||
def test_case_insensitive_days(self):
|
||||
"""'7D' should work the same as '7d'."""
|
||||
result = parse_due_date("7D")
|
||||
expected = (datetime.now().date() + timedelta(days=7)).isoformat()
|
||||
assert result == expected
|
||||
|
||||
def test_case_insensitive_weeks(self):
|
||||
"""'2W' should work the same as '2w'."""
|
||||
result = parse_due_date("2W")
|
||||
expected = (datetime.now().date() + timedelta(weeks=2)).isoformat()
|
||||
assert result == expected
|
||||
|
||||
def test_invalid_format_raises(self):
|
||||
"""Unsupported format should raise ValueError."""
|
||||
with pytest.raises(ValueError, match="Invalid date format"):
|
||||
parse_due_date("next tuesday")
|
||||
|
||||
def test_invalid_iso_date_raises(self):
|
||||
"""Invalid calendar date in ISO format should raise ValueError."""
|
||||
with pytest.raises(ValueError, match="Invalid date"):
|
||||
parse_due_date("2026-02-30")
|
||||
|
||||
def test_empty_string_raises(self):
|
||||
"""Empty string should raise ValueError."""
|
||||
with pytest.raises(ValueError, match="Invalid date format"):
|
||||
parse_due_date("")
|
||||
|
||||
def test_zero_days(self):
|
||||
"""'0d' should resolve to today."""
|
||||
result = parse_due_date("0d")
|
||||
expected = datetime.now().date().isoformat()
|
||||
assert result == expected
|
||||
|
||||
|
||||
# =============================================
|
||||
# LOAD / SAVE TESTS
|
||||
# =============================================
|
||||
|
||||
|
||||
class TestLoadSave:
|
||||
def test_load_creates_file_if_missing(self, isolate_registry):
|
||||
"""load_tasks should create schedule.json if it does not exist."""
|
||||
assert not isolate_registry.exists()
|
||||
tasks = load_tasks()
|
||||
assert tasks == []
|
||||
assert isolate_registry.exists()
|
||||
|
||||
def test_load_returns_empty_on_fresh_file(self):
|
||||
"""Fresh schedule.json should have no tasks."""
|
||||
tasks = load_tasks()
|
||||
assert tasks == []
|
||||
|
||||
def test_save_and_load_roundtrip(self, isolate_registry):
|
||||
"""save_tasks then load_tasks should return the same data."""
|
||||
sample = [{"id": "abc123", "task": "test", "status": "pending"}]
|
||||
assert save_tasks(sample) is True
|
||||
loaded = load_tasks()
|
||||
assert len(loaded) == 1
|
||||
assert loaded[0]["id"] == "abc123"
|
||||
|
||||
def test_save_overwrites_existing(self, isolate_registry):
|
||||
"""Saving new tasks should fully replace existing data."""
|
||||
save_tasks([{"id": "first", "status": "pending"}])
|
||||
save_tasks([{"id": "second", "status": "pending"}])
|
||||
loaded = load_tasks()
|
||||
assert len(loaded) == 1
|
||||
assert loaded[0]["id"] == "second"
|
||||
|
||||
def test_load_handles_corrupt_json(self, isolate_registry):
|
||||
"""Corrupt JSON should return empty list, not crash."""
|
||||
isolate_registry.parent.mkdir(parents=True, exist_ok=True)
|
||||
isolate_registry.write_text("{invalid json", encoding="utf-8")
|
||||
tasks = load_tasks()
|
||||
assert tasks == []
|
||||
|
||||
|
||||
# =============================================
|
||||
# CREATE TASK TESTS
|
||||
# =============================================
|
||||
|
||||
|
||||
class TestCreateTask:
|
||||
@patch.object(_mod.json_handler, "log_operation")
|
||||
def test_create_basic(self, mock_log):
|
||||
"""Create a task and verify all fields."""
|
||||
task = create_task(
|
||||
task="Check backup health",
|
||||
due_date="7d",
|
||||
recipient="@devpulse",
|
||||
message="Verify backup systems",
|
||||
)
|
||||
assert task["task"] == "Check backup health"
|
||||
assert task["recipient"] == "@devpulse"
|
||||
assert task["message"] == "Verify backup systems"
|
||||
assert task["status"] == "pending"
|
||||
assert len(task["id"]) == 16
|
||||
assert task["id"].isalnum()
|
||||
assert task["created"] == datetime.now().date().isoformat()
|
||||
mock_log.assert_called_once_with("task_created")
|
||||
|
||||
@patch.object(_mod.json_handler, "log_operation")
|
||||
def test_create_persists_to_json(self, mock_log, isolate_registry):
|
||||
"""Created task should be saved to the JSON file."""
|
||||
create_task(
|
||||
task="persisted task",
|
||||
due_date="1d",
|
||||
recipient="@seedgo",
|
||||
message="msg",
|
||||
)
|
||||
raw = json.loads(isolate_registry.read_text(encoding="utf-8"))
|
||||
assert len(raw["tasks"]) == 1
|
||||
assert raw["tasks"][0]["task"] == "persisted task"
|
||||
|
||||
@patch.object(_mod.json_handler, "log_operation")
|
||||
def test_create_multiple_tasks(self, mock_log):
|
||||
"""Multiple tasks should accumulate in the registry."""
|
||||
create_task(task="t1", due_date="1d", recipient="@a", message="m1")
|
||||
create_task(task="t2", due_date="2d", recipient="@b", message="m2")
|
||||
tasks = load_tasks()
|
||||
assert len(tasks) == 2
|
||||
assert tasks[0]["task"] == "t1"
|
||||
assert tasks[1]["task"] == "t2"
|
||||
|
||||
def test_create_invalid_date_raises(self):
|
||||
"""create_task should propagate ValueError from bad due_date."""
|
||||
with pytest.raises(ValueError):
|
||||
create_task(task="bad", due_date="xyz", recipient="@a", message="m")
|
||||
|
||||
|
||||
# =============================================
|
||||
# DUE TASKS TESTS
|
||||
# =============================================
|
||||
|
||||
|
||||
class TestDueTasks:
|
||||
def test_overdue_task_returned(self, isolate_registry):
|
||||
"""A pending task with a past due_date should be returned."""
|
||||
yesterday = (datetime.now().date() - timedelta(days=1)).isoformat()
|
||||
save_tasks(
|
||||
[
|
||||
{
|
||||
"id": "past01",
|
||||
"due_date": yesterday,
|
||||
"status": "pending",
|
||||
"task": "overdue",
|
||||
}
|
||||
]
|
||||
)
|
||||
due = get_due_tasks()
|
||||
assert len(due) == 1
|
||||
assert due[0]["id"] == "past01"
|
||||
|
||||
def test_today_task_returned(self, isolate_registry):
|
||||
"""A pending task due today should be returned."""
|
||||
today = datetime.now().date().isoformat()
|
||||
save_tasks(
|
||||
[
|
||||
{
|
||||
"id": "today01",
|
||||
"due_date": today,
|
||||
"status": "pending",
|
||||
"task": "due today",
|
||||
}
|
||||
]
|
||||
)
|
||||
due = get_due_tasks()
|
||||
assert len(due) == 1
|
||||
assert due[0]["id"] == "today01"
|
||||
|
||||
def test_future_task_not_returned(self, isolate_registry):
|
||||
"""A pending task with a future due_date should not be returned."""
|
||||
future = (datetime.now().date() + timedelta(days=30)).isoformat()
|
||||
save_tasks(
|
||||
[
|
||||
{
|
||||
"id": "future01",
|
||||
"due_date": future,
|
||||
"status": "pending",
|
||||
"task": "future task",
|
||||
}
|
||||
]
|
||||
)
|
||||
due = get_due_tasks()
|
||||
assert len(due) == 0
|
||||
|
||||
def test_dispatching_task_excluded(self, isolate_registry):
|
||||
"""Tasks with status 'dispatching' should not be returned."""
|
||||
yesterday = (datetime.now().date() - timedelta(days=1)).isoformat()
|
||||
save_tasks(
|
||||
[
|
||||
{
|
||||
"id": "disp01",
|
||||
"due_date": yesterday,
|
||||
"status": "dispatching",
|
||||
"task": "already dispatching",
|
||||
}
|
||||
]
|
||||
)
|
||||
due = get_due_tasks()
|
||||
assert len(due) == 0
|
||||
|
||||
def test_completed_task_excluded(self, isolate_registry):
|
||||
"""Tasks with status 'completed' should not be returned."""
|
||||
yesterday = (datetime.now().date() - timedelta(days=1)).isoformat()
|
||||
save_tasks(
|
||||
[
|
||||
{
|
||||
"id": "done01",
|
||||
"due_date": yesterday,
|
||||
"status": "completed",
|
||||
"task": "done",
|
||||
}
|
||||
]
|
||||
)
|
||||
due = get_due_tasks()
|
||||
assert len(due) == 0
|
||||
|
||||
def test_empty_registry_returns_empty(self):
|
||||
"""Empty registry should return empty list."""
|
||||
due = get_due_tasks()
|
||||
assert due == []
|
||||
|
||||
|
||||
# =============================================
|
||||
# STATUS TRANSITION TESTS
|
||||
# =============================================
|
||||
|
||||
|
||||
class TestStatusTransitions:
|
||||
def _seed_task(self, task_id: str = "abc12345abcd1234", status: str = "pending"):
|
||||
"""Helper to seed a single task."""
|
||||
save_tasks(
|
||||
[
|
||||
{
|
||||
"id": task_id,
|
||||
"task": "test",
|
||||
"status": status,
|
||||
"due_date": "2026-01-01",
|
||||
}
|
||||
]
|
||||
)
|
||||
return task_id
|
||||
|
||||
def test_mark_dispatching_success(self):
|
||||
"""mark_dispatching should set status and dispatch_started."""
|
||||
tid = self._seed_task()
|
||||
assert mark_dispatching(tid) is True
|
||||
task = get_task_by_id(tid)
|
||||
assert task is not None
|
||||
assert task["status"] == "dispatching"
|
||||
assert "dispatch_started" in task
|
||||
|
||||
def test_mark_dispatching_missing(self):
|
||||
"""mark_dispatching returns False for nonexistent ID."""
|
||||
assert mark_dispatching("nonexistent_id__") is False
|
||||
|
||||
def test_mark_completed_success(self):
|
||||
"""mark_completed should set status and completed_date."""
|
||||
tid = self._seed_task()
|
||||
assert mark_completed(tid) is True
|
||||
task = get_task_by_id(tid)
|
||||
assert task is not None
|
||||
assert task["status"] == "completed"
|
||||
assert task["completed_date"] == datetime.now().date().isoformat()
|
||||
|
||||
def test_mark_completed_missing(self):
|
||||
"""mark_completed returns False for nonexistent ID."""
|
||||
assert mark_completed("nonexistent_id__") is False
|
||||
|
||||
def test_mark_pending_success(self):
|
||||
"""mark_pending should reset status and remove dispatch_started."""
|
||||
tid = self._seed_task(status="dispatching")
|
||||
# Add dispatch_started to simulate real scenario
|
||||
tasks = load_tasks()
|
||||
tasks[0]["dispatch_started"] = datetime.now().isoformat()
|
||||
save_tasks(tasks)
|
||||
|
||||
assert mark_pending(tid) is True
|
||||
task = get_task_by_id(tid)
|
||||
assert task is not None
|
||||
assert task["status"] == "pending"
|
||||
assert "dispatch_started" not in task
|
||||
|
||||
def test_mark_pending_missing(self):
|
||||
"""mark_pending returns False for nonexistent ID."""
|
||||
assert mark_pending("nonexistent_id__") is False
|
||||
|
||||
def test_full_lifecycle(self):
|
||||
"""pending -> dispatching -> completed lifecycle."""
|
||||
tid = self._seed_task()
|
||||
task = get_task_by_id(tid)
|
||||
assert task is not None
|
||||
assert task["status"] == "pending"
|
||||
|
||||
mark_dispatching(tid)
|
||||
task = get_task_by_id(tid)
|
||||
assert task is not None
|
||||
assert task["status"] == "dispatching"
|
||||
|
||||
mark_completed(tid)
|
||||
task = get_task_by_id(tid)
|
||||
assert task is not None
|
||||
assert task["status"] == "completed"
|
||||
|
||||
|
||||
# =============================================
|
||||
# RECOVER STALE DISPATCHES TESTS
|
||||
# =============================================
|
||||
|
||||
|
||||
class TestRecoverStale:
|
||||
def test_recovers_stale_task(self, isolate_registry):
|
||||
"""Task stuck in dispatching beyond max_age should be reset."""
|
||||
stale_time = (datetime.now() - timedelta(minutes=10)).isoformat()
|
||||
save_tasks(
|
||||
[
|
||||
{
|
||||
"id": "stale01",
|
||||
"task": "stale dispatch",
|
||||
"status": "dispatching",
|
||||
"dispatch_started": stale_time,
|
||||
"due_date": "2026-01-01",
|
||||
}
|
||||
]
|
||||
)
|
||||
recovered = recover_stale_dispatches(max_age_minutes=5)
|
||||
assert recovered == 1
|
||||
task = get_task_by_id("stale01")
|
||||
assert task is not None
|
||||
assert task["status"] == "pending"
|
||||
assert "dispatch_started" not in task
|
||||
|
||||
def test_does_not_recover_recent_dispatch(self, isolate_registry):
|
||||
"""Task dispatching within max_age should not be recovered."""
|
||||
recent_time = (datetime.now() - timedelta(minutes=1)).isoformat()
|
||||
save_tasks(
|
||||
[
|
||||
{
|
||||
"id": "recent01",
|
||||
"task": "recent dispatch",
|
||||
"status": "dispatching",
|
||||
"dispatch_started": recent_time,
|
||||
"due_date": "2026-01-01",
|
||||
}
|
||||
]
|
||||
)
|
||||
recovered = recover_stale_dispatches(max_age_minutes=5)
|
||||
assert recovered == 0
|
||||
task = get_task_by_id("recent01")
|
||||
assert task is not None
|
||||
assert task["status"] == "dispatching"
|
||||
|
||||
def test_recovers_invalid_timestamp(self, isolate_registry):
|
||||
"""Task with unparseable dispatch_started should be recovered."""
|
||||
save_tasks(
|
||||
[
|
||||
{
|
||||
"id": "bad_ts01",
|
||||
"task": "bad timestamp",
|
||||
"status": "dispatching",
|
||||
"dispatch_started": "not-a-date",
|
||||
"due_date": "2026-01-01",
|
||||
}
|
||||
]
|
||||
)
|
||||
recovered = recover_stale_dispatches(max_age_minutes=5)
|
||||
assert recovered == 1
|
||||
task = get_task_by_id("bad_ts01")
|
||||
assert task is not None
|
||||
assert task["status"] == "pending"
|
||||
|
||||
def test_pending_tasks_untouched(self, isolate_registry):
|
||||
"""Pending tasks should not be affected by recovery."""
|
||||
save_tasks(
|
||||
[
|
||||
{
|
||||
"id": "ok01",
|
||||
"task": "normal pending",
|
||||
"status": "pending",
|
||||
"due_date": "2026-01-01",
|
||||
}
|
||||
]
|
||||
)
|
||||
recovered = recover_stale_dispatches(max_age_minutes=5)
|
||||
assert recovered == 0
|
||||
task = get_task_by_id("ok01")
|
||||
assert task is not None
|
||||
assert task["status"] == "pending"
|
||||
|
||||
def test_empty_registry_returns_zero(self):
|
||||
"""Recovery on empty registry should return 0."""
|
||||
assert recover_stale_dispatches() == 0
|
||||
|
||||
|
||||
# =============================================
|
||||
# DELETE TASK TESTS
|
||||
# =============================================
|
||||
|
||||
|
||||
class TestDeleteTask:
|
||||
def test_delete_existing(self, isolate_registry):
|
||||
"""Deleting an existing task returns True and removes it."""
|
||||
save_tasks([{"id": "del01", "task": "to delete", "status": "pending"}])
|
||||
assert delete_task("del01") is True
|
||||
assert get_task_by_id("del01") is None
|
||||
assert load_tasks() == []
|
||||
|
||||
def test_delete_missing(self):
|
||||
"""Deleting a nonexistent task returns False."""
|
||||
assert delete_task("nonexistent_id__") is False
|
||||
|
||||
def test_delete_preserves_other_tasks(self, isolate_registry):
|
||||
"""Deleting one task should leave others intact."""
|
||||
save_tasks(
|
||||
[
|
||||
{"id": "keep01", "task": "keep this", "status": "pending"},
|
||||
{"id": "del02", "task": "delete this", "status": "pending"},
|
||||
]
|
||||
)
|
||||
delete_task("del02")
|
||||
remaining = load_tasks()
|
||||
assert len(remaining) == 1
|
||||
assert remaining[0]["id"] == "keep01"
|
||||
|
||||
def test_delete_from_empty_registry(self):
|
||||
"""Delete on empty registry should return False without error."""
|
||||
assert delete_task("anything") is False
|
||||
|
||||
|
||||
# =============================================
|
||||
# GET PENDING TASKS TESTS
|
||||
# =============================================
|
||||
|
||||
|
||||
class TestGetPendingTasks:
|
||||
"""Tests for get_pending_tasks()."""
|
||||
|
||||
def test_returns_only_pending(self, isolate_registry):
|
||||
"""Only tasks with status 'pending' are returned."""
|
||||
save_tasks(
|
||||
[
|
||||
{"id": "pend01", "task": "pending one", "status": "pending"},
|
||||
{"id": "pend02", "task": "pending two", "status": "pending"},
|
||||
{"id": "done01", "task": "done", "status": "completed"},
|
||||
]
|
||||
)
|
||||
result = get_pending_tasks()
|
||||
assert len(result) == 2
|
||||
assert all(t["status"] == "pending" for t in result)
|
||||
|
||||
def test_excludes_dispatching_and_completed(self, isolate_registry):
|
||||
"""Tasks with dispatching or completed status are excluded."""
|
||||
save_tasks(
|
||||
[
|
||||
{"id": "disp01", "task": "dispatching", "status": "dispatching"},
|
||||
{"id": "done01", "task": "completed", "status": "completed"},
|
||||
{"id": "pend01", "task": "pending", "status": "pending"},
|
||||
]
|
||||
)
|
||||
result = get_pending_tasks()
|
||||
assert len(result) == 1
|
||||
assert result[0]["id"] == "pend01"
|
||||
|
||||
def test_empty_registry_returns_empty(self):
|
||||
"""Empty registry returns empty list."""
|
||||
result = get_pending_tasks()
|
||||
assert result == []
|
||||
|
||||
|
||||
# =============================================
|
||||
# ENSURE LOCK DIR TESTS
|
||||
# =============================================
|
||||
|
||||
|
||||
class TestEnsureLockDir:
|
||||
"""Tests for ensure_lock_dir()."""
|
||||
|
||||
def test_creates_directory_if_missing(self, isolate_registry):
|
||||
"""Creates the lock directory when it does not exist."""
|
||||
lock_dir = isolate_registry.parent
|
||||
if lock_dir.exists():
|
||||
import shutil
|
||||
|
||||
shutil.rmtree(lock_dir)
|
||||
assert not lock_dir.exists()
|
||||
|
||||
result = ensure_lock_dir()
|
||||
assert lock_dir.exists()
|
||||
assert lock_dir.is_dir()
|
||||
assert result["path"] == str(lock_dir)
|
||||
|
||||
def test_returns_dict_with_path_key(self, isolate_registry):
|
||||
"""Return value is a dict containing the 'path' key."""
|
||||
result = ensure_lock_dir()
|
||||
assert isinstance(result, dict)
|
||||
assert "path" in result
|
||||
assert isinstance(result["path"], str)
|
||||
@@ -0,0 +1,150 @@
|
||||
# =================== AIPass ====================
|
||||
# Name: test_timer_install.py
|
||||
# Description: Tests for the timer_install module (systemd user timer installer)
|
||||
# Version: 1.0.0
|
||||
# Created: 2026-06-25
|
||||
# Modified: 2026-06-25
|
||||
# =============================================
|
||||
|
||||
"""Tests for the timer_install module (systemd user timer installer)."""
|
||||
|
||||
import subprocess
|
||||
from unittest.mock import patch, MagicMock
|
||||
from pathlib import Path
|
||||
|
||||
from aipass.daemon.apps.modules.timer_install import (
|
||||
handle_command,
|
||||
HANDLED_COMMANDS,
|
||||
_run_systemctl,
|
||||
_install,
|
||||
_uninstall,
|
||||
)
|
||||
|
||||
|
||||
class TestHandleCommand:
|
||||
"""Tests for command routing."""
|
||||
|
||||
def test_handles_install_timer(self):
|
||||
"""Verify install-timer is in handled commands."""
|
||||
assert "install-timer" in HANDLED_COMMANDS
|
||||
|
||||
def test_handles_uninstall_timer(self):
|
||||
"""Verify uninstall-timer is in handled commands."""
|
||||
assert "uninstall-timer" in HANDLED_COMMANDS
|
||||
|
||||
def test_rejects_unknown(self):
|
||||
"""Unknown commands return False."""
|
||||
assert handle_command("unknown", []) is False
|
||||
|
||||
def test_help_flag(self, capsys):
|
||||
"""Help flag prints usage and returns True."""
|
||||
result = handle_command("install-timer", ["--help"])
|
||||
assert result is True
|
||||
|
||||
|
||||
class TestRunSystemctl:
|
||||
"""Tests for the systemctl wrapper."""
|
||||
|
||||
@patch("subprocess.run")
|
||||
def test_success(self, mock_run):
|
||||
"""Successful systemctl returns True."""
|
||||
mock_run.return_value = MagicMock(returncode=0)
|
||||
assert _run_systemctl("status", "daemon-tick.timer") is True
|
||||
|
||||
@patch("subprocess.run")
|
||||
def test_failure_returncode(self, mock_run):
|
||||
"""Non-zero returncode returns False."""
|
||||
mock_run.return_value = MagicMock(returncode=1, stderr="unit not found")
|
||||
assert _run_systemctl("start", "daemon-tick.timer") is False
|
||||
|
||||
@patch("subprocess.run", side_effect=FileNotFoundError)
|
||||
def test_systemctl_not_found(self, mock_run):
|
||||
"""Missing systemctl returns False."""
|
||||
assert _run_systemctl("status", "daemon-tick.timer") is False
|
||||
|
||||
@patch(
|
||||
"subprocess.run",
|
||||
side_effect=subprocess.TimeoutExpired(cmd="systemctl", timeout=15),
|
||||
)
|
||||
def test_timeout(self, mock_run):
|
||||
"""Timed-out systemctl returns False."""
|
||||
assert _run_systemctl("status", "daemon-tick.timer") is False
|
||||
|
||||
|
||||
class TestInstall:
|
||||
"""Tests for the install flow."""
|
||||
|
||||
def test_install_missing_unit_file(self):
|
||||
"""Returns 1 when unit files are missing."""
|
||||
with patch(
|
||||
"aipass.daemon.apps.modules.timer_install._DAEMON_ROOT",
|
||||
Path("/nonexistent"),
|
||||
):
|
||||
result = _install()
|
||||
assert result == 1
|
||||
|
||||
@patch("aipass.daemon.apps.modules.timer_install._run_systemctl", return_value=True)
|
||||
@patch("shutil.copy2")
|
||||
def test_install_success(self, mock_copy, mock_systemctl, tmp_path):
|
||||
"""Successful install copies files and calls systemctl 3 times."""
|
||||
service = tmp_path / "daemon-tick.service"
|
||||
timer = tmp_path / "daemon-tick.timer"
|
||||
service.write_text("[Unit]\n")
|
||||
timer.write_text("[Unit]\n")
|
||||
|
||||
install_dir = tmp_path / "systemd"
|
||||
install_dir.mkdir()
|
||||
|
||||
with (
|
||||
patch("aipass.daemon.apps.modules.timer_install._DAEMON_ROOT", tmp_path),
|
||||
patch("aipass.daemon.apps.modules.timer_install._UNIT_DIR", install_dir),
|
||||
):
|
||||
result = _install()
|
||||
assert result == 0
|
||||
assert mock_systemctl.call_count == 3
|
||||
|
||||
@patch("aipass.daemon.apps.modules.timer_install._run_systemctl")
|
||||
@patch("shutil.copy2")
|
||||
def test_install_systemctl_fails(self, mock_copy, mock_systemctl, tmp_path):
|
||||
"""Returns 1 when systemctl fails."""
|
||||
service = tmp_path / "daemon-tick.service"
|
||||
timer = tmp_path / "daemon-tick.timer"
|
||||
service.write_text("[Unit]\n")
|
||||
timer.write_text("[Unit]\n")
|
||||
|
||||
install_dir = tmp_path / "systemd"
|
||||
install_dir.mkdir()
|
||||
|
||||
mock_systemctl.return_value = False
|
||||
|
||||
with (
|
||||
patch("aipass.daemon.apps.modules.timer_install._DAEMON_ROOT", tmp_path),
|
||||
patch("aipass.daemon.apps.modules.timer_install._UNIT_DIR", install_dir),
|
||||
):
|
||||
result = _install()
|
||||
assert result == 1
|
||||
|
||||
|
||||
class TestUninstall:
|
||||
"""Tests for the uninstall flow."""
|
||||
|
||||
@patch("aipass.daemon.apps.modules.timer_install._run_systemctl", return_value=True)
|
||||
def test_uninstall_files_not_present(self, mock_systemctl, tmp_path):
|
||||
"""Returns 0 even when unit files are already absent."""
|
||||
with patch("aipass.daemon.apps.modules.timer_install._UNIT_DIR", tmp_path):
|
||||
result = _uninstall()
|
||||
assert result == 0
|
||||
|
||||
@patch("aipass.daemon.apps.modules.timer_install._run_systemctl", return_value=True)
|
||||
def test_uninstall_removes_files(self, mock_systemctl, tmp_path):
|
||||
"""Removes unit files from the target directory."""
|
||||
service = tmp_path / "daemon-tick.service"
|
||||
timer = tmp_path / "daemon-tick.timer"
|
||||
service.write_text("[Unit]\n")
|
||||
timer.write_text("[Unit]\n")
|
||||
|
||||
with patch("aipass.daemon.apps.modules.timer_install._UNIT_DIR", tmp_path):
|
||||
result = _uninstall()
|
||||
assert result == 0
|
||||
assert not service.exists()
|
||||
assert not timer.exists()
|
||||
@@ -6,6 +6,12 @@ Breadcrumbs only — details in README, `--help`, `.trinity/`, `DASHBOARD.local.
|
||||
|
||||
DEVPULSE — the user's primary collaborator, orchestration hub. Design, plan, debug, dispatch, track. Build own modules (watchdog, feedback), DPLANs, FPLANs, memories. Venture into other branches to investigate, debug, fix small bugs. Delegate heavy multi-file builds to sub-agents. CWD is identity grounding.
|
||||
|
||||
# Memory entry limits — hook-enforced, over-limit = whole edit REJECTED
|
||||
|
||||
- The caps are NOT listed here (they'd go stale). Single source: @memory's `memory.config.json → entry_limits`, auto-rendered into each file's `*_meta` line (e.g. `todos_meta: … task ≤150 chars`). **Read the `*_meta` line of the section you're writing — the live cap is right there.**
|
||||
- **Draft to ~80% of the cap** — never write at the ceiling. Unsure? `echo -n 'text' | wc -c` first.
|
||||
- If rejected anyway: **rewrite hard in ONE pass** (cut to ~80%), never shave a few chars per retry.
|
||||
|
||||
# How you work
|
||||
|
||||
- Build own directly: modules, DPLANs, FPLANs, memories — edit freely.
|
||||
|
||||
@@ -8,11 +8,14 @@
|
||||
{
|
||||
"file": "apps/drone.py",
|
||||
"standard": "cli",
|
||||
"lines": [
|
||||
166,
|
||||
168
|
||||
],
|
||||
"reason": "Raw passthrough of module subprocess stdout/stderr — console.print() breaks routed output"
|
||||
"lines": [244, 246, 360, 362, 479, 481, 585, 587],
|
||||
"reason": "Raw passthrough of routed command stdout/stderr — console.print() wraps at 80 cols when piped, corrupting JSON and machine output"
|
||||
},
|
||||
{
|
||||
"file": "apps/modules/router.py",
|
||||
"standard": "cli",
|
||||
"lines": [62],
|
||||
"reason": "Raw passthrough of routed command stdout — console.print() wraps at 80 cols when piped, corrupting JSON and machine output"
|
||||
},
|
||||
{
|
||||
"file": "apps/handlers/scanning/formatters.py",
|
||||
@@ -252,25 +255,25 @@
|
||||
{
|
||||
"file": "apps/handlers/command_registry/ops.py",
|
||||
"standard": "unused_function",
|
||||
"lines": [232, 263],
|
||||
"functions": ["update_command", "command_exists"],
|
||||
"reason": "Public CRUD API surface (update_command, command_exists) — tested, available for programmatic use. No CLI subcommand wired yet."
|
||||
},
|
||||
{
|
||||
"file": "apps/handlers/git/pr_handler.py",
|
||||
"standard": "unused_function",
|
||||
"lines": [108],
|
||||
"functions": ["create_pr"],
|
||||
"reason": "create_pr() is deprecated per FPLAN-0210 — pr command blocked at auth tier. Handler kept for backwards compatibility; still tested in test_git_module.py."
|
||||
},
|
||||
{
|
||||
"file": "apps/modules/git_module.py",
|
||||
"standard": "unused_function",
|
||||
"lines": [665],
|
||||
"functions": ["get_introspective"],
|
||||
"reason": "get_introspective() called dynamically via getattr() by module_registry_handler.py:219 for internal module introspection. Also tested in test_git_module, test_system_pr, test_devpulse_plugins, test_git_access."
|
||||
},
|
||||
{
|
||||
"file": "apps/handlers/broker/daemon.py",
|
||||
"standard": "unused_function",
|
||||
"lines": [422],
|
||||
"functions": ["start_background"],
|
||||
"reason": "Threaded broker entrypoint, exercised by tests/test_broker.py; production uses blocking start(); intentionally not called in shipped non-test code."
|
||||
}
|
||||
],
|
||||
|
||||
@@ -241,9 +241,9 @@ def _handle_module(name: str, args: List[str]) -> int:
|
||||
return 1
|
||||
|
||||
if result.get("stdout"):
|
||||
console.print(result["stdout"], end="", highlight=False, markup=False)
|
||||
sys.stdout.write(result["stdout"])
|
||||
if result.get("stderr"):
|
||||
err_console.print(result["stderr"], end="", highlight=False, markup=False)
|
||||
sys.stderr.write(result["stderr"])
|
||||
return result.get("exit_code", 0)
|
||||
|
||||
|
||||
@@ -357,9 +357,9 @@ def _handle_custom_command(args: list[str]) -> int:
|
||||
return 1
|
||||
|
||||
if result.stdout:
|
||||
console.print(result.stdout, end="", highlight=False)
|
||||
sys.stdout.write(result.stdout)
|
||||
if result.stderr:
|
||||
err_console.print(result.stderr, end="", highlight=False)
|
||||
sys.stderr.write(result.stderr)
|
||||
return result.exit_code
|
||||
|
||||
|
||||
@@ -476,9 +476,9 @@ def _handle_target(args: List[str]) -> int:
|
||||
return 1
|
||||
|
||||
if result.stdout:
|
||||
console.print(result.stdout, end="", highlight=False)
|
||||
sys.stdout.write(result.stdout)
|
||||
if result.stderr:
|
||||
err_console.print(result.stderr, end="", highlight=False)
|
||||
sys.stderr.write(result.stderr)
|
||||
return result.exit_code
|
||||
|
||||
|
||||
@@ -582,9 +582,9 @@ def main() -> int:
|
||||
return 1
|
||||
if isinstance(result, dict):
|
||||
if result.get("stdout"):
|
||||
console.print(result["stdout"], end="", highlight=False)
|
||||
sys.stdout.write(result["stdout"])
|
||||
if result.get("stderr"):
|
||||
err_console.print(result["stderr"], end="", highlight=False)
|
||||
sys.stderr.write(result["stderr"])
|
||||
return result.get("exit_code", 0)
|
||||
return 0 if result else 1
|
||||
|
||||
|
||||
@@ -41,13 +41,15 @@ def _find_real_caller():
|
||||
return None, None
|
||||
|
||||
|
||||
_BRANCH_ROOT = str(Path(__file__).resolve().parents[2])
|
||||
|
||||
|
||||
def _extract_branch_name(filepath: str) -> str:
|
||||
"""Extract branch name from a file path."""
|
||||
parts = Path(filepath).parts
|
||||
for i, part in enumerate(parts):
|
||||
if part == "aipass":
|
||||
if i + 1 < len(parts):
|
||||
return parts[i + 1]
|
||||
"""Extract branch name from a file path by walking up to .trinity/."""
|
||||
path = Path(filepath)
|
||||
for parent in [path, *path.parents]:
|
||||
if (parent / ".trinity").is_dir():
|
||||
return parent.name
|
||||
return "unknown"
|
||||
|
||||
|
||||
@@ -60,14 +62,13 @@ def _guard_branch_access():
|
||||
"""
|
||||
caller_file, import_line = _find_real_caller()
|
||||
|
||||
# DEBUG: Print what we found
|
||||
import os
|
||||
|
||||
if os.environ.get("AIPASS_DEBUG_GUARD"):
|
||||
import sys
|
||||
|
||||
print(f"[GUARD DEBUG] caller_file = {caller_file}", file=sys.stderr)
|
||||
print(f"[GUARD DEBUG] import_line = {import_line}", file=sys.stderr)
|
||||
sys.stderr.write(f"[GUARD DEBUG] caller_file = {caller_file}\n")
|
||||
sys.stderr.write(f"[GUARD DEBUG] import_line = {import_line}\n")
|
||||
|
||||
if caller_file is None:
|
||||
# Can't determine caller from real files
|
||||
@@ -79,10 +80,7 @@ def _guard_branch_access():
|
||||
return # Allow command-line Python through
|
||||
return # Allow if truly can't determine
|
||||
|
||||
# Check if caller is from our branch
|
||||
# MY_BRANCH is "aipass.drone" (dotted), but filesystem uses "/aipass/drone/"
|
||||
branch_path = "/" + MY_BRANCH.replace(".", "/") + "/"
|
||||
if branch_path in caller_file.replace("\\", "/"):
|
||||
if Path(caller_file).is_relative_to(_BRANCH_ROOT):
|
||||
return # Same branch, allowed
|
||||
|
||||
# External caller - block access
|
||||
@@ -90,6 +88,7 @@ def _guard_branch_access():
|
||||
caller_filename = Path(caller_file).name
|
||||
blocked_import = import_line if import_line else "unknown"
|
||||
|
||||
module_api = f"{MY_BRANCH}.apps.modules"
|
||||
raise ImportError(
|
||||
f"\n{'=' * 60}\n"
|
||||
f"ACCESS DENIED: Cross-branch handler import blocked\n"
|
||||
@@ -99,11 +98,7 @@ def _guard_branch_access():
|
||||
f" Blocked: {blocked_import}\n"
|
||||
f"\n"
|
||||
f" Handlers are internal to their branch.\n"
|
||||
f" Use the module API instead:\n"
|
||||
f" from {MY_BRANCH}.apps.modules.<module> import <function>\n"
|
||||
f"\n"
|
||||
f" Example:\n"
|
||||
f" from {MY_BRANCH}.apps.modules.logger import logger\n"
|
||||
f" Use the module API instead: {module_api}.<module>\n"
|
||||
f"\n"
|
||||
f" For full standards guide:\n"
|
||||
f" drone @seedgo handlers\n"
|
||||
|
||||
@@ -27,6 +27,7 @@ from __future__ import annotations
|
||||
import hashlib
|
||||
import hmac as hmac_mod
|
||||
import json
|
||||
import os
|
||||
import secrets
|
||||
import socket
|
||||
import threading
|
||||
@@ -50,8 +51,6 @@ _TMP_BASES = (Path("/tmp"), Path("/var/tmp"))
|
||||
|
||||
def _find_project_root() -> Path | None:
|
||||
"""Walk up from CWD to find *_REGISTRY.json; return its parent as project root."""
|
||||
import os
|
||||
|
||||
cwd = Path.cwd()
|
||||
for parent in [cwd, *cwd.parents]:
|
||||
if list(parent.glob("*_REGISTRY.json")):
|
||||
@@ -166,11 +165,24 @@ class BrokerDaemon:
|
||||
if identity == "devpulse":
|
||||
bases.append(self._repo_root)
|
||||
return bases
|
||||
branch_dir = self._repo_root / "src" / "aipass" / identity
|
||||
if branch_dir.is_dir():
|
||||
branch_dir = self._resolve_branch_dir(identity)
|
||||
if branch_dir and branch_dir.is_dir():
|
||||
bases.append(branch_dir)
|
||||
return bases
|
||||
|
||||
def _resolve_branch_dir(self, identity: str) -> Path | None:
|
||||
"""Find a branch directory by name via .trinity/ marker walk."""
|
||||
if self._repo_root is None:
|
||||
return None
|
||||
for root, dirs, _files in os.walk(self._repo_root):
|
||||
depth = len(Path(root).relative_to(self._repo_root).parts)
|
||||
if depth > 3:
|
||||
dirs.clear()
|
||||
continue
|
||||
if Path(root).name == identity and (Path(root) / ".trinity").is_dir():
|
||||
return Path(root).resolve()
|
||||
return None
|
||||
|
||||
def _handle_identify(self, req: BrokerRequest) -> tuple[BrokerResponse, str | None]:
|
||||
"""Verify HMAC and bind identity to the connection."""
|
||||
audit_entry: dict = {
|
||||
|
||||
@@ -18,6 +18,18 @@ from aipass.drone.apps.handlers.json import json_handler
|
||||
from aipass.drone.apps.handlers.git.lock_handler import find_repo_root
|
||||
|
||||
|
||||
def _find_branch_for_path(filepath: str, repo_root: Path) -> tuple[str, Path] | None:
|
||||
"""Find which branch a changed file belongs to by walking up to .trinity/."""
|
||||
abs_path = (repo_root / filepath).resolve()
|
||||
root = repo_root.resolve()
|
||||
for parent in [abs_path.parent, *abs_path.parent.parents]:
|
||||
if not parent.is_relative_to(root):
|
||||
break
|
||||
if (parent / ".trinity").is_dir():
|
||||
return parent.name, parent
|
||||
return None
|
||||
|
||||
|
||||
def _run_test_gate(repo_root: Path) -> dict | None:
|
||||
"""Run pytest for changed branches. Returns error dict if tests fail, None if all pass."""
|
||||
status_result = subprocess.run(
|
||||
@@ -26,21 +38,22 @@ def _run_test_gate(repo_root: Path) -> dict | None:
|
||||
text=True,
|
||||
cwd=str(repo_root),
|
||||
)
|
||||
changed_branches: set[str] = set()
|
||||
changed_branches: dict[str, Path] = {}
|
||||
for line in status_result.stdout.splitlines():
|
||||
if len(line) < 4:
|
||||
continue
|
||||
filepath = line[3:].split(" -> ")[-1]
|
||||
parts = Path(filepath).parts
|
||||
if len(parts) >= 3 and parts[0] == "src" and parts[1] == "aipass":
|
||||
changed_branches.add(parts[2])
|
||||
result = _find_branch_for_path(filepath, repo_root)
|
||||
if result:
|
||||
name, branch_path = result
|
||||
changed_branches[name] = branch_path
|
||||
|
||||
venv_python = repo_root / ".venv" / "bin" / "python"
|
||||
python_bin = str(venv_python) if venv_python.exists() else "python3"
|
||||
|
||||
failed_branches: list[tuple[str, str]] = []
|
||||
for branch_name in sorted(changed_branches):
|
||||
test_dir = repo_root / "src" / "aipass" / branch_name / "tests"
|
||||
test_dir = changed_branches[branch_name] / "tests"
|
||||
if not test_dir.is_dir():
|
||||
continue
|
||||
try:
|
||||
|
||||
@@ -144,13 +144,16 @@ def find_registry() -> Path:
|
||||
if hit is not None:
|
||||
return hit
|
||||
|
||||
# Fallback — use package-relative path; glob there too
|
||||
fallback_dir = Path(__file__).resolve().parents[4]
|
||||
# Fallback — walk up from this file to the project root (marker-based)
|
||||
_markers = (".git", "pyproject.toml", "setup.py", "setup.cfg")
|
||||
fallback_dir = Path(__file__).resolve().parent
|
||||
for parent in [fallback_dir, *fallback_dir.parents]:
|
||||
if any((parent / m).exists() for m in _markers):
|
||||
fallback_dir = parent
|
||||
break
|
||||
hit = _first_registry_in(fallback_dir)
|
||||
if hit is not None:
|
||||
return hit
|
||||
# Ultimate fallback: return a conventional name so the caller
|
||||
# gets a clear "not found" path in the error message.
|
||||
return fallback_dir / "AIPASS_REGISTRY.json"
|
||||
|
||||
|
||||
|
||||
@@ -67,16 +67,23 @@ def get_allowed_roots() -> list[Path]:
|
||||
return roots
|
||||
|
||||
|
||||
def _find_branch_root(path: Path, project_root: Path) -> Path | None:
|
||||
"""Walk up from *path* looking for .trinity/; return branch dir or None."""
|
||||
root = project_root.resolve()
|
||||
for parent in [path, *path.parents]:
|
||||
if not parent.is_relative_to(root):
|
||||
break
|
||||
if (parent / ".trinity").is_dir():
|
||||
return parent
|
||||
return None
|
||||
|
||||
|
||||
def _detect_current_branch(project_root: Path | None) -> str | None:
|
||||
"""Return the branch name the CWD lives in, or None."""
|
||||
if project_root is None:
|
||||
return None
|
||||
cwd = Path.cwd().resolve()
|
||||
aipass_src = project_root / "src" / "aipass"
|
||||
if not cwd.is_relative_to(aipass_src):
|
||||
return None
|
||||
rel = cwd.relative_to(aipass_src)
|
||||
return rel.parts[0] if rel.parts else None
|
||||
branch_root = _find_branch_root(Path.cwd().resolve(), project_root)
|
||||
return branch_root.name if branch_root else None
|
||||
|
||||
|
||||
def _resolve_git_dir(path: Path) -> Path | None:
|
||||
@@ -109,14 +116,11 @@ def check_carveouts(resolved: Path, project_root: Path | None) -> tuple[bool, st
|
||||
return True, "Protected: path resolves inside .git worktree gitdir"
|
||||
|
||||
if project_root is not None:
|
||||
aipass_src = project_root / "src" / "aipass"
|
||||
if resolved.is_relative_to(aipass_src):
|
||||
rel = resolved.relative_to(aipass_src)
|
||||
if rel.parts:
|
||||
target_branch = rel.parts[0]
|
||||
current_branch = _detect_current_branch(project_root)
|
||||
if current_branch is None or target_branch != current_branch:
|
||||
return True, (f"Protected: path is inside sibling branch src/aipass/{target_branch}/")
|
||||
target_branch_root = _find_branch_root(resolved, project_root)
|
||||
if target_branch_root is not None:
|
||||
current_branch = _detect_current_branch(project_root)
|
||||
if current_branch is None or target_branch_root.name != current_branch:
|
||||
return True, f"Protected: path is inside sibling branch {target_branch_root.name}/"
|
||||
|
||||
return False, ""
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ to the handler layer.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
@@ -58,7 +59,7 @@ def handle_command(command: Optional[str] = None, args: Optional[List[str]] = No
|
||||
cmd_args = args[2:] if len(args) > 2 else None
|
||||
result = route_command(target, cmd, args=cmd_args)
|
||||
if result.stdout:
|
||||
console.print(result.stdout, highlight=False)
|
||||
sys.stdout.write(result.stdout)
|
||||
return result.exit_code == 0
|
||||
if command == "route_all":
|
||||
if not args:
|
||||
@@ -132,7 +133,8 @@ def print_introspection():
|
||||
console.print("[yellow]Connected Handlers:[/yellow]")
|
||||
console.print(" [cyan]handlers/[/cyan]")
|
||||
console.print(
|
||||
" - [cyan]router_handler.py[/cyan] [dim](execute_branch_command — resolves and executes branch commands)[/dim]"
|
||||
" - [cyan]router_handler.py[/cyan] "
|
||||
"[dim](execute_branch_command — resolves and executes branch commands)[/dim]"
|
||||
)
|
||||
console.print(" - [cyan]executor.py[/cyan] [dim](CommandResult — subprocess execution result dataclass)[/dim]")
|
||||
console.print()
|
||||
|
||||
@@ -90,11 +90,12 @@ def _send_identified(broker: BrokerDaemon, branch: str, req: BrokerRequest) -> B
|
||||
|
||||
@pytest.fixture()
|
||||
def repo_root(tmp_path: Path) -> Path:
|
||||
"""Set up a mock repo root with branch directories."""
|
||||
"""Set up a mock repo root with branch directories marked by .trinity/."""
|
||||
root = tmp_path / "repo"
|
||||
root.mkdir()
|
||||
branch = root / "src" / "aipass" / "testbranch"
|
||||
branch.mkdir(parents=True)
|
||||
(branch / ".trinity").mkdir()
|
||||
(branch / "deleteme.txt").write_text("delete me", encoding="utf-8")
|
||||
(branch / "subdir").mkdir()
|
||||
(branch / "subdir" / "nested.txt").write_text("nested", encoding="utf-8")
|
||||
@@ -102,6 +103,7 @@ def repo_root(tmp_path: Path) -> Path:
|
||||
(branch / ".git" / "HEAD").write_text("ref: refs/heads/main", encoding="utf-8")
|
||||
sibling = root / "src" / "aipass" / "sibling"
|
||||
sibling.mkdir(parents=True)
|
||||
(sibling / ".trinity").mkdir()
|
||||
(sibling / "important.txt").write_text("don't delete", encoding="utf-8")
|
||||
return root
|
||||
|
||||
|
||||
@@ -387,8 +387,11 @@ class TestCommitChanges:
|
||||
stderr="",
|
||||
)
|
||||
|
||||
test_dir = repo_dir / "src" / "aipass" / "drone" / "tests"
|
||||
test_dir.mkdir(parents=True)
|
||||
drone_dir = repo_dir / "src" / "aipass" / "drone"
|
||||
drone_dir.mkdir(parents=True)
|
||||
(drone_dir / ".trinity").mkdir()
|
||||
test_dir = drone_dir / "tests"
|
||||
test_dir.mkdir()
|
||||
|
||||
with (
|
||||
patch("shutil.which", return_value="/usr/bin/ruff"),
|
||||
@@ -417,8 +420,11 @@ class TestCommitChanges:
|
||||
mock_diff = MagicMock(returncode=1, stdout="", stderr="")
|
||||
mock_commit = MagicMock(returncode=0, stdout="[main abc999] green commit", stderr="")
|
||||
|
||||
test_dir = repo_dir / "src" / "aipass" / "drone" / "tests"
|
||||
test_dir.mkdir(parents=True)
|
||||
drone_dir = repo_dir / "src" / "aipass" / "drone"
|
||||
drone_dir.mkdir(parents=True)
|
||||
(drone_dir / ".trinity").mkdir()
|
||||
test_dir = drone_dir / "tests"
|
||||
test_dir.mkdir()
|
||||
|
||||
with (
|
||||
patch("shutil.which", return_value="/usr/bin/ruff"),
|
||||
|
||||
@@ -36,10 +36,11 @@ def project_dir(tmp_path):
|
||||
|
||||
@pytest.fixture()
|
||||
def project_with_branches(project_dir):
|
||||
"""Project root with src/aipass/<branch> layout for sibling tests."""
|
||||
"""Project root with branch dirs containing .trinity/ markers."""
|
||||
for branch in ("drone", "api", "flow"):
|
||||
d = project_dir / "src" / "aipass" / branch
|
||||
d.mkdir(parents=True)
|
||||
(d / ".trinity").mkdir()
|
||||
(d / "README.md").write_text(f"# {branch}")
|
||||
return project_dir
|
||||
|
||||
|
||||
@@ -146,6 +146,8 @@ On `drone @flow close`:
|
||||
|
||||
Vector verification displays in console: "Vectorized: N chunks in chroma" or "NOT vectorized".
|
||||
|
||||
Closed plans are archived to `<repo-root>/.backup/processed_plans/`, a shared runtime namespace managed by `@backup` (see `src/aipass/backup/README.md`) and consumed by `@memory` for vectorization.
|
||||
|
||||
---
|
||||
|
||||
## Integration Points
|
||||
|
||||
@@ -869,6 +869,31 @@
|
||||
"standard": "unused_function",
|
||||
"reason": "sandbox_launch(), build_policy(), build_srt_config(), and resolve_bwrap_command() are consumed CROSS-BRANCH by ai_mail's dispatch_monitor.py (the launch seam at Phase 4). seedgo's intra-branch static analysis cannot see these callers. Verified: dispatch_monitor imports sandbox module to wire build_policy + sandbox_launch at agent launch."
|
||||
},
|
||||
{
|
||||
"file": "apps/handlers/security/presence_gate.py",
|
||||
"standard": "dead_code",
|
||||
"reason": "Invoked dynamically by engine via importlib from hooks.json handler path 'aipass.hooks.apps.handlers.security.presence_gate.handle' — not statically imported by design. Wired in UserPromptSubmit.presence_gate + Stop.presence_release."
|
||||
},
|
||||
{
|
||||
"file": "apps/handlers/security/presence_gate.py",
|
||||
"standard": "unused_function",
|
||||
"reason": "handle() and handle_stop() called dynamically by engine._run_handler via importlib.import_module + getattr from hooks.json. Wired in UserPromptSubmit.presence_gate and Stop.presence_release."
|
||||
},
|
||||
{
|
||||
"file": "apps/handlers/security/presence_gate.py",
|
||||
"standard": "json_structure",
|
||||
"reason": "Security gate uses stdlib json.dumps for hook protocol block responses — no JSON file ops needing json_handler."
|
||||
},
|
||||
{
|
||||
"file": "apps/modules/presence.py",
|
||||
"standard": "json_structure",
|
||||
"reason": "Presence service uses stdlib json for .ai_central/PRESENCE.central.json — shared runtime namespace, not branch json_handler storage."
|
||||
},
|
||||
{
|
||||
"file": "apps/modules/presence.py",
|
||||
"standard": "unused_function",
|
||||
"reason": "claim(), release(), refresh(), read_all() consumed by presence_gate.py handler (dynamically dispatched via engine). Static analysis cannot trace hooks.json → engine → handler → presence module."
|
||||
},
|
||||
{
|
||||
"file": "apps/handlers/notification/telegram_response.py",
|
||||
"standard": "unused_function",
|
||||
@@ -898,6 +923,141 @@
|
||||
"file": "tests/test_telegram_response.py",
|
||||
"standard": "meta",
|
||||
"reason": "Test files do not need Version/Modified metadata headers."
|
||||
},
|
||||
{
|
||||
"file": "apps/modules/presence.py",
|
||||
"standard": "json_structure",
|
||||
"reason": "Uses stdlib json for .ai_central/PRESENCE.central.json shared runtime namespace — not branch data storage needing json_handler."
|
||||
},
|
||||
{
|
||||
"file": "apps/modules/presence.py",
|
||||
"standard": "modules",
|
||||
"reason": "Direct file ops on .ai_central/PRESENCE.central.json — a shared runtime namespace (gitignored, cross-branch). Not a handler-level concern; the module IS the presence service that owns this file."
|
||||
},
|
||||
{
|
||||
"file": "apps/modules/presence.py",
|
||||
"standard": "dead_code",
|
||||
"reason": "claim(), release(), refresh(), read_all() consumed by presence_gate.py handler (dynamically dispatched via engine). handle_command() and print_introspection() called by drone routing. Static analysis cannot trace hooks.json → engine → handler → presence module."
|
||||
},
|
||||
{
|
||||
"file": "apps/modules/presence.py",
|
||||
"standard": "unused_function",
|
||||
"reason": "claim(), release(), refresh(), read_all() consumed by presence_gate.py handler (dynamically dispatched via engine). handle_command() and print_introspection() called by drone routing."
|
||||
},
|
||||
{
|
||||
"file": "apps/handlers/security/presence_gate.py",
|
||||
"standard": "dead_code",
|
||||
"reason": "Invoked dynamically by engine via importlib from hooks.json handler path 'aipass.hooks.apps.handlers.security.presence_gate.handle' — not statically imported by design. Wired in UserPromptSubmit.presence_gate + Stop.presence_release."
|
||||
},
|
||||
{
|
||||
"file": "apps/handlers/security/presence_gate.py",
|
||||
"standard": "unused_function",
|
||||
"reason": "handle() and handle_stop() called dynamically by engine._run_handler via importlib.import_module + getattr from hooks.json. Wired in UserPromptSubmit.presence_gate + Stop.presence_release."
|
||||
},
|
||||
{
|
||||
"file": "apps/handlers/security/presence_gate.py",
|
||||
"standard": "json_structure",
|
||||
"reason": "Security gate uses stdlib json.dumps for hook protocol block responses — no JSON file ops needing json_handler."
|
||||
},
|
||||
{
|
||||
"file": "tests/test_presence.py",
|
||||
"standard": "architecture",
|
||||
"reason": "Test files live in tests/, not in the 3-layer apps structure."
|
||||
},
|
||||
{
|
||||
"file": "tests/test_presence.py",
|
||||
"standard": "documentation",
|
||||
"reason": "Test methods use descriptive names as documentation per pytest convention."
|
||||
},
|
||||
{
|
||||
"file": "tests/test_presence.py",
|
||||
"standard": "encapsulation",
|
||||
"reason": "Tests import modules directly to test implementation details."
|
||||
},
|
||||
{
|
||||
"file": "tests/test_presence.py",
|
||||
"standard": "meta",
|
||||
"reason": "Test files do not need Version/Modified metadata headers."
|
||||
},
|
||||
{
|
||||
"file": "tests/test_presence_gate.py",
|
||||
"standard": "architecture",
|
||||
"reason": "Test files live in tests/, not in the 3-layer apps structure."
|
||||
},
|
||||
{
|
||||
"file": "tests/test_presence_gate.py",
|
||||
"standard": "documentation",
|
||||
"reason": "Test methods use descriptive names as documentation per pytest convention."
|
||||
},
|
||||
{
|
||||
"file": "tests/test_presence_gate.py",
|
||||
"standard": "encapsulation",
|
||||
"reason": "Tests import handlers directly to test implementation details."
|
||||
},
|
||||
{
|
||||
"file": "tests/test_presence_gate.py",
|
||||
"standard": "meta",
|
||||
"reason": "Test files do not need Version/Modified metadata headers."
|
||||
},
|
||||
{
|
||||
"file": "apps/modules/cc_sessions.py",
|
||||
"standard": "json_structure",
|
||||
"reason": "Reads CC-native ~/.claude/sessions/*.json (external files, not branch data) with stdlib json — no JSON file ops needing json_handler."
|
||||
},
|
||||
{
|
||||
"file": "apps/modules/cc_sessions.py",
|
||||
"standard": "modules",
|
||||
"reason": "Direct file ops on ~/.claude/sessions/*.json — external CC-native session files, not branch data. The module IS the CC session reader service; json_handler is for branch-managed JSON, not external platform files."
|
||||
},
|
||||
{
|
||||
"file": "apps/handlers/lifecycle/session_boot.py",
|
||||
"standard": "json_structure",
|
||||
"reason": "Boot wrapper has no JSON file ops — delegates to cc_sessions module for session reads. No json_handler needed."
|
||||
},
|
||||
{
|
||||
"file": "apps/handlers/lifecycle/session_boot.py",
|
||||
"standard": "dead_code",
|
||||
"reason": "Boot wrapper invoked via __main__ entry point from shell shim (tools/install_boot_shim.sh) — not engine-dispatched. boot() and main() called at process entry, not statically imported."
|
||||
},
|
||||
{
|
||||
"file": "apps/handlers/lifecycle/session_boot.py",
|
||||
"standard": "unused_function",
|
||||
"reason": "boot() and main() invoked via __main__ entry point from shell shim — not statically imported. _resolve_claude_binary(), _find_live_sessions(), _find_tmux(), etc. are internal helpers called by boot()."
|
||||
},
|
||||
{
|
||||
"file": "tests/test_cc_sessions.py",
|
||||
"standard": "architecture",
|
||||
"reason": "Test files live in tests/, not in the 3-layer apps structure."
|
||||
},
|
||||
{
|
||||
"file": "tests/test_cc_sessions.py",
|
||||
"standard": "documentation",
|
||||
"reason": "Test methods use descriptive names as documentation per pytest convention."
|
||||
},
|
||||
{
|
||||
"file": "tests/test_cc_sessions.py",
|
||||
"standard": "meta",
|
||||
"reason": "Test files do not need Version/Modified metadata headers."
|
||||
},
|
||||
{
|
||||
"file": "tests/test_session_boot.py",
|
||||
"standard": "architecture",
|
||||
"reason": "Test files live in tests/, not in the 3-layer apps structure."
|
||||
},
|
||||
{
|
||||
"file": "tests/test_session_boot.py",
|
||||
"standard": "documentation",
|
||||
"reason": "Test methods use descriptive names as documentation per pytest convention."
|
||||
},
|
||||
{
|
||||
"file": "tests/test_session_boot.py",
|
||||
"standard": "meta",
|
||||
"reason": "Test files do not need Version/Modified metadata headers."
|
||||
},
|
||||
{
|
||||
"file": "tests/test_session_boot.py",
|
||||
"standard": "encapsulation",
|
||||
"reason": "Tests import handlers directly to test implementation details."
|
||||
}
|
||||
],
|
||||
"notes": {
|
||||
|
||||
@@ -49,9 +49,11 @@ src/aipass/hooks/
|
||||
│ ├── sound.py # Shared sound utilities (speak, play, mute)
|
||||
│ ├── modules/
|
||||
│ │ ├── cadence.py # Prompt injection cadence (every-Nth-turn gating)
|
||||
│ │ ├── cc_sessions.py # CC-native session file reader (~/.claude/sessions/<pid>.json)
|
||||
│ │ ├── engine.py # Core dispatch — routes events to handlers
|
||||
│ │ ├── hooksound.py # Sound control (drone @hooks hooksound on/off)
|
||||
│ │ ├── hookstatus.py # Config viewer (drone @hooks status)
|
||||
│ │ ├── presence.py # Branch presence — claim/release/refresh for .ai_central/PRESENCE.central.json
|
||||
│ │ └── sandbox.py # Kernel sandbox — srt/bwrap wrapper + per-role policy generator
|
||||
│ ├── handlers/
|
||||
│ │ ├── bridges/ # One per provider (thin normalization)
|
||||
@@ -64,6 +66,7 @@ src/aipass/hooks/
|
||||
│ │ ├── security/ # Enforcement hooks
|
||||
│ │ │ ├── edit_gate.py # Blocks unsafe edits (cross-branch, inbox, diagnostics)
|
||||
│ │ │ ├── git_gate.py # Enforces git access tiers
|
||||
│ │ │ ├── presence_gate.py # Single-session gate — blocks duplicate runtimes per branch
|
||||
│ │ │ ├── rm_gate.py # Guardrail — catches accidental rm -rf, teaches drone rm
|
||||
│ │ │ └── subagent_gate.py # Blocks sub-agent stop until clean
|
||||
│ │ ├── lifecycle/ # Session management hooks
|
||||
@@ -82,7 +85,7 @@ src/aipass/hooks/
|
||||
│ └── diagnostics.py # JSONL logging for hook execution
|
||||
├── logs/
|
||||
│ └── engine.jsonl # JSONL diagnostics (every hook execution)
|
||||
└── tests/ # 593 tests across 23 test files
|
||||
└── tests/ # 705 tests across 25 test files
|
||||
```
|
||||
|
||||
## How It Works
|
||||
@@ -103,11 +106,11 @@ Handlers are called **dynamically at runtime** — the engine uses `importlib.im
|
||||
|
||||
| Event | Hooks | Description |
|
||||
|---|---|---|
|
||||
| UserPromptSubmit | identity, email, branch_loader, tier0_kernel, navmap | Prompt injection + inbox check |
|
||||
| UserPromptSubmit | presence_gate, identity, email, branch_loader, tier0_kernel, navmap | Presence gate + prompt injection + inbox check |
|
||||
| PreToolUse | tool_sound, edit_gate, git_gate, rm_gate | Security gates + guardrails + sound |
|
||||
| PostToolUse | auto_fix, auto_watchdog | Diagnostics + watchdog |
|
||||
| SubagentStop | subagent_gate | Seedgo validation |
|
||||
| Stop | stop_sound, telegram_response | Achievement bell + Telegram reply delivery |
|
||||
| Stop | stop_sound, telegram_response, presence_release | Bell + Telegram delivery + presence release |
|
||||
| Notification | announce | Announcement tone |
|
||||
| PreCompact | compact, rollover | Memory archival + rollover |
|
||||
|
||||
@@ -151,7 +154,7 @@ The @drone broker validates sandbox policy before agent launch. @ai_mail's dispa
|
||||
- All branches via hook dispatch — every Claude Code session routes through the engine
|
||||
- @ai_mail dispatch_monitor — sandbox_launch + build_policy for agent launch boundary
|
||||
|
||||
*Last Updated: 2026-06-10*
|
||||
*Last Updated: 2026-06-29*
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -53,9 +53,11 @@ def main() -> None:
|
||||
hook_def = full_config.get(event_type, {}).get(hook_filter, {})
|
||||
config = {"hooks_enabled": True, event_type: {hook_filter: hook_def}}
|
||||
|
||||
output = dispatch(event_type, stdin_data, config)
|
||||
output, exit_code = dispatch(event_type, stdin_data, config)
|
||||
if output:
|
||||
sys.stdout.write(output)
|
||||
if exit_code:
|
||||
sys.exit(exit_code)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
# =================== AIPass ====================
|
||||
# Name: session_boot.py
|
||||
# Version: 1.1.0
|
||||
# Description: Boot wrapper — attach-if-live or start-in-tmux for Claude Code
|
||||
# Branch: hooks
|
||||
# Layer: apps/handlers/lifecycle
|
||||
# Created: 2026-06-30
|
||||
# Modified: 2026-06-30
|
||||
# =============================================
|
||||
|
||||
"""Boot wrapper for Claude Code sessions.
|
||||
|
||||
When Patrick boots Claude in a branch directory, this wrapper:
|
||||
1. If already inside tmux ($TMUX set) → execs claude directly (no nesting).
|
||||
2. Checks CC-native ~/.claude/sessions/ for a live session at this cwd.
|
||||
3. If live AND hosted in a tmux session → attaches to that tmux session.
|
||||
4. If live but NOT in tmux → warns (can't inject into a plain terminal).
|
||||
5. If no live session → starts fresh inside a new tmux session.
|
||||
|
||||
Tmux sessions are named after the branch directory (e.g., "hooks", "devpulse").
|
||||
All sessions use --permission-mode bypassPermissions (TG user can't answer prompts).
|
||||
Remote visibility is preserved (tmux composes with remoteControlAtStartup).
|
||||
|
||||
Entry points:
|
||||
drone @hooks boot [claude args...]
|
||||
"""
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from aipass.prax.apps.modules.logger import system_logger as logger
|
||||
|
||||
_DEFAULT_ARGS = ["--permission-mode", "bypassPermissions"]
|
||||
|
||||
|
||||
def _resolve_claude_binary() -> str:
|
||||
"""Resolve the REAL claude binary path from PATH.
|
||||
|
||||
Uses shutil.which, which searches the filesystem PATH — it does NOT see
|
||||
shell functions. So even when a claude() shell function shadows the binary,
|
||||
this finds the real one.
|
||||
"""
|
||||
path = shutil.which("claude")
|
||||
if path:
|
||||
return path
|
||||
logger.error("[SESSION_BOOT] claude binary not found on PATH")
|
||||
return "claude"
|
||||
|
||||
|
||||
def _find_live_sessions(cwd: str) -> list[dict]:
|
||||
"""Find live CC sessions for the given cwd via cc_sessions module."""
|
||||
import importlib
|
||||
|
||||
cc_sessions = importlib.import_module("aipass.hooks.apps.modules.cc_sessions")
|
||||
return cc_sessions.find_live_for_cwd(cwd)
|
||||
|
||||
|
||||
def _find_tmux() -> str | None:
|
||||
"""Find tmux binary on PATH."""
|
||||
return shutil.which("tmux")
|
||||
|
||||
|
||||
def _tmux_session_exists(name: str) -> bool:
|
||||
"""Check if a tmux session with the given name exists."""
|
||||
result = subprocess.run(
|
||||
["tmux", "has-session", "-t", name],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
return result.returncode == 0
|
||||
|
||||
|
||||
def _find_tmux_session_for_pid(pid: int) -> str | None:
|
||||
"""Find which tmux session hosts the given PID (as a descendant of a pane)."""
|
||||
result = subprocess.run(
|
||||
["tmux", "list-panes", "-a", "-F", "#{pane_pid} #{session_name}"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
return None
|
||||
for line in result.stdout.strip().splitlines():
|
||||
parts = line.split(None, 1)
|
||||
if len(parts) != 2:
|
||||
continue
|
||||
pane_pid_str, session_name = parts
|
||||
try:
|
||||
pane_pid = int(pane_pid_str)
|
||||
except ValueError:
|
||||
logger.info("[SESSION_BOOT] Non-integer pane PID: %s", pane_pid_str)
|
||||
continue
|
||||
if _is_descendant(pid, pane_pid):
|
||||
return session_name
|
||||
return None
|
||||
|
||||
|
||||
def _is_descendant(target_pid: int, ancestor_pid: int) -> bool:
|
||||
"""Check if target_pid is a descendant of ancestor_pid via /proc."""
|
||||
pid = target_pid
|
||||
for _ in range(20):
|
||||
if pid == ancestor_pid:
|
||||
return True
|
||||
if pid <= 1:
|
||||
return False
|
||||
try:
|
||||
for line in Path(f"/proc/{pid}/status").read_text().splitlines():
|
||||
if line.startswith("PPid:"):
|
||||
pid = int(line.split()[1])
|
||||
break
|
||||
else:
|
||||
return False
|
||||
except OSError as exc:
|
||||
logger.info("[SESSION_BOOT] Cannot read /proc/%d/status: %s", pid, exc)
|
||||
return False
|
||||
return False
|
||||
|
||||
|
||||
def boot(cwd: str | None = None, extra_args: list[str] | None = None) -> dict:
|
||||
"""Boot Claude Code — attach if live, else start in tmux.
|
||||
|
||||
Args:
|
||||
cwd: Branch directory (defaults to current working directory).
|
||||
extra_args: Additional arguments to pass to claude (after default args).
|
||||
|
||||
Returns:
|
||||
Result dict with action taken and details.
|
||||
"""
|
||||
cwd = cwd or str(Path.cwd().resolve())
|
||||
branch = Path(cwd).name
|
||||
claude_bin = _resolve_claude_binary()
|
||||
|
||||
if os.environ.get("TMUX"):
|
||||
logger.info("[SESSION_BOOT] Already inside tmux — running claude directly")
|
||||
claude_cmd = [claude_bin] + _DEFAULT_ARGS
|
||||
if extra_args:
|
||||
claude_cmd.extend(extra_args)
|
||||
os.execvp(claude_bin, claude_cmd)
|
||||
return {"exit_code": 0, "action": "direct", "reason": "already in tmux"}
|
||||
|
||||
tmux = _find_tmux()
|
||||
if not tmux:
|
||||
logger.error("[SESSION_BOOT] tmux not found on PATH")
|
||||
return {"exit_code": 1, "error": "tmux not found — required for session hosting"}
|
||||
|
||||
live = _find_live_sessions(cwd)
|
||||
|
||||
if live:
|
||||
session = live[0]
|
||||
pid = session["pid"]
|
||||
name = session.get("name", "")
|
||||
logger.info("[SESSION_BOOT] Live session found: PID %d%s", pid, f" ({name})" if name else "")
|
||||
|
||||
tmux_session = _find_tmux_session_for_pid(pid)
|
||||
if tmux_session:
|
||||
logger.info("[SESSION_BOOT] Attaching to tmux session '%s'", tmux_session)
|
||||
os.execvp("tmux", ["tmux", "attach-session", "-t", tmux_session])
|
||||
return {"exit_code": 0, "action": "attached", "tmux_session": tmux_session}
|
||||
|
||||
return {
|
||||
"exit_code": 1,
|
||||
"action": "warn",
|
||||
"error": f"Live session at PID {pid} is not in tmux. Kill it first or attach to its terminal.",
|
||||
}
|
||||
|
||||
if _tmux_session_exists(branch):
|
||||
logger.info("[SESSION_BOOT] Killing stale tmux session '%s'", branch)
|
||||
subprocess.run(["tmux", "kill-session", "-t", branch], check=False)
|
||||
|
||||
claude_cmd = [claude_bin] + _DEFAULT_ARGS
|
||||
if extra_args:
|
||||
claude_cmd.extend(extra_args)
|
||||
|
||||
logger.info("[SESSION_BOOT] Starting fresh in tmux session '%s': %s", branch, " ".join(claude_cmd))
|
||||
os.execvp("tmux", ["tmux", "new-session", "-s", branch, "--"] + claude_cmd)
|
||||
return {"exit_code": 0, "action": "started", "tmux_session": branch}
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""CLI entry point for the boot wrapper."""
|
||||
result = boot(extra_args=sys.argv[1:] if len(sys.argv) > 1 else None)
|
||||
if result.get("exit_code", 0) != 0:
|
||||
sys.stderr.write(f"{result.get('error', 'boot failed')}\n")
|
||||
sys.exit(result["exit_code"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,11 +1,11 @@
|
||||
# =================== AIPass ====================
|
||||
# Name: telegram_response.py
|
||||
# Version: 1.0.0
|
||||
# Version: 2.0.0
|
||||
# Description: Telegram response delivery on Stop event (ported from Dev-Pass)
|
||||
# Branch: hooks
|
||||
# Layer: apps/handlers/notification
|
||||
# Created: 2026-06-15
|
||||
# Modified: 2026-06-15
|
||||
# Modified: 2026-06-29
|
||||
# =============================================
|
||||
|
||||
"""Telegram response delivery on Stop event.
|
||||
@@ -18,6 +18,7 @@ Layer 2: isSidechain filter — skips sidechain entries during transcript extrac
|
||||
Layer 3: Transcript position — only extracts text after the recorded injection point
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
@@ -30,12 +31,16 @@ from urllib.request import Request, urlopen
|
||||
from aipass.prax.apps.modules.logger import system_logger as logger
|
||||
|
||||
PENDING_DIR = Path.home() / ".aipass" / "telegram_pending"
|
||||
MIRROR_DIR = Path.home() / ".aipass" / "telegram_bots"
|
||||
PENDING_TTL = 3600
|
||||
TELEGRAM_CHAR_LIMIT = 4096
|
||||
_DELIVERY_LOG = Path(__file__).resolve().parent.parent.parent.parent / "logs" / "telegram_delivery.jsonl"
|
||||
|
||||
|
||||
def _is_expired(data: dict) -> bool:
|
||||
"""Check if pending file is expired (1-hour TTL + tmux-alive check)."""
|
||||
if data.get("mirror"):
|
||||
return False
|
||||
timestamp = data.get("timestamp", 0)
|
||||
if isinstance(timestamp, str):
|
||||
try:
|
||||
@@ -73,38 +78,49 @@ def _try_load_pending(path: Path) -> dict | None:
|
||||
return None
|
||||
|
||||
|
||||
def _in_mirror_dir(path: Path) -> bool:
|
||||
"""Check if a path is inside the persistent mirror mapping directory."""
|
||||
try:
|
||||
path.relative_to(MIRROR_DIR)
|
||||
return True
|
||||
except ValueError:
|
||||
logger.info("[HOOKS] telegram: %s is not in mirror dir", path.name)
|
||||
return False
|
||||
|
||||
|
||||
def find_pending_file(session_id: str) -> Path | None: # noqa: ARG001
|
||||
"""Find pending file matching current context via multi-bot matching.
|
||||
|
||||
Priority 1: AIPASS_BOT_ID env var -> bot-{bot_id}.json
|
||||
Priority 2: CWD relative_to work_dir -> bot-*.json
|
||||
Priority 1: AIPASS_BOT_ID env var -> bot-{bot_id}.json (mirror dir first, then pending)
|
||||
Priority 2: CWD relative_to work_dir -> bot-*.json (both dirs)
|
||||
"""
|
||||
if not PENDING_DIR.exists():
|
||||
return None
|
||||
|
||||
cwd = Path.cwd()
|
||||
env_bot_id = os.environ.get("AIPASS_BOT_ID")
|
||||
|
||||
if env_bot_id:
|
||||
v2_path = PENDING_DIR / f"bot-{env_bot_id}.json"
|
||||
if _try_load_pending(v2_path) is not None:
|
||||
logger.info("[HOOKS] telegram: v2 match bot_id env -> %s", v2_path.name)
|
||||
return v2_path
|
||||
for search_dir in [MIRROR_DIR, PENDING_DIR]:
|
||||
path = search_dir / f"bot-{env_bot_id}.json"
|
||||
if _try_load_pending(path) is not None:
|
||||
logger.info("[HOOKS] telegram: match bot_id env -> %s", path)
|
||||
return path
|
||||
|
||||
for pending_path in sorted(PENDING_DIR.glob("bot-*.json")):
|
||||
data = _try_load_pending(pending_path)
|
||||
if data is None:
|
||||
continue
|
||||
work_dir = data.get("work_dir")
|
||||
if not work_dir:
|
||||
continue
|
||||
try:
|
||||
cwd.relative_to(Path(work_dir))
|
||||
logger.info("[HOOKS] telegram: v2 match cwd -> %s", pending_path.name)
|
||||
return pending_path
|
||||
except ValueError:
|
||||
logger.info("[HOOKS] telegram: cwd not relative to %s, skipping", pending_path.name)
|
||||
for search_dir in [PENDING_DIR, MIRROR_DIR]:
|
||||
if not search_dir.exists():
|
||||
continue
|
||||
for pending_path in sorted(search_dir.glob("bot-*.json")):
|
||||
data = _try_load_pending(pending_path)
|
||||
if data is None:
|
||||
continue
|
||||
work_dir = data.get("work_dir")
|
||||
if not work_dir:
|
||||
continue
|
||||
try:
|
||||
cwd.relative_to(Path(work_dir))
|
||||
logger.info("[HOOKS] telegram: match cwd -> %s", pending_path)
|
||||
return pending_path
|
||||
except ValueError:
|
||||
logger.info("[HOOKS] telegram: cwd not relative to %s, skipping", pending_path.name)
|
||||
continue
|
||||
|
||||
return None
|
||||
|
||||
@@ -192,6 +208,128 @@ def _collect_assistant_text(lines: list[str]) -> list[str]:
|
||||
return text_parts
|
||||
|
||||
|
||||
def _extract_user_text(content: list | str) -> str | None:
|
||||
"""Extract text from user message content, returning None for tool-result-only messages."""
|
||||
if isinstance(content, str):
|
||||
return content.strip() or None
|
||||
if not isinstance(content, list):
|
||||
return None
|
||||
texts: list[str] = []
|
||||
has_non_tool = False
|
||||
for block in content:
|
||||
if not isinstance(block, dict):
|
||||
continue
|
||||
if block.get("type") == "text":
|
||||
text = block.get("text", "").strip()
|
||||
if text:
|
||||
texts.append(text)
|
||||
has_non_tool = True
|
||||
elif block.get("type") != "tool_result":
|
||||
has_non_tool = True
|
||||
if not has_non_tool:
|
||||
return None
|
||||
return " ".join(texts) if texts else None
|
||||
|
||||
|
||||
def _text_from_content(content: list) -> list[str]:
|
||||
"""Extract text strings from a message content block list."""
|
||||
if not isinstance(content, list):
|
||||
return []
|
||||
parts: list[str] = []
|
||||
for block in content:
|
||||
if isinstance(block, dict) and block.get("type") == "text":
|
||||
text = block.get("text", "").strip()
|
||||
if text:
|
||||
parts.append(text)
|
||||
return parts
|
||||
|
||||
|
||||
def _collect_mirror_entries(lines: list[str]) -> list[tuple[str | None, list[str]]]:
|
||||
"""Collect user+assistant turn pairs from JSONL lines for mirror delivery."""
|
||||
turns: list[tuple[str | None, list[str]]] = []
|
||||
current_user: str | None = None
|
||||
current_assistant: list[str] = []
|
||||
|
||||
for line in lines:
|
||||
try:
|
||||
entry = json.loads(line)
|
||||
except json.JSONDecodeError:
|
||||
logger.info("[HOOKS] telegram: skipping malformed JSONL line in mirror collection")
|
||||
continue
|
||||
if entry.get("isSidechain", False):
|
||||
continue
|
||||
|
||||
entry_type = entry.get("type")
|
||||
message = entry.get("message", {})
|
||||
content = message.get("content", [])
|
||||
|
||||
if entry_type == "user":
|
||||
user_text = _extract_user_text(content)
|
||||
if user_text is None:
|
||||
continue
|
||||
if current_user is not None or current_assistant:
|
||||
turns.append((current_user, current_assistant))
|
||||
current_user = user_text
|
||||
current_assistant = []
|
||||
|
||||
elif entry_type == "assistant":
|
||||
current_assistant.extend(_text_from_content(content))
|
||||
|
||||
if current_user is not None or current_assistant:
|
||||
turns.append((current_user, current_assistant))
|
||||
|
||||
return turns
|
||||
|
||||
|
||||
def extract_mirror_turn(transcript_path: str, start_line: int = 0) -> str | None:
|
||||
"""Extract all new turns (user input + assistant response) for mirror delivery."""
|
||||
path = Path(transcript_path)
|
||||
if not path.exists():
|
||||
logger.warning("[HOOKS] telegram: mirror transcript not found: %s", transcript_path)
|
||||
return None
|
||||
|
||||
try:
|
||||
all_lines = path.read_text(encoding="utf-8").strip().split("\n")
|
||||
except OSError as e:
|
||||
logger.error("[HOOKS] telegram: failed to read mirror transcript: %s", e)
|
||||
return None
|
||||
|
||||
if not all_lines:
|
||||
return None
|
||||
|
||||
lines = all_lines[start_line:] if start_line > 0 else all_lines
|
||||
if not lines and start_line > len(all_lines):
|
||||
logger.warning(
|
||||
"[HOOKS] telegram: cursor ahead of transcript (%d > %d) — clamping to deliver latest",
|
||||
start_line,
|
||||
len(all_lines),
|
||||
)
|
||||
clamped = _find_last_real_user_message(all_lines)
|
||||
lines = all_lines[max(0, clamped) :]
|
||||
if not lines:
|
||||
return None
|
||||
|
||||
turns = _collect_mirror_entries(lines)
|
||||
if not turns:
|
||||
return None
|
||||
|
||||
formatted = []
|
||||
for user_text, assistant_parts in turns:
|
||||
parts: list[str] = []
|
||||
if user_text:
|
||||
parts.append(f"You: {user_text}")
|
||||
if assistant_parts:
|
||||
parts.append("\n\n".join(assistant_parts))
|
||||
if parts:
|
||||
formatted.append("\n\n".join(parts))
|
||||
|
||||
if not formatted:
|
||||
return None
|
||||
|
||||
result = "\n\n---\n\n".join(formatted).strip()
|
||||
return result if result else None
|
||||
|
||||
|
||||
def chunk_text(text: str, limit: int = TELEGRAM_CHAR_LIMIT) -> list[str]:
|
||||
"""Split text into chunks for Telegram's message limit."""
|
||||
if len(text) <= limit:
|
||||
@@ -278,8 +416,14 @@ def markdown_to_telegram_html(text: str) -> str:
|
||||
return text
|
||||
|
||||
|
||||
def send_to_telegram(bot_token: str, chat_id: int, text: str, message_id: int | None = None) -> bool:
|
||||
"""Send a message to Telegram via Bot API using urllib."""
|
||||
def _parse_api_message(api_result: dict) -> dict:
|
||||
"""Extract message_id and text from a Telegram API response."""
|
||||
msg = api_result.get("result", {})
|
||||
return {"ok": True, "message_id": msg.get("message_id"), "text": msg.get("text", "")}
|
||||
|
||||
|
||||
def send_to_telegram(bot_token: str, chat_id: int, text: str, message_id: int | None = None) -> dict:
|
||||
"""Send a message to Telegram via Bot API. Returns dict with ok, message_id, text."""
|
||||
url = f"https://api.telegram.org/bot{bot_token}/sendMessage"
|
||||
|
||||
try:
|
||||
@@ -290,10 +434,10 @@ def send_to_telegram(bot_token: str, chat_id: int, text: str, message_id: int |
|
||||
data = json.dumps(html_payload).encode("utf-8")
|
||||
req = Request(url, data=data, headers={"Content-Type": "application/json"})
|
||||
with urlopen(req, timeout=15) as resp:
|
||||
result = json.loads(resp.read())
|
||||
if result.get("ok"):
|
||||
return True
|
||||
logger.warning("[HOOKS] telegram: HTML send failed: %s", result.get("description"))
|
||||
api_result = json.loads(resp.read())
|
||||
if api_result.get("ok"):
|
||||
return _parse_api_message(api_result)
|
||||
logger.warning("[HOOKS] telegram: HTML send failed: %s", api_result.get("description"))
|
||||
except Exception as e:
|
||||
logger.warning("[HOOKS] telegram: HTML send error: %s, plain text fallback", e)
|
||||
|
||||
@@ -306,11 +450,11 @@ def send_to_telegram(bot_token: str, chat_id: int, text: str, message_id: int |
|
||||
|
||||
try:
|
||||
with urlopen(req, timeout=15) as resp:
|
||||
result = json.loads(resp.read())
|
||||
if result.get("ok"):
|
||||
return True
|
||||
logger.error("[HOOKS] telegram: API error: %s", result.get("description"))
|
||||
return False
|
||||
api_result = json.loads(resp.read())
|
||||
if api_result.get("ok"):
|
||||
return _parse_api_message(api_result)
|
||||
logger.error("[HOOKS] telegram: API error: %s", api_result.get("description"))
|
||||
return {"ok": False}
|
||||
except HTTPError as e:
|
||||
try:
|
||||
body = json.loads(e.read().decode("utf-8"))
|
||||
@@ -318,17 +462,17 @@ def send_to_telegram(bot_token: str, chat_id: int, text: str, message_id: int |
|
||||
logger.error("[HOOKS] telegram: HTTP %d: %s (len=%d)", e.code, description, len(text))
|
||||
except Exception:
|
||||
logger.error("[HOOKS] telegram: HTTP %d: %s (len=%d)", e.code, e.reason, len(text))
|
||||
return False
|
||||
return {"ok": False}
|
||||
except URLError as e:
|
||||
logger.error("[HOOKS] telegram: send failed: %s", e)
|
||||
return False
|
||||
return {"ok": False}
|
||||
except Exception as e:
|
||||
logger.error("[HOOKS] telegram: unexpected send error: %s", e)
|
||||
return False
|
||||
return {"ok": False}
|
||||
|
||||
|
||||
def edit_telegram_message(bot_token: str, chat_id: int, message_id: int, text: str) -> bool:
|
||||
"""Edit an existing Telegram message via Bot API."""
|
||||
def edit_telegram_message(bot_token: str, chat_id: int, message_id: int, text: str) -> dict:
|
||||
"""Edit an existing Telegram message via Bot API. Returns dict with ok, message_id, text."""
|
||||
url = f"https://api.telegram.org/bot{bot_token}/editMessageText"
|
||||
|
||||
try:
|
||||
@@ -337,9 +481,9 @@ def edit_telegram_message(bot_token: str, chat_id: int, message_id: int, text: s
|
||||
data = json.dumps(html_payload).encode("utf-8")
|
||||
req = Request(url, data=data, headers={"Content-Type": "application/json"})
|
||||
with urlopen(req, timeout=15) as resp:
|
||||
result = json.loads(resp.read())
|
||||
if result.get("ok", False):
|
||||
return True
|
||||
api_result = json.loads(resp.read())
|
||||
if api_result.get("ok", False):
|
||||
return _parse_api_message(api_result)
|
||||
logger.warning("[HOOKS] telegram: HTML edit failed, plain text fallback")
|
||||
except Exception as e:
|
||||
logger.warning("[HOOKS] telegram: HTML edit error: %s, plain text fallback", e)
|
||||
@@ -350,23 +494,26 @@ def edit_telegram_message(bot_token: str, chat_id: int, message_id: int, text: s
|
||||
|
||||
try:
|
||||
with urlopen(req, timeout=15) as resp:
|
||||
result = json.loads(resp.read())
|
||||
return result.get("ok", False)
|
||||
api_result = json.loads(resp.read())
|
||||
if api_result.get("ok", False):
|
||||
return _parse_api_message(api_result)
|
||||
return {"ok": False}
|
||||
except Exception as e:
|
||||
logger.warning("[HOOKS] telegram: edit failed: %s", e)
|
||||
return False
|
||||
return {"ok": False}
|
||||
|
||||
|
||||
def _send_with_retry(bot_token: str, chat_id: int, text: str, retries: int = 3) -> bool:
|
||||
"""Send with retry and exponential backoff."""
|
||||
def _send_with_retry(bot_token: str, chat_id: int, text: str, retries: int = 3) -> dict:
|
||||
"""Send with retry and exponential backoff. Returns dict with ok, message_id, text."""
|
||||
for attempt in range(retries):
|
||||
if send_to_telegram(bot_token, chat_id, text):
|
||||
return True
|
||||
result = send_to_telegram(bot_token, chat_id, text)
|
||||
if result["ok"]:
|
||||
return result
|
||||
if attempt < retries - 1:
|
||||
delay = 1.0 * (2**attempt)
|
||||
logger.info("[HOOKS] telegram: send retry %d/%d after %.0fs", attempt + 2, retries, delay)
|
||||
time.sleep(delay)
|
||||
return False
|
||||
return {"ok": False}
|
||||
|
||||
|
||||
def handle(hook_data: dict) -> dict:
|
||||
@@ -394,16 +541,19 @@ def handle(hook_data: dict) -> dict:
|
||||
pending_data = json.loads(pending_file.read_text(encoding="utf-8"))
|
||||
except (json.JSONDecodeError, OSError) as e:
|
||||
logger.error("[HOOKS] telegram: failed to read pending: %s", e)
|
||||
pending_file.unlink(missing_ok=True)
|
||||
if not _in_mirror_dir(pending_file):
|
||||
pending_file.unlink(missing_ok=True)
|
||||
return {"stdout": "", "exit_code": 0}
|
||||
|
||||
is_mirror = pending_data.get("mirror", False)
|
||||
chat_id = pending_data.get("chat_id")
|
||||
bot_token = pending_data.get("bot_token")
|
||||
processing_message_id = pending_data.get("processing_message_id")
|
||||
|
||||
if not chat_id or not bot_token:
|
||||
logger.error("[HOOKS] telegram: missing chat_id or bot_token in pending")
|
||||
pending_file.unlink(missing_ok=True)
|
||||
if not is_mirror:
|
||||
pending_file.unlink(missing_ok=True)
|
||||
return {"stdout": "", "exit_code": 0}
|
||||
|
||||
response_text = _extract_response(hook_data, transcript_path, pending_data)
|
||||
@@ -417,11 +567,12 @@ def handle(hook_data: dict) -> dict:
|
||||
chunks = chunk_text(response_text)
|
||||
logger.info("[HOOKS] telegram: sending %d chunk(s) (logs_active=%s)", len(chunks), logs_were_active)
|
||||
|
||||
all_sent = _deliver_chunks(chunks, bot_token, chat_id, processing_message_id, logs_were_active)
|
||||
all_sent, chunk_results = _deliver_chunks(chunks, bot_token, chat_id, processing_message_id, logs_were_active)
|
||||
|
||||
_write_delivery_log(response_text, chunks, chunk_results, session_id)
|
||||
|
||||
if all_sent:
|
||||
pending_file.unlink(missing_ok=True)
|
||||
logger.info("[HOOKS] telegram: response delivered, pending cleaned")
|
||||
_advance_pending(pending_file, pending_data, transcript_path)
|
||||
else:
|
||||
logger.error("[HOOKS] telegram: delivery failed — keeping pending for retry")
|
||||
|
||||
@@ -430,6 +581,9 @@ def handle(hook_data: dict) -> dict:
|
||||
|
||||
def _extract_response(hook_data: dict, transcript_path: str, pending_data: dict) -> str | None:
|
||||
"""Try JSONL transcript extraction with retries, fall back to last_assistant_message."""
|
||||
if pending_data.get("mirror"):
|
||||
return _extract_mirror_response(transcript_path, pending_data)
|
||||
|
||||
response_text = None
|
||||
|
||||
if transcript_path:
|
||||
@@ -446,7 +600,7 @@ def _extract_response(hook_data: dict, transcript_path: str, pending_data: dict)
|
||||
logger.info("[HOOKS] telegram: JSONL retry %.1fs (attempt %d/3)", delay, attempt + 1)
|
||||
time.sleep(delay)
|
||||
|
||||
if not response_text:
|
||||
if not response_text and not pending_data.get("delivered"):
|
||||
response_text = (hook_data.get("last_assistant_message") or "").strip()
|
||||
if response_text:
|
||||
logger.info("[HOOKS] telegram: using last_assistant_message fallback (%d chars)", len(response_text))
|
||||
@@ -454,6 +608,23 @@ def _extract_response(hook_data: dict, transcript_path: str, pending_data: dict)
|
||||
return response_text or None
|
||||
|
||||
|
||||
def _extract_mirror_response(transcript_path: str, pending_data: dict) -> str | None:
|
||||
"""Extract all new turns for mirror delivery with retries."""
|
||||
if not transcript_path:
|
||||
return None
|
||||
start_line = pending_data.get("transcript_line_after", 0)
|
||||
for attempt in range(3):
|
||||
result = extract_mirror_turn(transcript_path, start_line)
|
||||
if result:
|
||||
logger.info("[HOOKS] telegram: mirror extraction: %d chars (attempt %d)", len(result), attempt + 1)
|
||||
return result
|
||||
if attempt < 2:
|
||||
delay = [0.2, 0.5][attempt]
|
||||
logger.info("[HOOKS] telegram: mirror retry %.1fs (attempt %d/3)", delay, attempt + 1)
|
||||
time.sleep(delay)
|
||||
return None
|
||||
|
||||
|
||||
def _prepend_branch_prefix(text: str) -> str:
|
||||
"""Prepend @branch identifier so user knows which branch responded."""
|
||||
try:
|
||||
@@ -486,22 +657,110 @@ def _deliver_chunks(
|
||||
chat_id: int,
|
||||
processing_message_id: int | None,
|
||||
logs_were_active: bool,
|
||||
) -> bool:
|
||||
"""Send all response chunks to Telegram. Returns True if all succeeded."""
|
||||
) -> tuple[bool, list[dict]]:
|
||||
"""Send all response chunks to Telegram. Returns (all_sent, per-chunk results)."""
|
||||
chunk_results: list[dict] = []
|
||||
all_sent = True
|
||||
single = len(chunks) == 1
|
||||
|
||||
for i, chunk in enumerate(chunks):
|
||||
if i == 0 and processing_message_id and not logs_were_active:
|
||||
text = f"[1/{len(chunks)}]\n{chunk}" if len(chunks) > 1 else chunk
|
||||
if not edit_telegram_message(bot_token, chat_id, processing_message_id, text):
|
||||
if not _send_with_retry(bot_token, chat_id, text):
|
||||
if i == 0 and processing_message_id:
|
||||
if single and not logs_were_active:
|
||||
result = edit_telegram_message(bot_token, chat_id, processing_message_id, chunk)
|
||||
if result["ok"]:
|
||||
chunk_results.append({"idx": i, "method": "edit", **result})
|
||||
continue
|
||||
result = _send_with_retry(bot_token, chat_id, chunk)
|
||||
chunk_results.append({"idx": i, "method": "send", **result})
|
||||
if not result["ok"]:
|
||||
all_sent = False
|
||||
elif i == 0 and processing_message_id and logs_were_active:
|
||||
continue
|
||||
edit_telegram_message(bot_token, chat_id, processing_message_id, "Done.")
|
||||
text = f"[1/{len(chunks)}]\n{chunk}" if len(chunks) > 1 else chunk
|
||||
if not _send_with_retry(bot_token, chat_id, text):
|
||||
all_sent = False
|
||||
|
||||
prefix = f"[{i + 1}/{len(chunks)}]\n" if not single else ""
|
||||
result = _send_with_retry(bot_token, chat_id, prefix + chunk)
|
||||
chunk_results.append({"idx": i, "method": "send", **result})
|
||||
if not result["ok"]:
|
||||
all_sent = False
|
||||
|
||||
return all_sent, chunk_results
|
||||
|
||||
|
||||
def _advance_pending(pending_file: Path, pending_data: dict, transcript_path: str) -> None:
|
||||
"""Advance transcript cursor so later Stops can deliver new text."""
|
||||
is_mirror = pending_data.get("mirror", False)
|
||||
if not transcript_path:
|
||||
if not is_mirror:
|
||||
pending_file.unlink(missing_ok=True)
|
||||
logger.info("[HOOKS] telegram: no transcript — pending %s", "kept (mirror)" if is_mirror else "removed")
|
||||
return
|
||||
try:
|
||||
line_count = 0
|
||||
with open(transcript_path, encoding="utf-8") as f:
|
||||
for _ in f:
|
||||
line_count += 1
|
||||
pending_data["transcript_line_after"] = line_count
|
||||
pending_data["delivered"] = True
|
||||
pending_file.write_text(json.dumps(pending_data, indent=2), encoding="utf-8")
|
||||
logger.info("[HOOKS] telegram: cursor advanced to line %d", line_count)
|
||||
except OSError as e:
|
||||
logger.warning("[HOOKS] telegram: cursor advance failed: %s", e)
|
||||
if not is_mirror:
|
||||
pending_file.unlink(missing_ok=True)
|
||||
|
||||
|
||||
def _write_delivery_log(intended_text: str, chunks: list[str], chunk_results: list[dict], session_id: str) -> None:
|
||||
"""Write JSONL record documenting what was delivered to Telegram."""
|
||||
intended_sha = hashlib.sha256(intended_text.encode("utf-8")).hexdigest()[:16]
|
||||
|
||||
delivered_parts = []
|
||||
for cr in chunk_results:
|
||||
text = cr.get("text") or ""
|
||||
text = re.sub(r"^\[\d+/\d+\]\n", "", text)
|
||||
delivered_parts.append(text)
|
||||
delivered_concat = "\n\n".join(delivered_parts)
|
||||
delivered_sha = hashlib.sha256(delivered_concat.encode("utf-8")).hexdigest()[:16]
|
||||
|
||||
all_ok = all(cr.get("ok") for cr in chunk_results)
|
||||
match = all_ok and intended_sha == delivered_sha
|
||||
|
||||
culprit = None
|
||||
if not match:
|
||||
failed = [cr["idx"] for cr in chunk_results if not cr.get("ok")]
|
||||
if failed:
|
||||
culprit = f"delivery_failed: chunks {failed}"
|
||||
elif abs(len(intended_text) - len(delivered_concat)) > max(len(intended_text) * 0.1, 20):
|
||||
culprit = f"length_mismatch: intended={len(intended_text)} delivered={len(delivered_concat)}"
|
||||
else:
|
||||
prefix = f"[{i + 1}/{len(chunks)}]\n" if len(chunks) > 1 else ""
|
||||
if not _send_with_retry(bot_token, chat_id, prefix + chunk):
|
||||
all_sent = False
|
||||
return all_sent
|
||||
culprit = "formatting_conversion"
|
||||
|
||||
record = {
|
||||
"ts": time.time(),
|
||||
"session": session_id[:8] if session_id else "",
|
||||
"intended_text": intended_text,
|
||||
"intended_sha256": intended_sha,
|
||||
"intended_len": len(intended_text),
|
||||
"chunks": [
|
||||
{
|
||||
"idx": cr.get("idx"),
|
||||
"method": cr.get("method"),
|
||||
"message_id": cr.get("message_id"),
|
||||
"ok": cr.get("ok"),
|
||||
"returned_len": len(cr.get("text") or ""),
|
||||
}
|
||||
for cr in chunk_results
|
||||
],
|
||||
"delivered_concat": delivered_concat,
|
||||
"delivered_sha256": delivered_sha,
|
||||
"delivered_len": len(delivered_concat),
|
||||
"match": match,
|
||||
}
|
||||
if culprit:
|
||||
record["culprit"] = culprit
|
||||
|
||||
try:
|
||||
_DELIVERY_LOG.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(_DELIVERY_LOG, "a", encoding="utf-8") as f:
|
||||
f.write(json.dumps(record) + "\n")
|
||||
except OSError as e:
|
||||
logger.warning("[HOOKS] telegram: delivery log write failed: %s", e)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user