diff --git a/.aipass/hooks.json b/.aipass/hooks.json index d4f049ee..0e84c6df 100644 --- a/.aipass/hooks.json +++ b/.aipass/hooks.json @@ -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": "" } }, diff --git a/.aipass/tier1_navmap.md b/.aipass/tier1_navmap.md index ab898dd7..b5874ec2 100644 --- a/.aipass/tier1_navmap.md +++ b/.aipass/tier1_navmap.md @@ -55,7 +55,7 @@ src/aipass// - @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 diff --git a/.backupignore b/.backupignore index f606661b..57595e6b 100644 --- a/.backupignore +++ b/.backupignore @@ -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 \ No newline at end of file diff --git a/.claude/commands/prep.md b/.claude/commands/prep.md index c165fbdc..69a71810 100644 --- a/.claude/commands/prep.md +++ b/.claude/commands/prep.md @@ -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] diff --git a/.claude/templates/memo.md b/.claude/templates/memo.md index 37b06a61..6c9bc059 100644 --- a/.claude/templates/memo.md +++ b/.claude/templates/memo.md @@ -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 diff --git a/.codex/skills/memo/SKILL.md b/.codex/skills/memo/SKILL.md index 95135647..250f122a 100644 --- a/.codex/skills/memo/SKILL.md +++ b/.codex/skills/memo/SKILL.md @@ -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 diff --git a/.codex/skills/prep/SKILL.md b/.codex/skills/prep/SKILL.md index d18832c4..a75955b4 100644 --- a/.codex/skills/prep/SKILL.md +++ b/.codex/skills/prep/SKILL.md @@ -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] diff --git a/CHANGELOG.md b/CHANGELOG.md index f13c2d3e..ddf4ed81 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,292 @@ PyPI version — not the changelog header. --- +## [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//.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 + `/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 `, 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). diff --git a/CLAUDE.md b/CLAUDE.md index 7ef166d6..e77b3513 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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`. \ No newline at end of file +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. \ No newline at end of file diff --git a/README.md b/README.md index 91ba5f31..911f0c56 100644 --- a/README.md +++ b/README.md @@ -99,12 +99,12 @@ aipass doctor # Check system health ### Explore the full framework -Clone the repo to see all 13 agents working together — the reference implementation: +Clone the repo to see 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 +./setup.sh # Creates venv, installs, bootstraps 17 agents cd src/aipass/devpulse claude # Talk to the orchestrator @@ -149,13 +149,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 +170,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 +205,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 | @@ -266,6 +279,9 @@ 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 ran the backup system, also remove its local state + shipped config +rm -rf .backup/ && rm -f .backupignore + # If you installed via pip pip uninstall aipass ``` diff --git a/pyproject.toml b/pyproject.toml index 926eef23..7705020a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -51,6 +51,9 @@ memory = [ "chromadb>=1.0", "fastembed>=0.4", ] +telegram = [ + "telethon>=1.36", +] seedgo = [] dev = [ "pytest>=9.0.3", diff --git a/src/aipass/ai_mail/apps/ai_mail.py b/src/aipass/ai_mail/apps/ai_mail.py index 42294ea4..6c72ada6 100644 --- a/src/aipass/ai_mail/apps/ai_mail.py +++ b/src/aipass/ai_mail/apps/ai_mail.py @@ -16,7 +16,6 @@ # Standard library imports import sys import importlib -import argparse import signal from pathlib import Path from typing import Any, List @@ -51,52 +50,47 @@ 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 - -USAGE: - drone @ai_mail [args] - drone @ai_mail --help - -EXAMPLES: - # Dispatch (send + wake in one command) - drone @ai_mail dispatch @branch "Subject" "Body" - drone @ai_mail dispatch @branch "Subject" "Body" --fresh - - # Send mail (no wake) - drone @ai_mail email @seedgo "Subject" "Msg" # Send to branch - drone @ai_mail email @all "Subject" "Msg" # Broadcast to all - - # 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()) + """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() + + 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() + + 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() + + console.print("[yellow]USAGE:[/yellow]") + console.print(" [cyan]drone @ai_mail[/cyan] [args]") + console.print(" [cyan]drone @ai_mail --help[/cyan]") + console.print() + + 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() # ============================================================================= diff --git a/src/aipass/ai_mail/apps/handlers/dispatch/dispatch_monitor.py b/src/aipass/ai_mail/apps/handlers/dispatch/dispatch_monitor.py index eb514abf..6a85b9f0 100644 --- a/src/aipass/ai_mail/apps/handlers/dispatch/dispatch_monitor.py +++ b/src/aipass/ai_mail/apps/handlers/dispatch/dispatch_monitor.py @@ -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: diff --git a/src/aipass/ai_mail/apps/handlers/dispatch/wake.py b/src/aipass/ai_mail/apps/handlers/dispatch/wake.py index aecdfa41..6b8c34f3 100644 --- a/src/aipass/ai_mail/apps/handlers/dispatch/wake.py +++ b/src/aipass/ai_mail/apps/handlers/dispatch/wake.py @@ -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 - - # 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) - - 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 + 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, + ) + + monitor_pid = process.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) + + 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" diff --git a/src/aipass/ai_mail/apps/handlers/email/footer.py b/src/aipass/ai_mail/apps/handlers/email/footer.py index c5ef8dab..6263ac0c 100644 --- a/src/aipass/ai_mail/apps/handlers/email/footer.py +++ b/src/aipass/ai_mail/apps/handlers/email/footer.py @@ -22,7 +22,7 @@ ⚠️ 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 +□ CLOSE YOUR PLAN → drone @flow close — this task's plan only, never the master/parent □ EMAIL SENDER → drone @ai_mail email @ "Subject" "Summary" Memories = Presence. No update = No learning. diff --git a/src/aipass/ai_mail/tests/test_footer.py b/src/aipass/ai_mail/tests/test_footer.py index d47c5534..9dcf6eff 100644 --- a/src/aipass/ai_mail/tests/test_footer.py +++ b/src/aipass/ai_mail/tests/test_footer.py @@ -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 diff --git a/src/aipass/ai_mail/tests/test_wake.py b/src/aipass/ai_mail/tests/test_wake.py index 2ce16c40..d715891f 100644 --- a/src/aipass/ai_mail/tests/test_wake.py +++ b/src/aipass/ai_mail/tests/test_wake.py @@ -601,6 +601,7 @@ def test_spawn_env_includes_local_bin(self, tmp_path, monkeypatch): 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: diff --git a/src/aipass/aipass/.aipass/aipass_local_prompt.md b/src/aipass/aipass/.aipass/aipass_local_prompt.md index 3f6273d8..bc1a27ca 100644 --- a/src/aipass/aipass/.aipass/aipass_local_prompt.md +++ b/src/aipass/aipass/.aipass/aipass_local_prompt.md @@ -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 diff --git a/src/aipass/api/README.md b/src/aipass/api/README.md index 07fcd33a..e0ee8ddb 100644 --- a/src/aipass/api/README.md +++ b/src/aipass/api/README.md @@ -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) diff --git a/src/aipass/api/apps/handlers/auth/secrets.py b/src/aipass/api/apps/handlers/auth/secrets.py index 6cc77f93..38031c7c 100644 --- a/src/aipass/api/apps/handlers/auth/secrets.py +++ b/src/aipass/api/apps/handlers/auth/secrets.py @@ -9,17 +9,19 @@ """ Secrets Store Handler -Reads structured secrets from ~/.secrets/aipass//. +Reads and writes structured secrets in ~/.secrets/aipass//. 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//.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 # ============================================== diff --git a/src/aipass/api/apps/modules/api_key.py b/src/aipass/api/apps/modules/api_key.py index 2485d4a2..8c13aadf 100644 --- a/src/aipass/api/apps/modules/api_key.py +++ b/src/aipass/api/apps/modules/api_key.py @@ -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 [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] [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__": diff --git a/src/aipass/api/apps/modules/google_client.py b/src/aipass/api/apps/modules/google_client.py index c4dbc4fe..f4023861 100644 --- a/src/aipass/api/apps/modules/google_client.py +++ b/src/aipass/api/apps/modules/google_client.py @@ -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() # ============================================= diff --git a/src/aipass/api/apps/modules/openrouter_client.py b/src/aipass/api/apps/modules/openrouter_client.py index b425da2f..5f05a406 100644 --- a/src/aipass/api/apps/modules/openrouter_client.py +++ b/src/aipass/api/apps/modules/openrouter_client.py @@ -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 [--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] [--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: diff --git a/src/aipass/api/apps/modules/secrets.py b/src/aipass/api/apps/modules/secrets.py index 327398a9..d5e44892 100644 --- a/src/aipass/api/apps/modules/secrets.py +++ b/src/aipass/api/apps/modules/secrets.py @@ -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. diff --git a/src/aipass/api/apps/modules/usage_tracker.py b/src/aipass/api/apps/modules/usage_tracker.py index 23d4e09b..78795c70 100644 --- a/src/aipass/api/apps/modules/usage_tracker.py +++ b/src/aipass/api/apps/modules/usage_tracker.py @@ -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 - drone @api stats - drone @api session - drone @api caller-usage - 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] ") + console.print(" [cyan]drone @api stats[/cyan]") + console.print(" [cyan]drone @api session[/cyan]") + console.print(" [cyan]drone @api caller-usage[/cyan] ") + 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')}") diff --git a/src/aipass/api/tests/test_secrets.py b/src/aipass/api/tests/test_secrets.py index a4804592..a5d79e15 100644 --- a/src/aipass/api/tests/test_secrets.py +++ b/src/aipass/api/tests/test_secrets.py @@ -21,8 +21,19 @@ - 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.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 @@ def test_list_empty_provider(self, mock_console, mock_secrets, mock_jh) -> None: 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) diff --git a/src/aipass/backup/.aipass/aipass_local_prompt.md b/src/aipass/backup/.aipass/aipass_local_prompt.md index e7cd8959..382a2e8f 100644 --- a/src/aipass/backup/.aipass/aipass_local_prompt.md +++ b/src/aipass/backup/.aipass/aipass_local_prompt.md @@ -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 diff --git a/src/aipass/backup/.daemon/schedule.json b/src/aipass/backup/.daemon/schedule.json new file mode 100644 index 00000000..212d84b8 --- /dev/null +++ b/src/aipass/backup/.daemon/schedule.json @@ -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." + } + ] +} diff --git a/src/aipass/backup/README.md b/src/aipass/backup/README.md index 0952e8d0..6d5cf52e 100644 --- a/src/aipass/backup/README.md +++ b/src/aipass/backup/README.md @@ -64,11 +64,61 @@ apps/ backup register [--name ] # Register a project for backup backup snapshot # Full mirror backup backup versioned # Incremental timestamped backup -backup all # Snapshot + versioned +backup all # Snapshot + versioned + drive backup status # Show backup info and history -backup --version # Show version +backup restore list # List available versions of a file +backup restore file # Restore a file version to output path +backup settings # Settings UI (stub) +backup drive_sync # Google Drive sync (stub — DPLAN-003) +backup drive_check # Drive connectivity check (stub — DPLAN-003) +backup drive_stats # Drive storage stats (stub — DPLAN-003) +backup drive_clear # 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 `/.backup/` during memory overflow +- **@flow** — closed plans archived to `/.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) diff --git a/src/aipass/backup/apps/handlers/ignore/patterns.py b/src/aipass/backup/apps/handlers/ignore/patterns.py index f77ad88a..5e3c47f9 100644 --- a/src/aipass/backup/apps/handlers/ignore/patterns.py +++ b/src/aipass/backup/apps/handlers/ignore/patterns.py @@ -17,37 +17,11 @@ 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. diff --git a/src/aipass/backup/apps/handlers/project/setup.py b/src/aipass/backup/apps/handlers/project/setup.py index e0aa3b24..e66cb890 100644 --- a/src/aipass/backup/apps/handlers/project/setup.py +++ b/src/aipass/backup/apps/handlers/project/setup.py @@ -15,22 +15,22 @@ 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 = { diff --git a/src/aipass/backup/templates/README.md b/src/aipass/backup/templates/README.md index b4d52f82..5d279a05 100644 --- a/src/aipass/backup/templates/README.md +++ b/src/aipass/backup/templates/README.md @@ -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. diff --git a/src/aipass/backup/run/.backupignore b/src/aipass/backup/templates/backupignore.template similarity index 78% rename from src/aipass/backup/run/.backupignore rename to src/aipass/backup/templates/backupignore.template index 57595e6b..5b1c0735 100644 --- a/src/aipass/backup/run/.backupignore +++ b/src/aipass/backup/templates/backupignore.template @@ -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 diff --git a/src/aipass/backup/tests/test_ignore_pathspec.py b/src/aipass/backup/tests/test_ignore_pathspec.py index d96b9d25..49fd36b9 100644 --- a/src/aipass/backup/tests/test_ignore_pathspec.py +++ b/src/aipass/backup/tests/test_ignore_pathspec.py @@ -11,7 +11,6 @@ import pathspec from aipass.backup.apps.handlers.ignore.patterns import ( - BUILTIN_IGNORES, is_ignored, load_spec, ) @@ -240,24 +239,61 @@ def test_dotfile_can_be_excluded_explicitly(self, tmp_path): 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 = _build_backupignore() - assert ".ruff_cache/" in content + content = _TEMPLATE_PATH.read_text(encoding="utf-8") assert ".coverage" in content - assert "__pycache__/" 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 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.""" diff --git a/src/aipass/cli/.daemon/schedule.json b/src/aipass/cli/.daemon/schedule.json new file mode 100644 index 00000000..4a960c65 --- /dev/null +++ b/src/aipass/cli/.daemon/schedule.json @@ -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." + } + ] +} diff --git a/src/aipass/commons/.daemon/schedule.json b/src/aipass/commons/.daemon/schedule.json index 431c2f02..723521bf 100644 --- a/src/aipass/commons/.daemon/schedule.json +++ b/src/aipass/commons/.daemon/schedule.json @@ -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." } diff --git a/src/aipass/daemon/.seedgo/bypass.json b/src/aipass/daemon/.seedgo/bypass.json index e2973693..2e08e887 100644 --- a/src/aipass/daemon/.seedgo/bypass.json +++ b/src/aipass/daemon/.seedgo/bypass.json @@ -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": { diff --git a/src/aipass/daemon/README.md b/src/aipass/daemon/README.md index 6c1a6395..a17379fd 100644 --- a/src/aipass/daemon/README.md +++ b/src/aipass/daemon/README.md @@ -94,6 +94,8 @@ drone @daemon actions list # Action registry drone @daemon actions 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 --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//.daemon/schedule.json`. The daemon discovers and fires — branches define their own jobs. + +### Job file schema + +```json +{ + "version": 1, + "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 --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) diff --git a/src/aipass/daemon/apps/daemon.py b/src/aipass/daemon/apps/daemon.py index 347a4072..43a17c02 100644 --- a/src/aipass/daemon/apps/daemon.py +++ b/src/aipass/daemon/apps/daemon.py @@ -24,7 +24,7 @@ # 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: diff --git a/src/aipass/daemon/apps/handlers/actions/actions_registry.py b/src/aipass/daemon/apps/handlers/actions/actions_registry.py deleted file mode 100644 index 8581484d..00000000 --- a/src/aipass/daemon/apps/handlers/actions/actions_registry.py +++ /dev/null @@ -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 diff --git a/src/aipass/daemon/apps/handlers/schedule/__init__.py b/src/aipass/daemon/apps/handlers/schedule/__init__.py index 4a7d5be6..137cbc2f 100644 --- a/src/aipass/daemon/apps/handlers/schedule/__init__.py +++ b/src/aipass/daemon/apps/handlers/schedule/__init__.py @@ -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", ] diff --git a/src/aipass/daemon/apps/handlers/schedule/runstate.py b/src/aipass/daemon/apps/handlers/schedule/runstate.py index bc5be838..85353f3a 100644 --- a/src/aipass/daemon/apps/handlers/schedule/runstate.py +++ b/src/aipass/daemon/apps/handlers/schedule/runstate.py @@ -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", {}) diff --git a/src/aipass/daemon/apps/handlers/schedule/task_registry.py b/src/aipass/daemon/apps/handlers/schedule/task_registry.py deleted file mode 100644 index 6dd70815..00000000 --- a/src/aipass/daemon/apps/handlers/schedule/task_registry.py +++ /dev/null @@ -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() diff --git a/src/aipass/daemon/apps/handlers/schedule/telegram_notifier.py b/src/aipass/daemon/apps/handlers/schedule/telegram_notifier.py new file mode 100644 index 00000000..1b8485c4 --- /dev/null +++ b/src/aipass/daemon/apps/handlers/schedule/telegram_notifier.py @@ -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}") diff --git a/src/aipass/daemon/apps/modules/actions.py b/src/aipass/daemon/apps/modules/actions.py index dcb13576..65cf2457 100644 --- a/src/aipass/daemon/apps/modules/actions.py +++ b/src/aipass/daemon/apps/modules/actions.py @@ -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. -# ============================================= -# IMPORTS -# ============================================= +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) +""" -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.""" - console.print() - console.print("[bold cyan]actions Module[/bold cyan]") + """Display retirement notice.""" console.print() - console.print("[dim]CLI interface for the numbered action registry (DPLAN-043)[/dim]") + console.print("[bold cyan]actions Module[/bold cyan] [yellow](RETIRED)[/yellow]") 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("[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() -# ============================================= -# 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 info") - console.print(" drone @daemon actions on") - console.print(" drone @daemon actions off") - console.print(' drone @daemon actions set reminder "message" [--to @branch]') - console.print(' drone @daemon actions set schedule @branch "prompt" [time]') - console.print(" drone @daemon actions migrate") - console.print(" drone @daemon actions delete ") - console.print() - - console.print("[yellow]COMMANDS:[/yellow]") - console.print(" list List all registered actions with status") - console.print(" info Show detailed view of a single action") - console.print(" on Enable an action") - console.print(" off Disable an action") - console.print(" set Create a new reminder or schedule") - console.print(" migrate Import existing plugins into registry") - console.print(" delete 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 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 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 "message" [--to @branch]'.""" - if len(args) < 2: - _error('Usage: actions set reminder "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" [time_spec]'.""" - if len(args) < 3: - _error('Usage: actions set schedule @branch "prompt" [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 '.""" - if not args: - _error("Action ID required: actions delete ") - 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 ...") - 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 - - # Help flag - if args[0] in ["--help", "-h", "help"]: - print_help() - 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 not args: + print_introspection() + return True + if args[0] in ("--help", "-h", "help"): + print_introspection() + return True -if __name__ == "__main__": - main() + json_handler.log_operation("actions_command_retired", {"args": args[:2]}) + logger.info("[actions] Retired CLI invoked") + print_introspection() + return True diff --git a/src/aipass/daemon/apps/modules/queue.py b/src/aipass/daemon/apps/modules/queue.py new file mode 100644 index 00000000..31e814ef --- /dev/null +++ b/src/aipass/daemon/apps/modules/queue.py @@ -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 diff --git a/src/aipass/daemon/apps/modules/run.py b/src/aipass/daemon/apps/modules/run.py index fb606463..1101fd26 100644 --- a/src/aipass/daemon/apps/modules/run.py +++ b/src/aipass/daemon/apps/modules/run.py @@ -27,6 +27,7 @@ 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//.daemon/schedule.json") + console.print() + console.print(" [bold]Schema:[/bold]") + console.print(" {") + console.print(' "version": 1,') + console.print(' "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) diff --git a/src/aipass/daemon/apps/modules/schedule.py b/src/aipass/daemon/apps/modules/schedule.py index c3020771..924d6d3c 100644 --- a/src/aipass/daemon/apps/modules/schedule.py +++ b/src/aipass/daemon/apps/modules/schedule.py @@ -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. -# ============================================= -# IMPORTS -# ============================================= +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) +""" -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.""" - console.print() - console.print("[bold cyan]schedule Module[/bold cyan]") - console.print() - console.print("[dim]CLI interface for fire-and-forget scheduled follow-ups[/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.""" + """Display retirement notice.""" console.print() - _header("Scheduled Tasks") + console.print("[bold cyan]schedule Module[/bold cyan] [yellow](RETIRED)[/yellow]") 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 ") - console.print(" drone @daemon schedule run-due") + 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]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 --to @branch [--message "details"]') - return False - - if not parsed.task: - _error("Task description is required") - console.print('[dim]Usage: schedule create "task" --due --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 [/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 - - # Handle help flag - if args[0] in ["--help", "-h", "help"]: - _print_help() - 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 not args: + print_introspection() + return True + if args[0] in ("--help", "-h", "help"): + print_introspection() + return True -if __name__ == "__main__": - main() + json_handler.log_operation("schedule_command_retired", {"args": args[:2]}) + logger.info("[schedule] Retired CLI invoked") + print_introspection() + return True diff --git a/src/aipass/daemon/apps/modules/scheduler_ops.py b/src/aipass/daemon/apps/modules/scheduler_ops.py deleted file mode 100644 index 0a0ce246..00000000 --- a/src/aipass/daemon/apps/modules/scheduler_ops.py +++ /dev/null @@ -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 diff --git a/src/aipass/daemon/apps/modules/timer_install.py b/src/aipass/daemon/apps/modules/timer_install.py new file mode 100644 index 00000000..0b1041a4 --- /dev/null +++ b/src/aipass/daemon/apps/modules/timer_install.py @@ -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 diff --git a/src/aipass/daemon/apps/scheduler_cron.py b/src/aipass/daemon/apps/scheduler_cron.py deleted file mode 100755 index ecb6a209..00000000 --- a/src/aipass/daemon/apps/scheduler_cron.py +++ /dev/null @@ -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) diff --git a/src/aipass/daemon/daemon-tick.service b/src/aipass/daemon/daemon-tick.service new file mode 100644 index 00000000..ded38baf --- /dev/null +++ b/src/aipass/daemon/daemon-tick.service @@ -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 diff --git a/src/aipass/daemon/daemon-tick.timer b/src/aipass/daemon/daemon-tick.timer new file mode 100644 index 00000000..30aad404 --- /dev/null +++ b/src/aipass/daemon/daemon-tick.timer @@ -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 diff --git a/src/aipass/daemon/tests/test_actions_module.py b/src/aipass/daemon/tests/test_actions_module.py deleted file mode 100644 index 7451f5c3..00000000 --- a/src/aipass/daemon/tests/test_actions_module.py +++ /dev/null @@ -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() diff --git a/src/aipass/daemon/tests/test_actions_registry.py b/src/aipass/daemon/tests/test_actions_registry.py deleted file mode 100644 index 221ff6d1..00000000 --- a/src/aipass/daemon/tests/test_actions_registry.py +++ /dev/null @@ -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 diff --git a/src/aipass/daemon/tests/test_contracts.py b/src/aipass/daemon/tests/test_contracts.py index 948267c1..f305fd62 100644 --- a/src/aipass/daemon/tests/test_contracts.py +++ b/src/aipass/daemon/tests/test_contracts.py @@ -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 diff --git a/src/aipass/daemon/tests/test_run_module.py b/src/aipass/daemon/tests/test_run_module.py index 99e65acb..c223d7de 100644 --- a/src/aipass/daemon/tests/test_run_module.py +++ b/src/aipass/daemon/tests/test_run_module.py @@ -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 @@ def test_disabled_jobs_skipped(self, mock_rs, mock_discover): 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 @@ def test_fires_due_job(self, mock_rs, mock_discover, mock_fire, mock_save): 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): diff --git a/src/aipass/daemon/tests/test_schedule_module.py b/src/aipass/daemon/tests/test_schedule_module.py deleted file mode 100644 index 3f2bf336..00000000 --- a/src/aipass/daemon/tests/test_schedule_module.py +++ /dev/null @@ -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 diff --git a/src/aipass/daemon/tests/test_scheduler_bot.py b/src/aipass/daemon/tests/test_scheduler_bot.py new file mode 100644 index 00000000..eaee3127 --- /dev/null +++ b/src/aipass/daemon/tests/test_scheduler_bot.py @@ -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 diff --git a/src/aipass/daemon/tests/test_scheduler_cron.py b/src/aipass/daemon/tests/test_scheduler_cron.py deleted file mode 100644 index 2ae13ef9..00000000 --- a/src/aipass/daemon/tests/test_scheduler_cron.py +++ /dev/null @@ -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 diff --git a/src/aipass/daemon/tests/test_scheduler_ops.py b/src/aipass/daemon/tests/test_scheduler_ops.py deleted file mode 100644 index 9be64627..00000000 --- a/src/aipass/daemon/tests/test_scheduler_ops.py +++ /dev/null @@ -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) diff --git a/src/aipass/daemon/tests/test_task_registry.py b/src/aipass/daemon/tests/test_task_registry.py deleted file mode 100644 index d27696d5..00000000 --- a/src/aipass/daemon/tests/test_task_registry.py +++ /dev/null @@ -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) diff --git a/src/aipass/daemon/tests/test_timer_install.py b/src/aipass/daemon/tests/test_timer_install.py new file mode 100644 index 00000000..3fe7cbc3 --- /dev/null +++ b/src/aipass/daemon/tests/test_timer_install.py @@ -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() diff --git a/src/aipass/drone/.seedgo/bypass.json b/src/aipass/drone/.seedgo/bypass.json index 45467787..7f4124ef 100644 --- a/src/aipass/drone/.seedgo/bypass.json +++ b/src/aipass/drone/.seedgo/bypass.json @@ -252,25 +252,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." } ], diff --git a/src/aipass/flow/README.md b/src/aipass/flow/README.md index 42413763..369fae7b 100644 --- a/src/aipass/flow/README.md +++ b/src/aipass/flow/README.md @@ -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 `/.backup/processed_plans/`, a shared runtime namespace managed by `@backup` (see `src/aipass/backup/README.md`) and consumed by `@memory` for vectorization. + --- ## Integration Points diff --git a/src/aipass/hooks/.seedgo/bypass.json b/src/aipass/hooks/.seedgo/bypass.json index 892f2145..56607918 100644 --- a/src/aipass/hooks/.seedgo/bypass.json +++ b/src/aipass/hooks/.seedgo/bypass.json @@ -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,81 @@ "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." } ], "notes": { diff --git a/src/aipass/hooks/README.md b/src/aipass/hooks/README.md index d11597ca..e46bda2c 100644 --- a/src/aipass/hooks/README.md +++ b/src/aipass/hooks/README.md @@ -52,6 +52,7 @@ src/aipass/hooks/ │ │ ├── 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 +65,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 +84,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 +105,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 +153,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* --- diff --git a/src/aipass/hooks/apps/handlers/bridges/claude.py b/src/aipass/hooks/apps/handlers/bridges/claude.py index 384f8577..35321ae7 100644 --- a/src/aipass/hooks/apps/handlers/bridges/claude.py +++ b/src/aipass/hooks/apps/handlers/bridges/claude.py @@ -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__": diff --git a/src/aipass/hooks/apps/handlers/notification/telegram_response.py b/src/aipass/hooks/apps/handlers/notification/telegram_response.py index cd336694..8ea70900 100644 --- a/src/aipass/hooks/apps/handlers/notification/telegram_response.py +++ b/src/aipass/hooks/apps/handlers/notification/telegram_response.py @@ -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 3: Transcript position — only extracts text after the recorded injection point """ +import hashlib import json import os import re @@ -30,12 +31,16 @@ 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 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 [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 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) diff --git a/src/aipass/hooks/apps/handlers/security/presence_gate.py b/src/aipass/hooks/apps/handlers/security/presence_gate.py new file mode 100644 index 00000000..d8614724 --- /dev/null +++ b/src/aipass/hooks/apps/handlers/security/presence_gate.py @@ -0,0 +1,97 @@ +# =================== AIPass ==================== +# Name: presence_gate.py +# Version: 1.0.0 +# Description: Single-session gate — blocks duplicate Claude runtimes per branch +# Branch: hooks +# Layer: apps/handlers/security +# Created: 2026-06-29 +# Modified: 2026-06-29 +# ============================================= + +"""Single-session gate — blocks duplicate Claude runtimes per branch. + +Fires on UserPromptSubmit: calls presence.claim(). If OCCUPIED by a live PID, +blocks the prompt. If free or stale, acquires and proceeds. + +Fires on Stop: calls presence.release() to clean up. + +Skips sub-agents and dispatched/daemon session types. +""" + +import importlib +import json +import os +from pathlib import Path + +from aipass.prax.apps.modules.logger import system_logger as logger + +_ALLOW = {"exit_code": 0, "stdout": ""} +_NON_BLOCKING_SESSION_TYPES = frozenset({"dispatched", "daemon"}) + + +def _resolve_branch(hook_data: dict) -> str: + """Resolve the branch name from hook_data's cwd (session dir, not process cwd).""" + cwd = hook_data.get("cwd", "") or str(Path.cwd()) + search = Path(cwd).resolve() + while search.parent != search: + if (search / ".trinity").is_dir() or (search / "apps").is_dir(): + return search.name + if (search / "pyproject.toml").exists() or (search / ".git").is_dir(): + break + search = search.parent + return Path(cwd).name + + +def handle(hook_data: dict) -> dict: + """UserPromptSubmit gate — enforce one live session per branch. + + Args: + hook_data: Parsed hook event dict from engine. + + Returns: + Result dict with stdout (block JSON or empty) and exit_code. + """ + try: + agent_type = hook_data.get("agent_type", "") + if agent_type and agent_type != "main": + return _ALLOW + + session_type = os.environ.get("AIPASS_SESSION_TYPE", "interactive") + if session_type in _NON_BLOCKING_SESSION_TYPES: + return _ALLOW + + branch = _resolve_branch(hook_data) + session_id = os.environ.get("CLAUDE_CODE_SESSION_ID", "") + + presence = importlib.import_module("aipass.hooks.apps.modules.presence") + result = presence.claim( + branch=branch, + session_id=session_id, + session_type=session_type, + ) + + if result["status"] == "ACQUIRED": + return _ALLOW + + pid = result.get("pid", "?") + holder_type = result.get("session_type", "unknown") + reason = f"{branch} already live at PID {pid} (session_type: {holder_type}) — attach, do not spawn." + logger.warning("[presence_gate] BLOCKED: %s", reason) + return { + "exit_code": 2, + "stdout": json.dumps({"decision": "block", "reason": reason}), + "sound": "presence gate", + } + except Exception as exc: + logger.warning("[presence_gate] gate error (allowing): %s", exc) + return _ALLOW + + +def handle_stop(hook_data: dict) -> dict: + """No-op on Stop. Presence is NOT released per-turn. + + Stop fires at the end of every assistant turn, not just session end. + Releasing each turn would create a gap where a 2nd session wouldn't be blocked. + Stale detection (dead claude PID) handles cleanup when the session truly exits. + """ + return _ALLOW diff --git a/src/aipass/hooks/apps/modules/engine.py b/src/aipass/hooks/apps/modules/engine.py index c46727aa..3c8d936f 100644 --- a/src/aipass/hooks/apps/modules/engine.py +++ b/src/aipass/hooks/apps/modules/engine.py @@ -89,17 +89,17 @@ def _matches(matcher: str, value: str) -> bool: return value in matcher.split("|") -def dispatch(event_type: str, stdin_data: str, config: dict) -> str: - """Core dispatch — run hooks for event, return merged stdout.""" +def dispatch(event_type: str, stdin_data: str, config: dict) -> tuple[str, int]: + """Core dispatch — run hooks for event, return (merged_stdout, exit_code).""" if not config.get("hooks_enabled", True): logger.info("[HOOKS] all hooks disabled") _log({"ts": time.time(), "event": event_type, "action": "all_hooks_disabled"}) - return "" + return "", 0 event_hooks = config.get(event_type, {}) if not event_hooks: _log({"ts": time.time(), "event": event_type, "action": "no_hooks_configured"}) - return "" + return "", 0 match_value = "" parsed = {} @@ -196,7 +196,7 @@ def dispatch(event_type: str, stdin_data: str, config: dict) -> str: "total_ms": round(total_ms, 1), } ) - return result["stdout"] + return result["stdout"], 2 logger.error( "[HOOKS] %s.%s CRASHED exit=2: %s", @@ -229,7 +229,7 @@ def dispatch(event_type: str, stdin_data: str, config: dict) -> str: } ) - return "\n".join(outputs) + return "\n".join(outputs), 0 # ============================================================================= diff --git a/src/aipass/hooks/apps/modules/presence.py b/src/aipass/hooks/apps/modules/presence.py new file mode 100644 index 00000000..569af348 --- /dev/null +++ b/src/aipass/hooks/apps/modules/presence.py @@ -0,0 +1,416 @@ +# =================== AIPass ==================== +# Name: presence.py +# Version: 1.0.0 +# Description: Branch presence service — claim/release/refresh for .ai_central/PRESENCE.central.json +# Branch: hooks +# Layer: apps/modules +# Created: 2026-06-29 +# Modified: 2026-06-29 +# ============================================= + +"""Branch presence service for concurrent session detection. + +Manages a shared PRESENCE.central.json in .ai_central/ at the AIPass project root. +Each branch can claim presence (one live session per branch), detect stale holders +via PID liveness + /proc/cwd verification, and release on exit. + +File locking uses flock (POSIX) / msvcrt (Windows) to prevent concurrent corruption. +""" + +import json +import os +import sys +from contextlib import contextmanager +from datetime import datetime +from pathlib import Path + +from aipass.cli.apps.modules import err_console +from aipass.prax.apps.modules.logger import system_logger as logger + +CONSOLE = err_console + +# Cross-platform flock +if sys.platform == "win32": + import msvcrt +else: + import fcntl + + +# --------------------------------------------------------------------------- +# Path resolution +# --------------------------------------------------------------------------- + + +def _find_ai_central() -> Path: + """Walk up from this file to find the directory containing .ai_central/.""" + current = Path(__file__).resolve().parent + for _ in range(20): # safety cap + candidate = current / ".ai_central" + if candidate.is_dir(): + return candidate + if current.parent == current: + break + current = current.parent + raise FileNotFoundError("Cannot locate .ai_central/ directory from presence module") + + +_PRESENCE_FILE_NAME = "PRESENCE.central.json" +_LOCK_FILE_NAME = ".presence.lock" + + +def _presence_path() -> Path: + """Return path to the PRESENCE.central.json file.""" + return _find_ai_central() / _PRESENCE_FILE_NAME + + +def _lock_path() -> Path: + """Return path to the .presence.lock file.""" + return _find_ai_central() / _LOCK_FILE_NAME + + +# --------------------------------------------------------------------------- +# File locking context manager +# --------------------------------------------------------------------------- + + +def _release_lock_fd(lock_fd) -> None: + """Release and close a lock file descriptor.""" + try: + if sys.platform == "win32": + msvcrt.locking(lock_fd.fileno(), msvcrt.LK_UNLCK, 1) + else: + fcntl.flock(lock_fd.fileno(), fcntl.LOCK_UN) + lock_fd.close() + except Exception as exc: + logger.warning("[PRESENCE] lock release failed: %s", exc) + try: + lock_fd.close() + except Exception as close_exc: + logger.warning("[PRESENCE] lock file close failed: %s", close_exc) + + +@contextmanager +def _presence_lock(): + """Acquire exclusive lock on the presence file for read-modify-write.""" + lock_file = _lock_path() + lock_fd = None + try: + lock_fd = open(lock_file, "w", encoding="utf-8") + if sys.platform == "win32": + msvcrt.locking(lock_fd.fileno(), msvcrt.LK_LOCK, 1) + else: + fcntl.flock(lock_fd.fileno(), fcntl.LOCK_EX) + yield + finally: + if lock_fd is not None: + _release_lock_fd(lock_fd) + + +# --------------------------------------------------------------------------- +# JSON read/write helpers +# --------------------------------------------------------------------------- + + +def _read_presence() -> dict: + """Read the presence JSON. Returns empty dict if missing or corrupt.""" + fp = _presence_path() + if not fp.exists(): + return {} + try: + return json.loads(fp.read_text(encoding="utf-8")) + except (json.JSONDecodeError, OSError) as exc: + logger.warning("[PRESENCE] Failed to read %s: %s", fp, exc) + return {} + + +def _write_presence(data: dict) -> None: + """Write the presence JSON.""" + fp = _presence_path() + fp.parent.mkdir(parents=True, exist_ok=True) + fp.write_text(json.dumps(data, indent=2), encoding="utf-8") + + +# --------------------------------------------------------------------------- +# Session PID resolution +# --------------------------------------------------------------------------- + + +def _read_proc_comm(pid: int) -> str: + """Read /proc//comm. Returns empty string on failure.""" + try: + return Path(f"/proc/{pid}/comm").read_text().strip() + except OSError as exc: + logger.info("[PRESENCE] Cannot read /proc/%d/comm: %s", pid, exc) + return "" + + +def _read_proc_ppid(pid: int) -> int | None: + """Read PPid from /proc//status. Returns None on failure.""" + try: + for line in Path(f"/proc/{pid}/status").read_text().splitlines(): + if line.startswith("PPid:"): + return int(line.split()[1]) + except OSError as exc: + logger.info("[PRESENCE] Cannot read /proc/%d/status: %s", pid, exc) + return None + + +def _resolve_session_pid() -> int | None: + """Walk the parent process chain to find the persistent claude session PID. + + The hook runs as an ephemeral subprocess — os.getpid() gives a PID that dies + in milliseconds. The owning claude session is a parent process with comm=claude. + Linux only (/proc). Returns None on non-Linux or if no claude ancestor found. + """ + if sys.platform != "linux": + return None + pid = os.getpid() + ancestors = [] + for _ in range(12): + comm = _read_proc_comm(pid) + if not comm: + break + ancestors.append(f"{pid}:{comm}") + if comm == "claude": + logger.info("[PRESENCE] Resolved session PID: %d (chain: %s)", pid, " -> ".join(ancestors)) + return pid + ppid = _read_proc_ppid(pid) + if not ppid or ppid == pid: + break + pid = ppid + logger.info("[PRESENCE] No claude ancestor found (chain: %s)", " -> ".join(ancestors)) + return None + + +# --------------------------------------------------------------------------- +# Liveness detection +# --------------------------------------------------------------------------- + + +def _is_pid_alive(pid: int) -> bool: + """Check if a process with the given PID exists.""" + try: + os.kill(pid, 0) + return True + except ProcessLookupError: + logger.info("[PRESENCE] PID %d not found (dead)", pid) + return False + except PermissionError: + logger.info("[PRESENCE] PID %d exists but permission denied — treating as alive", pid) + return True + except OSError as exc: + logger.info("[PRESENCE] PID %d os.kill failed: %s — treating as dead", pid, exc) + return False + + +def _cwd_matches(pid: int, expected_dir: str) -> bool: + """Check if the process CWD matches the expected branch directory. + + Linux only — reads /proc//cwd. + On non-Linux platforms, returns True (skip the check, rely on os.kill alone). + """ + if sys.platform != "linux": + return True + try: + actual_cwd = os.readlink(f"/proc/{pid}/cwd") + return str(Path(actual_cwd).resolve()) == str(Path(expected_dir).resolve()) + except (OSError, PermissionError): + logger.info("[PRESENCE] Cannot read /proc/%d/cwd — treating as stale", pid) + return False + + +def _is_holder_alive(entry: dict) -> bool: + """Determine if the holder recorded in the presence entry is still alive. + + A holder is alive if: + 1. The PID exists (os.kill signal 0) + 2. AND the PID's CWD matches the branch work_dir (Linux /proc check) + + If either check fails, the holder is stale and can be reclaimed. + """ + pid = entry.get("pid") + if pid is None: + return False + if not _is_pid_alive(pid): + return False + work_dir = entry.get("work_dir", "") + if not work_dir: + return False + return _cwd_matches(pid, work_dir) + + +# --------------------------------------------------------------------------- +# Public API +# --------------------------------------------------------------------------- + + +def claim( + branch: str, + session_id: str = "", + session_type: str = "interactive", + attach_handle: str = "", +) -> dict: + """Claim presence for a branch. + + Records the persistent claude session PID (resolved via parent chain), + not the ephemeral hook subprocess PID. Fails OPEN if no claude ancestor found. + + Returns: + {"status": "ACQUIRED"} on success, or + {"status": "OCCUPIED", "pid": N, "session_id": "...", + "work_dir": "...", "session_type": "..."} + when a live session already owns the branch. + """ + session_pid = _resolve_session_pid() + if session_pid is None: + logger.info("[PRESENCE] No claude session PID resolved — allowing (fail-open)") + return {"status": "ACQUIRED"} + + now = datetime.now().strftime("%Y-%m-%dT%H:%M:%S") + cwd = os.getcwd() + + with _presence_lock(): + data = _read_presence() + existing = data.get(branch) + + if existing: + result = _handle_existing(data, existing, branch, session_pid, now, session_id) + if result is not None: + return result + + data[branch] = { + "pid": session_pid, + "session_id": session_id, + "work_dir": cwd, + "session_type": session_type, + "attach_handle": attach_handle, + "started": now, + "last_seen": now, + } + _write_presence(data) + logger.info("[PRESENCE] Acquired %s (session PID %d)", branch, session_pid) + return {"status": "ACQUIRED"} + + +def _handle_existing( + data: dict, + existing: dict, + branch: str, + my_pid: int, + now: str, + session_id: str, +) -> dict | None: + """Handle an existing presence entry. Returns a result dict or None to proceed.""" + holder_pid = existing.get("pid") + + # Re-entry: same PID already holds it + if holder_pid == my_pid: + existing["last_seen"] = now + existing["session_id"] = session_id or existing.get("session_id", "") + _write_presence(data) + logger.info("[PRESENCE] Re-entry for %s (PID %d)", branch, my_pid) + return {"status": "ACQUIRED"} + + # Check if the holder is still alive + if _is_holder_alive(existing): + logger.info( + "[PRESENCE] %s occupied by PID %d (session_type=%s)", + branch, + holder_pid, + existing.get("session_type", "unknown"), + ) + return { + "status": "OCCUPIED", + "pid": holder_pid, + "session_id": existing.get("session_id", ""), + "work_dir": existing.get("work_dir", ""), + "session_type": existing.get("session_type", ""), + } + + # Stale holder — let caller reclaim + logger.info( + "[PRESENCE] Stale holder for %s (PID %d) — reclaiming", + branch, + holder_pid, + ) + return None + + +def release(branch: str) -> bool: + """Release presence for a branch. Only the holder session can release its own entry.""" + session_pid = _resolve_session_pid() + if session_pid is None: + logger.info("[PRESENCE] Release skipped for %s — no claude session PID resolved", branch) + return False + with _presence_lock(): + data = _read_presence() + if branch not in data: + return False + if data[branch].get("pid") != session_pid: + logger.info( + "[PRESENCE] Release skipped for %s — not our session (ours=%d, holder=%d)", + branch, + session_pid, + data[branch].get("pid", 0), + ) + return False + del data[branch] + _write_presence(data) + logger.info("[PRESENCE] Released %s (session PID %d)", branch, session_pid) + return True + + +def refresh(branch: str) -> None: + """Update last_seen timestamp for the branch entry.""" + now = datetime.now().strftime("%Y-%m-%dT%H:%M:%S") + with _presence_lock(): + data = _read_presence() + if branch in data: + data[branch]["last_seen"] = now + _write_presence(data) + logger.info("[PRESENCE] Refreshed %s", branch) + + +def read_all() -> dict: + """Return the full presence JSON (all branches).""" + return _read_presence() + + +# ============================================================================= +# MODULE INTERFACE (drone @hooks routing) +# ============================================================================= + + +def print_introspection() -> None: + """Print presence state for drone routing.""" + CONSOLE.print("[bold cyan]presence[/bold cyan] Module") + try: + data = _read_presence() + if not data: + CONSOLE.print(" No branches currently present") + else: + for branch, entry in data.items(): + pid = entry.get("pid", "?") + stype = entry.get("session_type", "unknown") + last = entry.get("last_seen", "?") + alive = _is_holder_alive(entry) + status = "[green]live[/green]" if alive else "[dim]stale[/dim]" + CONSOLE.print(f" {branch}: PID {pid} type={stype} last_seen={last} {status}") + except FileNotFoundError as exc: + logger.info("[PRESENCE] introspection: %s", exc) + CONSOLE.print(" .ai_central/ not found") + + +def handle_command(command: str, args: list) -> bool: + """Route presence commands from drone @hooks.""" + if command in ("--help", "-h", "help"): + CONSOLE.print("[bold cyan]presence[/bold cyan] — Branch presence claim/release service") + CONSOLE.print() + CONSOLE.print(" drone @hooks presence Show current presence state for all branches") + return True + + if command == "presence": + if not args: + print_introspection() + return True + return False diff --git a/src/aipass/hooks/tests/test_engine.py b/src/aipass/hooks/tests/test_engine.py index 9e0bbf6e..365948d2 100644 --- a/src/aipass/hooks/tests/test_engine.py +++ b/src/aipass/hooks/tests/test_engine.py @@ -107,13 +107,15 @@ def test_hooks_disabled_returns_empty(self, mock_logger): config = {"hooks_enabled": False} with patch("aipass.hooks.apps.modules.engine._log"): result = dispatch("UserPromptSubmit", "{}", config) - assert result == "" + assert result[0] == "" + assert result[1] == 0 def test_no_hooks_for_event_returns_empty(self, mock_logger): config = {"hooks_enabled": True} with patch("aipass.hooks.apps.modules.engine._log"): result = dispatch("UnknownEvent", "{}", config) - assert result == "" + assert result[0] == "" + assert result[1] == 0 def test_disabled_hook_skipped(self, mock_logger): config = { @@ -130,7 +132,8 @@ def test_disabled_hook_skipped(self, mock_logger): with patch("aipass.hooks.apps.modules.engine._run_hook") as mock_run: result = dispatch("PreToolUse", '{"tool_name":"Edit"}', config) mock_run.assert_not_called() - assert result == "" + assert result[0] == "" + assert result[1] == 0 def test_matcher_filters_hooks(self, mock_logger): config = { @@ -165,7 +168,8 @@ def test_matching_hook_fires(self, mock_logger): mock_run.return_value = {"exit_code": 0, "stdout": "edit_output", "stderr": "", "elapsed_ms": 10} result = dispatch("PreToolUse", '{"tool_name":"Edit"}', config) mock_run.assert_called_once() - assert "edit_output" in result + assert "edit_output" in result[0] + assert result[1] == 0 def test_multiple_hooks_concatenate_output(self, mock_logger): config = { @@ -182,8 +186,9 @@ def test_multiple_hooks_concatenate_output(self, mock_logger): {"exit_code": 0, "stdout": "output_B", "stderr": "", "elapsed_ms": 10}, ] result = dispatch("UserPromptSubmit", '{"user_prompt":"test"}', config) - assert "output_A" in result - assert "output_B" in result + assert "output_A" in result[0] + assert "output_B" in result[0] + assert result[1] == 0 def test_exit2_with_block_json_bails(self, mock_logger): config = { @@ -199,8 +204,9 @@ def test_exit2_with_block_json_bails(self, mock_logger): mock_run.return_value = {"exit_code": 2, "stdout": block_json, "stderr": "", "elapsed_ms": 10} result = dispatch("PreToolUse", '{"tool_name":"Edit"}', config) assert mock_run.call_count == 1 - parsed = json.loads(result) + parsed = json.loads(result[0]) assert parsed["decision"] == "block" + assert result[1] == 2 def test_exit2_without_json_is_crash_not_block(self, mock_logger): config = { @@ -218,7 +224,8 @@ def test_exit2_without_json_is_crash_not_block(self, mock_logger): ] result = dispatch("PreToolUse", '{"tool_name":"Edit"}', config) assert mock_run.call_count == 2 - assert "survived" in result + assert "survived" in result[0] + assert result[1] == 0 def test_hook_with_custom_timeout(self, mock_logger): config = { @@ -261,7 +268,8 @@ def test_malformed_stdin_does_not_crash(self, mock_logger): with patch("aipass.hooks.apps.modules.engine._run_hook") as mock_run: mock_run.return_value = {"exit_code": 0, "stdout": "ok", "stderr": "", "elapsed_ms": 5} result = dispatch("UserPromptSubmit", "not json at all{{{", config) - assert "ok" in result + assert "ok" in result[0] + assert result[1] == 0 class TestFindProjectConfig: @@ -448,7 +456,7 @@ class TestExceptionContracts: def test_dispatch_with_none_config_event_returns_empty(self, mock_logger): with patch("aipass.hooks.apps.modules.engine._log"): result = dispatch("Stop", "{}", {"hooks_enabled": True}) - assert result == "" + assert result == ("", 0) def test_run_hook_timeout_returns_negative_exit(self, mock_subprocess, mock_logger): mock_subprocess.side_effect = subprocess.TimeoutExpired("cmd", 30) @@ -508,10 +516,13 @@ def test_log_no_overwrite_on_append(self, temp_test_dir, mock_logger): assert len(lines) == 2 assert json.loads(lines[0])["existing"] is True - def test_dispatch_returns_string(self, mock_logger): + def test_dispatch_returns_tuple(self, mock_logger): with patch("aipass.hooks.apps.modules.engine._log"): result = dispatch("Stop", "{}", {"hooks_enabled": True}) - assert isinstance(result, str) + assert isinstance(result, tuple) + assert isinstance(result[0], str) + assert isinstance(result[1], int) + assert result == ("", 0) class TestConftest: @@ -666,7 +677,8 @@ def test_dispatch_with_empty_stdin(self, mock_logger): with patch("aipass.hooks.apps.modules.engine._run_hook") as mock_run: mock_run.return_value = {"exit_code": 0, "stdout": "ok", "stderr": "", "elapsed_ms": 5} result = dispatch("Stop", "", config) - assert "ok" in result + assert "ok" in result[0] + assert result[1] == 0 def test_config_with_nonexistent_dir(self, temp_test_dir, mock_logger): nonexistent = temp_test_dir / "does_not_exist" diff --git a/src/aipass/hooks/tests/test_presence.py b/src/aipass/hooks/tests/test_presence.py new file mode 100644 index 00000000..3d551f30 --- /dev/null +++ b/src/aipass/hooks/tests/test_presence.py @@ -0,0 +1,536 @@ +"""Tests for the presence service module.""" + +import json +import sys +from contextlib import nullcontext +from unittest.mock import patch + +import pytest + +from aipass.hooks.apps.modules import presence + + +@pytest.fixture +def presence_dir(tmp_path): + """Create a temporary .ai_central directory with PRESENCE.central.json.""" + ai_central = tmp_path / ".ai_central" + ai_central.mkdir() + return ai_central + + +@pytest.fixture +def presence_file(presence_dir): + """Return path to the presence file.""" + return presence_dir / "PRESENCE.central.json" + + +@pytest.fixture +def patch_paths(presence_dir): + """Patch _find_ai_central to use the temp directory.""" + with patch.object(presence, "_find_ai_central", return_value=presence_dir): + yield presence_dir + + +@pytest.fixture +def patch_flock(): + """Make the presence lock a no-op (avoid real file locking in tests).""" + with patch.object(presence, "_presence_lock", side_effect=lambda: nullcontext()): + yield + + +def _patch_session_pid(pid): + """Patch _resolve_session_pid to return a given PID.""" + return patch.object(presence, "_resolve_session_pid", return_value=pid) + + +# ── session PID resolution tests ──────────────────────────────────────── + + +class TestResolveSessionPid: + def test_finds_claude_ancestor(self): + comm_map = {100: "python3", 90: "bash", 80: "claude"} + ppid_map = {100: 90, 90: 80} + with ( + patch("sys.platform", "linux"), + patch("os.getpid", return_value=100), + patch.object(presence, "_read_proc_comm", side_effect=lambda p: comm_map.get(p, "")), + patch.object(presence, "_read_proc_ppid", side_effect=lambda p: ppid_map.get(p)), + ): + assert presence._resolve_session_pid() == 80 + + def test_no_claude_ancestor_returns_none(self): + comm_map = {100: "python3", 90: "bash", 80: "init"} + ppid_map = {100: 90, 90: 80, 80: 1} + with ( + patch("sys.platform", "linux"), + patch("os.getpid", return_value=100), + patch.object(presence, "_read_proc_comm", side_effect=lambda p: comm_map.get(p, "")), + patch.object(presence, "_read_proc_ppid", side_effect=lambda p: ppid_map.get(p)), + ): + assert presence._resolve_session_pid() is None + + def test_non_linux_returns_none(self): + with patch("sys.platform", "win32"): + assert presence._resolve_session_pid() is None + + def test_proc_read_failure_returns_none(self): + with ( + patch("sys.platform", "linux"), + patch("os.getpid", return_value=100), + patch.object(presence, "_read_proc_comm", return_value=""), + ): + assert presence._resolve_session_pid() is None + + def test_direct_claude_process(self): + with ( + patch("sys.platform", "linux"), + patch("os.getpid", return_value=100), + patch.object(presence, "_read_proc_comm", return_value="claude"), + ): + assert presence._resolve_session_pid() == 100 + + +# ── claim tests ────────────────────────────────────────────────────────── + + +class TestClaim: + def test_claim_empty_file(self, patch_paths, patch_flock, presence_file): + with _patch_session_pid(1000), patch("os.getcwd", return_value="/tmp/branch"): + result = presence.claim("devpulse", session_id="abc") + assert result["status"] == "ACQUIRED" + data = json.loads(presence_file.read_text()) + assert data["devpulse"]["pid"] == 1000 + assert data["devpulse"]["session_id"] == "abc" + assert data["devpulse"]["work_dir"] == "/tmp/branch" + + def test_claim_reentry_same_pid(self, patch_paths, patch_flock, presence_file): + presence_file.write_text( + json.dumps( + { + "devpulse": { + "pid": 1000, + "session_id": "old", + "work_dir": "/w", + "session_type": "interactive", + "attach_handle": "", + "started": "2026-01-01T00:00:00", + "last_seen": "2026-01-01T00:00:00", + } + } + ) + ) + with _patch_session_pid(1000), patch("os.getcwd", return_value="/w"): + result = presence.claim("devpulse", session_id="new-id") + assert result["status"] == "ACQUIRED" + data = json.loads(presence_file.read_text()) + assert data["devpulse"]["session_id"] == "new-id" + + def test_claim_stale_dead_pid(self, patch_paths, patch_flock, presence_file): + presence_file.write_text( + json.dumps( + { + "devpulse": { + "pid": 9999, + "session_id": "old", + "work_dir": "/w", + "session_type": "interactive", + "attach_handle": "", + "started": "2026-01-01T00:00:00", + "last_seen": "2026-01-01T00:00:00", + } + } + ) + ) + with ( + _patch_session_pid(2000), + patch("os.getcwd", return_value="/w2"), + patch.object(presence, "_is_pid_alive", return_value=False), + ): + result = presence.claim("devpulse", session_id="new") + assert result["status"] == "ACQUIRED" + data = json.loads(presence_file.read_text()) + assert data["devpulse"]["pid"] == 2000 + + def test_claim_occupied_live_pid(self, patch_paths, patch_flock, presence_file): + presence_file.write_text( + json.dumps( + { + "devpulse": { + "pid": 5000, + "session_id": "live", + "work_dir": "/w", + "session_type": "interactive", + "attach_handle": "", + "started": "2026-01-01T00:00:00", + "last_seen": "2026-01-01T00:00:00", + } + } + ) + ) + with ( + _patch_session_pid(6000), + patch("os.getcwd", return_value="/w2"), + patch.object(presence, "_is_pid_alive", return_value=True), + patch.object(presence, "_cwd_matches", return_value=True), + ): + result = presence.claim("devpulse", session_id="new") + assert result["status"] == "OCCUPIED" + assert result["pid"] == 5000 + assert result["session_type"] == "interactive" + + def test_claim_stale_cwd_mismatch(self, patch_paths, patch_flock, presence_file): + presence_file.write_text( + json.dumps( + { + "devpulse": { + "pid": 5000, + "session_id": "old", + "work_dir": "/original", + "session_type": "interactive", + "attach_handle": "", + "started": "2026-01-01T00:00:00", + "last_seen": "2026-01-01T00:00:00", + } + } + ) + ) + with ( + _patch_session_pid(6000), + patch("os.getcwd", return_value="/new"), + patch.object(presence, "_is_pid_alive", return_value=True), + patch.object(presence, "_cwd_matches", return_value=False), + ): + result = presence.claim("devpulse", session_id="new") + assert result["status"] == "ACQUIRED" + + def test_claim_no_existing_file(self, patch_paths, patch_flock): + with _patch_session_pid(1000), patch("os.getcwd", return_value="/w"): + result = presence.claim("hooks") + assert result["status"] == "ACQUIRED" + + def test_claim_multiple_branches(self, patch_paths, patch_flock, presence_file): + presence_file.write_text( + json.dumps( + { + "api": { + "pid": 3000, + "session_id": "a", + "work_dir": "/api", + "session_type": "interactive-mirror", + "attach_handle": "", + "started": "2026-01-01T00:00:00", + "last_seen": "2026-01-01T00:00:00", + } + } + ) + ) + with _patch_session_pid(4000), patch("os.getcwd", return_value="/hooks"): + result = presence.claim("hooks", session_id="h1") + assert result["status"] == "ACQUIRED" + data = json.loads(presence_file.read_text()) + assert "api" in data + assert "hooks" in data + + def test_claim_fails_open_when_no_session_pid(self, patch_paths, patch_flock): + with _patch_session_pid(None): + result = presence.claim("devpulse") + assert result["status"] == "ACQUIRED" + + def test_claim_ephemeral_holder_reclaimed(self, patch_paths, patch_flock, presence_file): + """Models the real ephemeral-PID scenario: holder PID is dead (hook exited), + but a LIVE claude session exists. 2nd session reclaims.""" + presence_file.write_text( + json.dumps( + { + "devpulse": { + "pid": 99999, + "session_id": "old", + "work_dir": "/w", + "session_type": "interactive", + "attach_handle": "", + "started": "2026-01-01T00:00:00", + "last_seen": "2026-01-01T00:00:00", + } + } + ) + ) + with ( + _patch_session_pid(8000), + patch("os.getcwd", return_value="/w"), + patch.object(presence, "_is_pid_alive", return_value=False), + ): + result = presence.claim("devpulse", session_id="new") + assert result["status"] == "ACQUIRED" + data = json.loads(presence_file.read_text()) + assert data["devpulse"]["pid"] == 8000 + + +# ── release tests ──────────────────────────────────────────────────────── + + +class TestRelease: + def test_release_existing(self, patch_paths, patch_flock, presence_file): + presence_file.write_text( + json.dumps( + { + "devpulse": { + "pid": 1000, + "session_id": "a", + "work_dir": "/w", + "session_type": "interactive", + "attach_handle": "", + "started": "2026-01-01T00:00:00", + "last_seen": "2026-01-01T00:00:00", + } + } + ) + ) + with _patch_session_pid(1000): + result = presence.release("devpulse") + assert result is True + data = json.loads(presence_file.read_text()) + assert "devpulse" not in data + + def test_release_not_claimed(self, patch_paths, patch_flock, presence_file): + presence_file.write_text(json.dumps({})) + with _patch_session_pid(1000): + result = presence.release("devpulse") + assert result is False + + def test_release_wrong_pid_refused(self, patch_paths, patch_flock, presence_file): + presence_file.write_text( + json.dumps( + { + "devpulse": { + "pid": 1000, + "session_id": "a", + "work_dir": "/w", + "session_type": "interactive", + "attach_handle": "", + "started": "2026-01-01T00:00:00", + "last_seen": "2026-01-01T00:00:00", + } + } + ) + ) + with _patch_session_pid(9999): + result = presence.release("devpulse") + assert result is False + data = json.loads(presence_file.read_text()) + assert "devpulse" in data + assert data["devpulse"]["pid"] == 1000 + + def test_release_no_session_pid_skips(self, patch_paths, patch_flock, presence_file): + presence_file.write_text( + json.dumps( + { + "devpulse": { + "pid": 1000, + "session_id": "a", + "work_dir": "/w", + "session_type": "interactive", + "attach_handle": "", + "started": "2026-01-01T00:00:00", + "last_seen": "2026-01-01T00:00:00", + } + } + ) + ) + with _patch_session_pid(None): + result = presence.release("devpulse") + assert result is False + data = json.loads(presence_file.read_text()) + assert "devpulse" in data + + def test_release_preserves_others(self, patch_paths, patch_flock, presence_file): + presence_file.write_text( + json.dumps( + { + "devpulse": { + "pid": 1000, + "session_id": "a", + "work_dir": "/w", + "session_type": "interactive", + "attach_handle": "", + "started": "2026-01-01T00:00:00", + "last_seen": "2026-01-01T00:00:00", + }, + "api": { + "pid": 2000, + "session_id": "b", + "work_dir": "/api", + "session_type": "interactive-mirror", + "attach_handle": "", + "started": "2026-01-01T00:00:00", + "last_seen": "2026-01-01T00:00:00", + }, + } + ) + ) + with _patch_session_pid(1000): + presence.release("devpulse") + data = json.loads(presence_file.read_text()) + assert "api" in data + assert "devpulse" not in data + + +# ── refresh tests ──────────────────────────────────────────────────────── + + +class TestRefresh: + def test_refresh_updates_last_seen(self, patch_paths, patch_flock, presence_file): + presence_file.write_text( + json.dumps( + { + "hooks": { + "pid": 1000, + "session_id": "a", + "work_dir": "/w", + "session_type": "interactive", + "attach_handle": "", + "started": "2026-01-01T00:00:00", + "last_seen": "2026-01-01T00:00:00", + } + } + ) + ) + presence.refresh("hooks") + data = json.loads(presence_file.read_text()) + assert data["hooks"]["last_seen"] != "2026-01-01T00:00:00" + + def test_refresh_nonexistent_noop(self, patch_paths, patch_flock, presence_file): + presence_file.write_text(json.dumps({})) + presence.refresh("hooks") + data = json.loads(presence_file.read_text()) + assert data == {} + + +# ── read_all tests ─────────────────────────────────────────────────────── + + +class TestReadAll: + def test_read_all_returns_data(self, patch_paths, patch_flock, presence_file): + expected = { + "hooks": { + "pid": 1000, + "session_id": "a", + "work_dir": "/w", + "session_type": "interactive", + "attach_handle": "", + "started": "2026-01-01T00:00:00", + "last_seen": "2026-01-01T00:00:00", + } + } + presence_file.write_text(json.dumps(expected)) + result = presence.read_all() + assert result == expected + + def test_read_all_empty(self, patch_paths, patch_flock): + result = presence.read_all() + assert result == {} + + +# ── liveness tests ─────────────────────────────────────────────────────── + + +class TestLiveness: + def test_is_pid_alive_true(self): + with patch("os.kill") as mock_kill: + assert presence._is_pid_alive(1234) is True + mock_kill.assert_called_once_with(1234, 0) + + def test_is_pid_alive_dead(self): + with patch("os.kill", side_effect=ProcessLookupError): + assert presence._is_pid_alive(1234) is False + + def test_is_pid_alive_permission_error(self): + with patch("os.kill", side_effect=PermissionError): + assert presence._is_pid_alive(1234) is True + + def test_cwd_matches_linux(self): + with ( + patch("sys.platform", "linux"), + patch("os.readlink", return_value="/tmp/project/src/aipass/hooks"), + ): + assert presence._cwd_matches(1234, "/tmp/project/src/aipass/hooks") is True + + def test_cwd_mismatch_linux(self): + with ( + patch("sys.platform", "linux"), + patch("os.readlink", return_value="/tmp/project/src/aipass/api"), + ): + assert presence._cwd_matches(1234, "/tmp/project/src/aipass/hooks") is False + + def test_cwd_matches_non_linux_skips(self): + with patch("sys.platform", "win32"): + assert presence._cwd_matches(1234, "/anything") is True + + def test_cwd_read_fails_treats_as_stale(self): + with ( + patch("sys.platform", "linux"), + patch("os.readlink", side_effect=OSError("no proc")), + ): + assert presence._cwd_matches(1234, "/w") is False + + def test_is_holder_alive_full_check(self): + entry = {"pid": 1234, "work_dir": "/w"} + with ( + patch.object(presence, "_is_pid_alive", return_value=True), + patch.object(presence, "_cwd_matches", return_value=True), + ): + assert presence._is_holder_alive(entry) is True + + def test_is_holder_alive_dead_pid(self): + entry = {"pid": 1234, "work_dir": "/w"} + with patch.object(presence, "_is_pid_alive", return_value=False): + assert presence._is_holder_alive(entry) is False + + def test_is_holder_alive_no_pid(self): + assert presence._is_holder_alive({}) is False + + def test_is_holder_alive_no_work_dir(self): + entry = {"pid": 1234, "work_dir": ""} + with patch.object(presence, "_is_pid_alive", return_value=True): + assert presence._is_holder_alive(entry) is False + + +# ── proc helpers tests ─────────────────────────────────────────────────── + + +class TestProcHelpers: + def test_read_proc_comm_success(self, tmp_path): + comm_file = tmp_path / "comm" + comm_file.write_text("claude\n") + with patch("pathlib.Path.__truediv__", return_value=comm_file): + pass + with patch.object(presence.Path, "__new__", return_value=comm_file): + pass + result = presence._read_proc_comm(99999999) + assert result == "" or isinstance(result, str) + + def test_read_proc_comm_oserror(self): + result = presence._read_proc_comm(99999999) + assert result == "" + + def test_read_proc_ppid_oserror(self): + result = presence._read_proc_ppid(99999999) + assert result is None + + +# ── file locking tests ────────────────────────────────────────────────── + + +class TestFileLocking: + @pytest.mark.skipif(sys.platform == "win32", reason="fcntl is POSIX-only") + def test_presence_lock_acquires_flock(self, patch_paths): + with patch("aipass.hooks.apps.modules.presence.fcntl") as mock_fcntl: + mock_fcntl.LOCK_EX = 2 + mock_fcntl.LOCK_UN = 8 + with presence._presence_lock(): + pass + mock_fcntl.flock.assert_called() + + def test_corrupt_json_returns_empty(self, patch_paths, patch_flock, presence_file): + presence_file.write_text("not json {{{") + result = presence._read_presence() + assert result == {} diff --git a/src/aipass/hooks/tests/test_presence_gate.py b/src/aipass/hooks/tests/test_presence_gate.py new file mode 100644 index 00000000..e7bed511 --- /dev/null +++ b/src/aipass/hooks/tests/test_presence_gate.py @@ -0,0 +1,132 @@ +"""Tests for the presence gate handler.""" + +import json +import os +from unittest.mock import MagicMock, patch + +from aipass.hooks.apps.handlers.security import presence_gate + + +def _make_presence_mock(claim_result, release_result=True): + """Build a mock presence module with given claim/release return values.""" + mock = MagicMock() + mock.claim.return_value = claim_result + mock.release.return_value = release_result + return mock + + +_ACQUIRED_MOCK = _make_presence_mock({"status": "ACQUIRED"}) +_OCCUPIED_MOCK = _make_presence_mock( + { + "status": "OCCUPIED", + "pid": 5000, + "session_id": "existing", + "work_dir": "/tmp/branch", + "session_type": "interactive", + } +) + + +class TestResolveBranch: + def test_uses_hook_data_cwd(self, tmp_path): + branch_dir = tmp_path / "devpulse" + branch_dir.mkdir() + (branch_dir / ".trinity").mkdir() + assert presence_gate._resolve_branch({"cwd": str(branch_dir)}) == "devpulse" + + def test_walks_up_to_branch_root(self, tmp_path): + branch_dir = tmp_path / "hooks" + (branch_dir / "apps" / "modules").mkdir(parents=True) + sub = branch_dir / "apps" / "modules" + assert presence_gate._resolve_branch({"cwd": str(sub)}) == "hooks" + + def test_stops_at_repo_root(self, tmp_path): + (tmp_path / ".git").mkdir() + assert presence_gate._resolve_branch({"cwd": str(tmp_path)}) == tmp_path.name + + def test_fallback_to_path_cwd_when_no_cwd_in_hook_data(self): + result = presence_gate._resolve_branch({}) + assert isinstance(result, str) + assert len(result) > 0 + + +class TestHandle: + def test_first_prompt_acquired(self): + with patch.dict(os.environ, {"AIPASS_SESSION_TYPE": "interactive"}, clear=True): + with patch("importlib.import_module", return_value=_ACQUIRED_MOCK): + result = presence_gate.handle({}) + assert result["exit_code"] == 0 + + def test_occupied_blocks(self): + with patch.dict(os.environ, {"AIPASS_SESSION_TYPE": "interactive"}, clear=True): + with patch("importlib.import_module", return_value=_OCCUPIED_MOCK): + result = presence_gate.handle({}) + assert result["exit_code"] == 2 + parsed = json.loads(result["stdout"]) + assert parsed["decision"] == "block" + assert "5000" in parsed["reason"] + + def test_subagent_skipped(self): + result = presence_gate.handle({"agent_type": "sub"}) + assert result["exit_code"] == 0 + assert result["stdout"] == "" + + def test_main_agent_not_skipped(self): + with patch.dict(os.environ, {"AIPASS_SESSION_TYPE": "interactive"}, clear=True): + with patch("importlib.import_module", return_value=_ACQUIRED_MOCK): + result = presence_gate.handle({"agent_type": "main"}) + assert result["exit_code"] == 0 + + def test_dispatched_session_skipped(self): + with patch.dict(os.environ, {"AIPASS_SESSION_TYPE": "dispatched"}, clear=True): + result = presence_gate.handle({}) + assert result["exit_code"] == 0 + assert result["stdout"] == "" + + def test_daemon_session_skipped(self): + with patch.dict(os.environ, {"AIPASS_SESSION_TYPE": "daemon"}, clear=True): + result = presence_gate.handle({}) + assert result["exit_code"] == 0 + assert result["stdout"] == "" + + def test_resume_dead_holder_acquires(self): + with patch.dict(os.environ, {"AIPASS_SESSION_TYPE": "interactive"}, clear=True): + with patch("importlib.import_module", return_value=_ACQUIRED_MOCK): + result = presence_gate.handle({}) + assert result["exit_code"] == 0 + + def test_block_message_includes_branch(self, tmp_path): + branch_dir = tmp_path / "devpulse" + branch_dir.mkdir() + (branch_dir / ".trinity").mkdir() + with patch.dict(os.environ, {"AIPASS_SESSION_TYPE": "interactive"}, clear=True): + with patch("importlib.import_module", return_value=_OCCUPIED_MOCK): + result = presence_gate.handle({"cwd": str(branch_dir)}) + parsed = json.loads(result["stdout"]) + assert "devpulse" in parsed["reason"] + assert "attach" in parsed["reason"].lower() + + def test_branch_resolved_from_hook_data_cwd(self, tmp_path): + branch_dir = tmp_path / "api" + branch_dir.mkdir() + (branch_dir / ".trinity").mkdir() + mock = _make_presence_mock({"status": "ACQUIRED"}) + with patch.dict(os.environ, {"AIPASS_SESSION_TYPE": "interactive"}, clear=True): + with patch("importlib.import_module", return_value=mock): + presence_gate.handle({"cwd": str(branch_dir)}) + mock.claim.assert_called_once() + assert mock.claim.call_args[1]["branch"] == "api" + + +class TestHandleStop: + def test_stop_is_noop(self): + result = presence_gate.handle_stop({}) + assert result["exit_code"] == 0 + assert result["stdout"] == "" + + def test_stop_does_not_call_presence(self): + mock = _make_presence_mock({"status": "ACQUIRED"}) + with patch("importlib.import_module", return_value=mock): + presence_gate.handle_stop({}) + mock.release.assert_not_called() + mock.claim.assert_not_called() diff --git a/src/aipass/hooks/tests/test_telegram_response.py b/src/aipass/hooks/tests/test_telegram_response.py index 1d190691..3ca3ff9d 100644 --- a/src/aipass/hooks/tests/test_telegram_response.py +++ b/src/aipass/hooks/tests/test_telegram_response.py @@ -1,11 +1,11 @@ # =================== AIPass ==================== # Name: test_telegram_response.py -# Version: 1.0.0 +# Version: 2.0.0 # Description: Tests for telegram_response notification handler # Branch: hooks # Layer: tests # Created: 2026-06-15 -# Modified: 2026-06-15 +# Modified: 2026-06-29 # ============================================= """Tests for handlers/notification/telegram_response.py.""" @@ -56,10 +56,10 @@ def _make_pending(tmp_path: Path, name: str = "bot-123.json", **overrides) -> Pa return path -def _mock_urlopen_ok(): +def _mock_urlopen_ok(message_id=100, text="mocked"): """Return a context-manager mock whose read() returns Telegram ok response.""" resp = MagicMock() - resp.read.return_value = json.dumps({"ok": True}).encode() + resp.read.return_value = json.dumps({"ok": True, "result": {"message_id": message_id, "text": text}}).encode() resp.__enter__ = MagicMock(return_value=resp) resp.__exit__ = MagicMock(return_value=False) return resp @@ -74,6 +74,16 @@ def _mock_urlopen_fail(): return resp +def _ok_result(message_id=100, text="mocked"): + """Build a successful send/edit result dict.""" + return {"ok": True, "message_id": message_id, "text": text} + + +def _fail_result(): + """Build a failed send/edit result dict.""" + return {"ok": False} + + # =========================================================================== # Layer 1 defense — handle() early returns # =========================================================================== @@ -98,7 +108,7 @@ def test_subagent_transcript_path_returns_early(self): { "hook_event_name": "Stop", "session_id": "abc", - "transcript_path": "/home/user/.claude/sessions/subagents/12345.jsonl", + "transcript_path": str(Path.home() / ".claude/sessions/subagents/12345.jsonl"), } ) @@ -163,7 +173,6 @@ def test_cwd_relative_match(self, tmp_path): patch.dict("os.environ", {}, clear=True), patch(f"{MOD}.Path.cwd", return_value=work / "subdir"), ): - # subdir is relative to work_dir, so it should match result = find_pending_file("session-abc") assert result is not None @@ -650,7 +659,8 @@ def test_successful_html_send(self): with patch(LOGGER_PATCH), patch(f"{MOD}.urlopen", return_value=_mock_urlopen_ok()): result = send_to_telegram("tok:ABC", 123, "Hello") - assert result is True + assert result["ok"] is True + assert result["message_id"] == 100 def test_html_fails_plain_text_fallback_succeeds(self): from aipass.hooks.apps.handlers.notification.telegram_response import send_to_telegram @@ -667,7 +677,7 @@ def urlopen_side_effect(*args, **kwargs): with patch(LOGGER_PATCH), patch(f"{MOD}.urlopen", side_effect=urlopen_side_effect): result = send_to_telegram("tok:ABC", 123, "Hello **bold**") - assert result is True + assert result["ok"] is True assert call_count == 2 def test_html_fails_plain_text_also_fails(self): @@ -676,7 +686,7 @@ def test_html_fails_plain_text_also_fails(self): with patch(LOGGER_PATCH), patch(f"{MOD}.urlopen", side_effect=Exception("network error")): result = send_to_telegram("tok:ABC", 123, "Hello") - assert result is False + assert result["ok"] is False def test_http_error_handling(self): from aipass.hooks.apps.handlers.notification.telegram_response import send_to_telegram @@ -701,7 +711,7 @@ def urlopen_side_effect(*args, **kwargs): with patch(LOGGER_PATCH), patch(f"{MOD}.urlopen", side_effect=urlopen_side_effect): result = send_to_telegram("tok:ABC", 123, "Hello") - assert result is False + assert result["ok"] is False def test_url_error_handling(self): from aipass.hooks.apps.handlers.notification.telegram_response import send_to_telegram @@ -719,7 +729,7 @@ def urlopen_side_effect(*args, **kwargs): with patch(LOGGER_PATCH), patch(f"{MOD}.urlopen", side_effect=urlopen_side_effect): result = send_to_telegram("tok:ABC", 123, "Hello") - assert result is False + assert result["ok"] is False def test_reply_to_message_id_included(self): from aipass.hooks.apps.handlers.notification.telegram_response import send_to_telegram @@ -735,6 +745,14 @@ def urlopen_capture(req, **kwargs): assert captured_requests[0]["reply_to_message_id"] == 456 + def test_returns_message_text_from_api(self): + from aipass.hooks.apps.handlers.notification.telegram_response import send_to_telegram + + with patch(LOGGER_PATCH), patch(f"{MOD}.urlopen", return_value=_mock_urlopen_ok(text="returned")): + result = send_to_telegram("tok:ABC", 123, "Hello") + + assert result["text"] == "returned" + # =========================================================================== # edit_telegram_message @@ -750,7 +768,8 @@ def test_successful_html_edit(self): with patch(LOGGER_PATCH), patch(f"{MOD}.urlopen", return_value=_mock_urlopen_ok()): result = edit_telegram_message("tok:ABC", 123, 789, "Updated text") - assert result is True + assert result["ok"] is True + assert result["message_id"] == 100 def test_html_edit_fails_plain_text_fallback(self): from aipass.hooks.apps.handlers.notification.telegram_response import edit_telegram_message @@ -767,7 +786,7 @@ def urlopen_side_effect(*args, **kwargs): with patch(LOGGER_PATCH), patch(f"{MOD}.urlopen", side_effect=urlopen_side_effect): result = edit_telegram_message("tok:ABC", 123, 789, "Updated") - assert result is True + assert result["ok"] is True def test_both_fail_returns_false(self): from aipass.hooks.apps.handlers.notification.telegram_response import edit_telegram_message @@ -775,7 +794,7 @@ def test_both_fail_returns_false(self): with patch(LOGGER_PATCH), patch(f"{MOD}.urlopen", side_effect=Exception("total failure")): result = edit_telegram_message("tok:ABC", 123, 789, "Text") - assert result is False + assert result["ok"] is False def test_edit_url_uses_editMessageText(self): from aipass.hooks.apps.handlers.notification.telegram_response import edit_telegram_message @@ -800,11 +819,10 @@ def urlopen_capture(req, **kwargs): class TestHandleIntegration: """Full handle() flow integration tests.""" - def test_happy_path_send_and_cleanup(self, tmp_path): - """Full flow: pending exists -> extract -> send -> cleanup.""" + def test_happy_path_send_and_advance(self, tmp_path): + """Full flow: pending exists -> extract -> send -> advance cursor.""" from aipass.hooks.apps.handlers.notification.telegram_response import handle - # Set up transcript transcript = tmp_path / "transcript.jsonl" lines = [ _jsonl_line("user", "Hello"), @@ -812,7 +830,6 @@ def test_happy_path_send_and_cleanup(self, tmp_path): ] transcript.write_text("\n".join(lines), encoding="utf-8") - # Set up pending file pending_dir = tmp_path / "telegram_pending" pending_dir.mkdir() pending_data = { @@ -830,6 +847,7 @@ def test_happy_path_send_and_cleanup(self, tmp_path): patch(f"{MOD}.urlopen", return_value=_mock_urlopen_ok()), patch(f"{MOD}._check_log_streamer_active", return_value=False), patch(f"{MOD}.Path.cwd", return_value=tmp_path), + patch(f"{MOD}._write_delivery_log"), ): result = handle( { @@ -840,8 +858,10 @@ def test_happy_path_send_and_cleanup(self, tmp_path): ) assert result == {"stdout": "", "exit_code": 0} - # Pending should be cleaned up on success - assert not pending_file.exists() + assert pending_file.exists() + updated = json.loads(pending_file.read_text(encoding="utf-8")) + assert updated["delivered"] is True + assert updated["transcript_line_after"] == 2 def test_send_fails_pending_kept(self, tmp_path): """When delivery fails, pending file is kept for retry.""" @@ -872,6 +892,7 @@ def test_send_fails_pending_kept(self, tmp_path): patch(f"{MOD}._check_log_streamer_active", return_value=False), patch(f"{MOD}.time.sleep"), patch(f"{MOD}.Path.cwd", return_value=tmp_path), + patch(f"{MOD}._write_delivery_log"), ): result = handle( { @@ -882,8 +903,9 @@ def test_send_fails_pending_kept(self, tmp_path): ) assert result == {"stdout": "", "exit_code": 0} - # Pending should still exist assert pending_file.exists() + updated = json.loads(pending_file.read_text(encoding="utf-8")) + assert "delivered" not in updated def test_no_response_text_pending_kept(self, tmp_path): """When no response text is extracted, pending is kept.""" @@ -944,7 +966,6 @@ def test_jsonl_retry_mechanism(self, tmp_path): pending_file = pending_dir / "bot-1.json" pending_file.write_text(json.dumps(pending_data), encoding="utf-8") - # Import to get the original function reference from aipass.hooks.apps.handlers.notification.telegram_response import extract_assistant_response extract_call_count = 0 @@ -965,6 +986,7 @@ def mock_extract(tp: str, start_line: int = 0) -> str | None: patch(f"{MOD}._check_log_streamer_active", return_value=False), patch(f"{MOD}.time.sleep"), patch(f"{MOD}.Path.cwd", return_value=tmp_path), + patch(f"{MOD}._write_delivery_log"), ): result = handle( { @@ -976,7 +998,9 @@ def mock_extract(tp: str, start_line: int = 0) -> str | None: assert result == {"stdout": "", "exit_code": 0} assert extract_call_count >= 2 - assert not pending_file.exists() + assert pending_file.exists() + updated = json.loads(pending_file.read_text(encoding="utf-8")) + assert updated["delivered"] is True def test_fallback_to_last_assistant_message(self, tmp_path): """When JSONL extraction fails, falls back to last_assistant_message from hook_data.""" @@ -1000,6 +1024,7 @@ def test_fallback_to_last_assistant_message(self, tmp_path): patch(f"{MOD}._check_log_streamer_active", return_value=False), patch(f"{MOD}.time.sleep"), patch(f"{MOD}.Path.cwd", return_value=tmp_path), + patch(f"{MOD}._write_delivery_log"), ): result = handle( { @@ -1013,6 +1038,45 @@ def test_fallback_to_last_assistant_message(self, tmp_path): assert result == {"stdout": "", "exit_code": 0} assert not pending_file.exists() + def test_already_delivered_skips_fallback(self, tmp_path): + """After first delivery, last_assistant_message fallback is skipped.""" + from aipass.hooks.apps.handlers.notification.telegram_response import handle + + transcript = tmp_path / "transcript.jsonl" + transcript.write_text("", encoding="utf-8") + + pending_dir = tmp_path / "telegram_pending" + pending_dir.mkdir() + pending_data = { + "chat_id": 999, + "bot_token": "tok:ABC", + "timestamp": time.time(), + "work_dir": str(tmp_path), + "delivered": True, + "transcript_line_after": 5, + } + pending_file = pending_dir / "bot-1.json" + pending_file.write_text(json.dumps(pending_data), encoding="utf-8") + + with ( + patch(LOGGER_PATCH), + patch(f"{MOD}.find_pending_file", return_value=pending_file), + patch(f"{MOD}._check_log_streamer_active", return_value=False), + patch(f"{MOD}.time.sleep"), + patch(f"{MOD}.Path.cwd", return_value=tmp_path), + ): + result = handle( + { + "hook_event_name": "Stop", + "session_id": "session-abc", + "transcript_path": str(transcript), + "last_assistant_message": "Would be a duplicate", + } + ) + + assert result == {"stdout": "", "exit_code": 0} + assert pending_file.exists() + def test_missing_chat_id_cleans_pending(self, tmp_path): """Pending with missing chat_id is cleaned up.""" from aipass.hooks.apps.handlers.notification.telegram_response import handle @@ -1070,10 +1134,10 @@ class TestSendWithRetry: def test_success_on_first_try(self): from aipass.hooks.apps.handlers.notification.telegram_response import _send_with_retry - with patch(LOGGER_PATCH), patch(f"{MOD}.send_to_telegram", return_value=True) as mock_send: + with patch(LOGGER_PATCH), patch(f"{MOD}.send_to_telegram", return_value=_ok_result()) as mock_send: result = _send_with_retry("tok:ABC", 123, "Hello") - assert result is True + assert result["ok"] is True assert mock_send.call_count == 1 def test_success_on_retry(self): @@ -1081,12 +1145,12 @@ def test_success_on_retry(self): with ( patch(LOGGER_PATCH), - patch(f"{MOD}.send_to_telegram", side_effect=[False, True]) as mock_send, + patch(f"{MOD}.send_to_telegram", side_effect=[_fail_result(), _ok_result()]) as mock_send, patch(f"{MOD}.time.sleep"), ): result = _send_with_retry("tok:ABC", 123, "Hello") - assert result is True + assert result["ok"] is True assert mock_send.call_count == 2 def test_all_retries_fail(self): @@ -1094,12 +1158,12 @@ def test_all_retries_fail(self): with ( patch(LOGGER_PATCH), - patch(f"{MOD}.send_to_telegram", return_value=False) as mock_send, + patch(f"{MOD}.send_to_telegram", return_value=_fail_result()) as mock_send, patch(f"{MOD}.time.sleep"), ): result = _send_with_retry("tok:ABC", 123, "Hello", retries=3) - assert result is False + assert result["ok"] is False assert mock_send.call_count == 3 @@ -1139,19 +1203,22 @@ class TestDeliverChunks: def test_single_chunk_no_processing_msg(self): from aipass.hooks.apps.handlers.notification.telegram_response import _deliver_chunks - with patch(LOGGER_PATCH), patch(f"{MOD}._send_with_retry", return_value=True) as mock_send: - result = _deliver_chunks(["Hello"], "tok", 123, None, False) + with patch(LOGGER_PATCH), patch(f"{MOD}._send_with_retry", return_value=_ok_result()) as mock_send: + all_sent, chunk_results = _deliver_chunks(["Hello"], "tok", 123, None, False) - assert result is True + assert all_sent is True + assert len(chunk_results) == 1 + assert chunk_results[0]["method"] == "send" mock_send.assert_called_once_with("tok", 123, "Hello") def test_single_chunk_with_processing_msg_edits(self): from aipass.hooks.apps.handlers.notification.telegram_response import _deliver_chunks - with patch(LOGGER_PATCH), patch(f"{MOD}.edit_telegram_message", return_value=True) as mock_edit: - result = _deliver_chunks(["Hello"], "tok", 123, 789, False) + with patch(LOGGER_PATCH), patch(f"{MOD}.edit_telegram_message", return_value=_ok_result()) as mock_edit: + all_sent, chunk_results = _deliver_chunks(["Hello"], "tok", 123, 789, False) - assert result is True + assert all_sent is True + assert chunk_results[0]["method"] == "edit" mock_edit.assert_called_once_with("tok", 123, 789, "Hello") def test_single_chunk_edit_fails_falls_back_to_send(self): @@ -1159,12 +1226,13 @@ def test_single_chunk_edit_fails_falls_back_to_send(self): with ( patch(LOGGER_PATCH), - patch(f"{MOD}.edit_telegram_message", return_value=False), - patch(f"{MOD}._send_with_retry", return_value=True) as mock_send, + patch(f"{MOD}.edit_telegram_message", return_value=_fail_result()), + patch(f"{MOD}._send_with_retry", return_value=_ok_result()) as mock_send, ): - result = _deliver_chunks(["Hello"], "tok", 123, 789, False) + all_sent, chunk_results = _deliver_chunks(["Hello"], "tok", 123, 789, False) - assert result is True + assert all_sent is True + assert chunk_results[0]["method"] == "send" mock_send.assert_called_once() def test_logs_active_sends_done_then_sends_new(self): @@ -1173,28 +1241,784 @@ def test_logs_active_sends_done_then_sends_new(self): with ( patch(LOGGER_PATCH), - patch(f"{MOD}.edit_telegram_message", return_value=True) as mock_edit, - patch(f"{MOD}._send_with_retry", return_value=True) as mock_send, + patch(f"{MOD}.edit_telegram_message", return_value=_ok_result()) as mock_edit, + patch(f"{MOD}._send_with_retry", return_value=_ok_result()) as mock_send, ): - result = _deliver_chunks(["Hello"], "tok", 123, 789, True) + all_sent, chunk_results = _deliver_chunks(["Hello"], "tok", 123, 789, True) - assert result is True + assert all_sent is True mock_edit.assert_called_once_with("tok", 123, 789, "Done.") mock_send.assert_called_once() - def test_multiple_chunks_numbering(self): + def test_multiple_chunks_clears_placeholder_sends_all_fresh(self): + """Multi-chunk: clears placeholder and sends ALL chunks as fresh messages.""" from aipass.hooks.apps.handlers.notification.telegram_response import _deliver_chunks sent_texts = [] def capture_send(bot_token, chat_id, text): sent_texts.append(text) - return True + return _ok_result() + + with ( + patch(LOGGER_PATCH), + patch(f"{MOD}.edit_telegram_message", return_value=_ok_result()) as mock_edit, + patch(f"{MOD}._send_with_retry", side_effect=capture_send), + ): + all_sent, chunk_results = _deliver_chunks(["Part A", "Part B", "Part C"], "tok", 123, 789, False) + + assert all_sent is True + mock_edit.assert_called_once_with("tok", 123, 789, "Done.") + assert len(sent_texts) == 3 + assert "[1/3]" in sent_texts[0] + assert "[2/3]" in sent_texts[1] + assert "[3/3]" in sent_texts[2] + + def test_multiple_chunks_no_processing_msg(self): + from aipass.hooks.apps.handlers.notification.telegram_response import _deliver_chunks + + sent_texts = [] + + def capture_send(bot_token, chat_id, text): + sent_texts.append(text) + return _ok_result() with patch(LOGGER_PATCH), patch(f"{MOD}._send_with_retry", side_effect=capture_send): - result = _deliver_chunks(["Part A", "Part B", "Part C"], "tok", 123, None, False) + all_sent, chunk_results = _deliver_chunks(["Part A", "Part B", "Part C"], "tok", 123, None, False) - assert result is True + assert all_sent is True assert "[1/3]" in sent_texts[0] assert "[2/3]" in sent_texts[1] assert "[3/3]" in sent_texts[2] + + def test_chunk_results_contain_message_ids(self): + from aipass.hooks.apps.handlers.notification.telegram_response import _deliver_chunks + + call_idx = 0 + + def mock_send_with_ids(bot_token, chat_id, text): + nonlocal call_idx + call_idx += 1 + return _ok_result(message_id=200 + call_idx) + + with patch(LOGGER_PATCH), patch(f"{MOD}._send_with_retry", side_effect=mock_send_with_ids): + _, chunk_results = _deliver_chunks(["A", "B"], "tok", 123, None, False) + + assert chunk_results[0]["message_id"] == 201 + assert chunk_results[1]["message_id"] == 202 + + +# =========================================================================== +# _advance_pending +# =========================================================================== + + +class TestAdvancePending: + """Pending file cursor advancement.""" + + def test_advances_cursor(self, tmp_path): + from aipass.hooks.apps.handlers.notification.telegram_response import _advance_pending + + transcript = tmp_path / "transcript.jsonl" + transcript.write_text("line1\nline2\nline3\n", encoding="utf-8") + + pending_file = tmp_path / "pending.json" + pending_data = {"chat_id": 1, "bot_token": "tok"} + pending_file.write_text(json.dumps(pending_data), encoding="utf-8") + + with patch(LOGGER_PATCH): + _advance_pending(pending_file, pending_data, str(transcript)) + + assert pending_file.exists() + updated = json.loads(pending_file.read_text(encoding="utf-8")) + assert updated["transcript_line_after"] == 3 + assert updated["delivered"] is True + + def test_no_transcript_removes_pending(self, tmp_path): + from aipass.hooks.apps.handlers.notification.telegram_response import _advance_pending + + pending_file = tmp_path / "pending.json" + pending_data = {"chat_id": 1} + pending_file.write_text(json.dumps(pending_data), encoding="utf-8") + + with patch(LOGGER_PATCH): + _advance_pending(pending_file, pending_data, "") + + assert not pending_file.exists() + + def test_transcript_read_failure_removes_pending(self, tmp_path): + from aipass.hooks.apps.handlers.notification.telegram_response import _advance_pending + + pending_file = tmp_path / "pending.json" + pending_data = {"chat_id": 1} + pending_file.write_text(json.dumps(pending_data), encoding="utf-8") + + with patch(LOGGER_PATCH): + _advance_pending(pending_file, pending_data, "/nonexistent/transcript.jsonl") + + assert not pending_file.exists() + + +# =========================================================================== +# _write_delivery_log +# =========================================================================== + + +class TestWriteDeliveryLog: + """Delivery match log JSONL output.""" + + def test_writes_jsonl_record(self, tmp_path): + from aipass.hooks.apps.handlers.notification.telegram_response import _write_delivery_log + + log_path = tmp_path / "delivery.jsonl" + + with patch(LOGGER_PATCH), patch(f"{MOD}._DELIVERY_LOG", log_path): + _write_delivery_log( + "hello", + ["hello"], + [{"idx": 0, "method": "send", "ok": True, "message_id": 1, "text": "hello"}], + "session123", + ) + + assert log_path.exists() + record = json.loads(log_path.read_text(encoding="utf-8").strip()) + assert record["intended_len"] == 5 + assert record["match"] is True + assert record["session"] == "session1" + assert len(record["chunks"]) == 1 + + def test_mismatch_reports_culprit(self, tmp_path): + from aipass.hooks.apps.handlers.notification.telegram_response import _write_delivery_log + + log_path = tmp_path / "delivery.jsonl" + + with patch(LOGGER_PATCH), patch(f"{MOD}._DELIVERY_LOG", log_path): + _write_delivery_log( + "**bold text**", + ["**bold text**"], + [{"idx": 0, "method": "send", "ok": True, "message_id": 1, "text": "bold text"}], + "session123", + ) + + record = json.loads(log_path.read_text(encoding="utf-8").strip()) + assert record["match"] is False + assert "culprit" in record + + def test_failed_chunk_culprit(self, tmp_path): + from aipass.hooks.apps.handlers.notification.telegram_response import _write_delivery_log + + log_path = tmp_path / "delivery.jsonl" + + with patch(LOGGER_PATCH), patch(f"{MOD}._DELIVERY_LOG", log_path): + _write_delivery_log( + "hello", + ["hello"], + [{"idx": 0, "method": "send", "ok": False, "text": ""}], + "sess", + ) + + record = json.loads(log_path.read_text(encoding="utf-8").strip()) + assert record["match"] is False + assert "delivery_failed" in record["culprit"] + + def test_log_write_failure_does_not_raise(self): + from aipass.hooks.apps.handlers.notification.telegram_response import _write_delivery_log + + impossible = Path("/dev/null/impossible/log.jsonl") + with patch(LOGGER_PATCH), patch(f"{MOD}._DELIVERY_LOG", impossible): + _write_delivery_log("hi", ["hi"], [{"idx": 0, "ok": True, "text": "hi"}], "s") + + +# =========================================================================== +# _is_expired — mirror files +# =========================================================================== + + +class TestIsExpiredMirror: + """Mirror files are never expired.""" + + def test_mirror_file_never_expired(self): + from aipass.hooks.apps.handlers.notification.telegram_response import _is_expired + + with patch(LOGGER_PATCH): + assert _is_expired({"timestamp": 0, "mirror": True}) is False + + def test_mirror_file_old_timestamp_not_expired(self): + from aipass.hooks.apps.handlers.notification.telegram_response import _is_expired + + with patch(LOGGER_PATCH): + assert _is_expired({"timestamp": 1000, "mirror": True}) is False + + +# =========================================================================== +# _extract_user_text +# =========================================================================== + + +class TestExtractUserText: + """User message text extraction.""" + + def test_text_block(self): + from aipass.hooks.apps.handlers.notification.telegram_response import _extract_user_text + + content = [{"type": "text", "text": "Hello world"}] + assert _extract_user_text(content) == "Hello world" + + def test_tool_result_only_returns_none(self): + from aipass.hooks.apps.handlers.notification.telegram_response import _extract_user_text + + content = [{"type": "tool_result", "content": "ok"}] + assert _extract_user_text(content) is None + + def test_string_content(self): + from aipass.hooks.apps.handlers.notification.telegram_response import _extract_user_text + + assert _extract_user_text("Hello") == "Hello" + + def test_empty_string_returns_none(self): + from aipass.hooks.apps.handlers.notification.telegram_response import _extract_user_text + + assert _extract_user_text("") is None + + def test_empty_list_returns_none(self): + from aipass.hooks.apps.handlers.notification.telegram_response import _extract_user_text + + assert _extract_user_text([]) is None + + def test_non_list_non_string_returns_none(self): + from aipass.hooks.apps.handlers.notification.telegram_response import _extract_user_text + + assert _extract_user_text(42) is None # type: ignore[arg-type] + + def test_mixed_text_and_tool_result(self): + from aipass.hooks.apps.handlers.notification.telegram_response import _extract_user_text + + content = [{"type": "text", "text": "Question"}, {"type": "tool_result", "content": "ok"}] + assert _extract_user_text(content) == "Question" + + +# =========================================================================== +# extract_mirror_turn +# =========================================================================== + + +class TestExtractMirrorTurn: + """Mirror transcript extraction — user input + assistant response.""" + + def test_single_turn_user_and_assistant(self, tmp_path): + from aipass.hooks.apps.handlers.notification.telegram_response import extract_mirror_turn + + transcript = tmp_path / "transcript.jsonl" + lines = [ + _jsonl_line("user", "What is AIPass?"), + _jsonl_line("assistant", "AIPass is a multi-agent framework."), + ] + transcript.write_text("\n".join(lines), encoding="utf-8") + + with patch(LOGGER_PATCH): + result = extract_mirror_turn(str(transcript)) + + assert result is not None + assert "You: What is AIPass?" in result + assert "AIPass is a multi-agent framework." in result + + def test_multiple_turns_separated_by_divider(self, tmp_path): + from aipass.hooks.apps.handlers.notification.telegram_response import extract_mirror_turn + + transcript = tmp_path / "transcript.jsonl" + lines = [ + _jsonl_line("user", "First question"), + _jsonl_line("assistant", "First answer"), + _jsonl_line("user", "Second question"), + _jsonl_line("assistant", "Second answer"), + ] + transcript.write_text("\n".join(lines), encoding="utf-8") + + with patch(LOGGER_PATCH): + result = extract_mirror_turn(str(transcript)) + + assert result is not None + assert "You: First question" in result + assert "First answer" in result + assert "---" in result + assert "You: Second question" in result + assert "Second answer" in result + + def test_sidechain_entries_skipped(self, tmp_path): + from aipass.hooks.apps.handlers.notification.telegram_response import extract_mirror_turn + + transcript = tmp_path / "transcript.jsonl" + lines = [ + _jsonl_line("user", "Real question"), + _jsonl_line("assistant", "Sidechain noise", sidechain=True), + _jsonl_line("assistant", "Real answer"), + ] + transcript.write_text("\n".join(lines), encoding="utf-8") + + with patch(LOGGER_PATCH): + result = extract_mirror_turn(str(transcript)) + + assert result is not None + assert "Sidechain noise" not in result + assert "Real answer" in result + + def test_tool_result_user_messages_skipped(self, tmp_path): + from aipass.hooks.apps.handlers.notification.telegram_response import extract_mirror_turn + + transcript = tmp_path / "transcript.jsonl" + lines = [ + _jsonl_line("user", "Real question"), + _jsonl_line("assistant", "Working on it..."), + _jsonl_line("user", tool_result=True), + _jsonl_line("assistant", "Done with the work"), + ] + transcript.write_text("\n".join(lines), encoding="utf-8") + + with patch(LOGGER_PATCH): + result = extract_mirror_turn(str(transcript)) + + assert result is not None + assert "You: Real question" in result + assert "Working on it..." in result + assert "Done with the work" in result + # tool_result should not create a second turn + assert "---" not in result + + def test_start_line_skips_old_entries(self, tmp_path): + from aipass.hooks.apps.handlers.notification.telegram_response import extract_mirror_turn + + transcript = tmp_path / "transcript.jsonl" + lines = [ + _jsonl_line("user", "Old question"), + _jsonl_line("assistant", "Old answer"), + _jsonl_line("user", "New question"), + _jsonl_line("assistant", "New answer"), + ] + transcript.write_text("\n".join(lines), encoding="utf-8") + + with patch(LOGGER_PATCH): + result = extract_mirror_turn(str(transcript), start_line=2) + + assert result is not None + assert "Old question" not in result + assert "New question" in result + assert "New answer" in result + + def test_no_new_entries_returns_none(self, tmp_path): + from aipass.hooks.apps.handlers.notification.telegram_response import extract_mirror_turn + + transcript = tmp_path / "transcript.jsonl" + lines = [ + _jsonl_line("user", "Question"), + _jsonl_line("assistant", "Answer"), + ] + transcript.write_text("\n".join(lines), encoding="utf-8") + + with patch(LOGGER_PATCH): + result = extract_mirror_turn(str(transcript), start_line=2) + + assert result is None + + def test_missing_transcript_returns_none(self): + from aipass.hooks.apps.handlers.notification.telegram_response import extract_mirror_turn + + with patch(LOGGER_PATCH): + result = extract_mirror_turn("/nonexistent/path.jsonl") + + assert result is None + + def test_corrupt_lines_skipped(self, tmp_path): + from aipass.hooks.apps.handlers.notification.telegram_response import extract_mirror_turn + + transcript = tmp_path / "transcript.jsonl" + lines = [ + _jsonl_line("user", "Question"), + "this is {{{ not json", + _jsonl_line("assistant", "Answer"), + ] + transcript.write_text("\n".join(lines), encoding="utf-8") + + with patch(LOGGER_PATCH): + result = extract_mirror_turn(str(transcript)) + + assert result is not None + assert "You: Question" in result + assert "Answer" in result + + def test_assistant_only_no_user_text(self, tmp_path): + """Assistant text after cursor with no user message — still delivered.""" + from aipass.hooks.apps.handlers.notification.telegram_response import extract_mirror_turn + + transcript = tmp_path / "transcript.jsonl" + lines = [ + _jsonl_line("assistant", "Continuation text"), + ] + transcript.write_text("\n".join(lines), encoding="utf-8") + + with patch(LOGGER_PATCH): + result = extract_mirror_turn(str(transcript)) + + assert result is not None + assert "Continuation text" in result + assert "You:" not in result + + def test_stale_cursor_clamps_to_latest_turn(self, tmp_path): + """Cursor ahead of transcript self-heals by clamping to latest turn.""" + from aipass.hooks.apps.handlers.notification.telegram_response import extract_mirror_turn + + transcript = tmp_path / "transcript.jsonl" + lines = [ + _jsonl_line("user", "Old question"), + _jsonl_line("assistant", "Old answer"), + _jsonl_line("user", "Latest question"), + _jsonl_line("assistant", "Latest answer"), + ] + transcript.write_text("\n".join(lines), encoding="utf-8") + + with patch(LOGGER_PATCH): + result = extract_mirror_turn(str(transcript), start_line=50) + + assert result is not None + assert "You: Latest question" in result + assert "Latest answer" in result + + def test_stale_cursor_no_user_msg_delivers_all(self, tmp_path): + """Stale cursor with no user messages — delivers all assistant text.""" + from aipass.hooks.apps.handlers.notification.telegram_response import extract_mirror_turn + + transcript = tmp_path / "transcript.jsonl" + lines = [ + _jsonl_line("assistant", "Some output"), + ] + transcript.write_text("\n".join(lines), encoding="utf-8") + + with patch(LOGGER_PATCH): + result = extract_mirror_turn(str(transcript), start_line=99) + + assert result is not None + assert "Some output" in result + + +# =========================================================================== +# find_pending_file — mirror directory +# =========================================================================== + + +class TestFindPendingFileMirror: + """Mirror directory search for persistent mapping files.""" + + def test_mirror_dir_env_bot_id_match(self, tmp_path): + from aipass.hooks.apps.handlers.notification.telegram_response import find_pending_file + + mirror_dir = tmp_path / "telegram_bots" + mirror_dir.mkdir() + data = {"timestamp": time.time(), "work_dir": str(tmp_path), "mirror": True} + (mirror_dir / "bot-42.json").write_text(json.dumps(data), encoding="utf-8") + + with ( + patch(LOGGER_PATCH), + patch(f"{MOD}.MIRROR_DIR", mirror_dir), + patch(f"{MOD}.PENDING_DIR", tmp_path / "nonexistent"), + patch.dict("os.environ", {"AIPASS_BOT_ID": "42"}), + ): + result = find_pending_file("session-xyz") + + assert result is not None + assert result.name == "bot-42.json" + + def test_mirror_dir_preferred_over_pending_for_env(self, tmp_path): + """Mirror dir is checked before pending dir for AIPASS_BOT_ID match.""" + from aipass.hooks.apps.handlers.notification.telegram_response import find_pending_file + + mirror_dir = tmp_path / "telegram_bots" + mirror_dir.mkdir() + pending_dir = tmp_path / "telegram_pending" + pending_dir.mkdir() + mirror_data = {"timestamp": time.time(), "work_dir": str(tmp_path), "mirror": True} + pending_data = {"timestamp": time.time(), "work_dir": str(tmp_path)} + (mirror_dir / "bot-42.json").write_text(json.dumps(mirror_data), encoding="utf-8") + (pending_dir / "bot-42.json").write_text(json.dumps(pending_data), encoding="utf-8") + + with ( + patch(LOGGER_PATCH), + patch(f"{MOD}.MIRROR_DIR", mirror_dir), + patch(f"{MOD}.PENDING_DIR", pending_dir), + patch.dict("os.environ", {"AIPASS_BOT_ID": "42"}), + ): + result = find_pending_file("session-xyz") + + assert result is not None + assert str(mirror_dir) in str(result) + + def test_mirror_dir_cwd_match(self, tmp_path): + from aipass.hooks.apps.handlers.notification.telegram_response import find_pending_file + + mirror_dir = tmp_path / "telegram_bots" + mirror_dir.mkdir() + work = tmp_path / "project" + work.mkdir() + data = {"timestamp": time.time(), "work_dir": str(work), "mirror": True} + (mirror_dir / "bot-7.json").write_text(json.dumps(data), encoding="utf-8") + + with ( + patch(LOGGER_PATCH), + patch(f"{MOD}.MIRROR_DIR", mirror_dir), + patch(f"{MOD}.PENDING_DIR", tmp_path / "nonexistent"), + patch.dict("os.environ", {}, clear=True), + patch(f"{MOD}.Path.cwd", return_value=work / "subdir"), + ): + result = find_pending_file("session-abc") + + assert result is not None + assert result.name == "bot-7.json" + + +# =========================================================================== +# handle — mirror integration tests +# =========================================================================== + + +class TestHandleMirrorIntegration: + """Full handle() flow for mirror sessions.""" + + def test_mirror_user_typed_directly_delivered(self, tmp_path): + """User types directly in terminal — mirror delivers to TG.""" + from aipass.hooks.apps.handlers.notification.telegram_response import handle + + transcript = tmp_path / "transcript.jsonl" + lines = [ + _jsonl_line("user", "Old question"), + _jsonl_line("assistant", "Old answer"), + _jsonl_line("user", "What is this?"), + _jsonl_line("assistant", "This is the answer."), + ] + transcript.write_text("\n".join(lines), encoding="utf-8") + + mirror_dir = tmp_path / "telegram_bots" + mirror_dir.mkdir() + mirror_data = { + "chat_id": 999, + "bot_token": "tok:ABC", + "session_name": "devpulse", + "work_dir": str(tmp_path), + "mirror": True, + "transcript_line_after": 2, + } + mirror_file = mirror_dir / "bot-1.json" + mirror_file.write_text(json.dumps(mirror_data), encoding="utf-8") + + sent_texts = [] + + def capture_send(bot_token, chat_id, text): + sent_texts.append(text) + return _ok_result() + + with ( + patch(LOGGER_PATCH), + patch(f"{MOD}.find_pending_file", return_value=mirror_file), + patch(f"{MOD}._send_with_retry", side_effect=capture_send), + patch(f"{MOD}._check_log_streamer_active", return_value=False), + patch(f"{MOD}.Path.cwd", return_value=tmp_path), + patch(f"{MOD}._write_delivery_log"), + ): + result = handle( + { + "hook_event_name": "Stop", + "session_id": "session-abc", + "transcript_path": str(transcript), + } + ) + + assert result == {"stdout": "", "exit_code": 0} + assert len(sent_texts) == 1 + assert "You: What is this?" in sent_texts[0] + assert "This is the answer." in sent_texts[0] + + def test_mirror_tg_injected_no_double_send(self, tmp_path): + """TG-injected turn — cursor advancement prevents re-delivery.""" + from aipass.hooks.apps.handlers.notification.telegram_response import handle + + transcript = tmp_path / "transcript.jsonl" + lines = [ + _jsonl_line("user", "Injected from TG"), + _jsonl_line("assistant", "Response to TG"), + ] + transcript.write_text("\n".join(lines), encoding="utf-8") + + mirror_dir = tmp_path / "telegram_bots" + mirror_dir.mkdir() + mirror_data = { + "chat_id": 999, + "bot_token": "tok:ABC", + "session_name": "devpulse", + "work_dir": str(tmp_path), + "mirror": True, + "transcript_line_after": 2, + "delivered": True, + } + mirror_file = mirror_dir / "bot-1.json" + mirror_file.write_text(json.dumps(mirror_data), encoding="utf-8") + + with ( + patch(LOGGER_PATCH), + patch(f"{MOD}.find_pending_file", return_value=mirror_file), + patch(f"{MOD}._check_log_streamer_active", return_value=False), + patch(f"{MOD}.time.sleep"), + patch(f"{MOD}.Path.cwd", return_value=tmp_path), + ): + result = handle( + { + "hook_event_name": "Stop", + "session_id": "session-abc", + "transcript_path": str(transcript), + } + ) + + assert result == {"stdout": "", "exit_code": 0} + assert mirror_file.exists() + + def test_mirror_cursor_advances_no_redelivery(self, tmp_path): + """Cursor advances after mirror delivery — old turns not re-sent.""" + from aipass.hooks.apps.handlers.notification.telegram_response import handle + + transcript = tmp_path / "transcript.jsonl" + lines = [ + _jsonl_line("user", "Question"), + _jsonl_line("assistant", "Answer"), + ] + transcript.write_text("\n".join(lines), encoding="utf-8") + + mirror_dir = tmp_path / "telegram_bots" + mirror_dir.mkdir() + mirror_data = { + "chat_id": 999, + "bot_token": "tok:ABC", + "session_name": "devpulse", + "work_dir": str(tmp_path), + "mirror": True, + "transcript_line_after": 0, + } + mirror_file = mirror_dir / "bot-1.json" + mirror_file.write_text(json.dumps(mirror_data), encoding="utf-8") + + with ( + patch(LOGGER_PATCH), + patch(f"{MOD}.find_pending_file", return_value=mirror_file), + patch(f"{MOD}._send_with_retry", return_value=_ok_result()), + patch(f"{MOD}._check_log_streamer_active", return_value=False), + patch(f"{MOD}.Path.cwd", return_value=tmp_path), + patch(f"{MOD}._write_delivery_log"), + ): + handle( + { + "hook_event_name": "Stop", + "session_id": "session-abc", + "transcript_path": str(transcript), + } + ) + + updated = json.loads(mirror_file.read_text(encoding="utf-8")) + assert updated["transcript_line_after"] == 2 + assert updated["delivered"] is True + assert mirror_file.exists() + + def test_mirror_file_never_deleted_on_error(self, tmp_path): + """Mirror mapping files are never deleted, even on validation errors.""" + from aipass.hooks.apps.handlers.notification.telegram_response import handle + + mirror_dir = tmp_path / "telegram_bots" + mirror_dir.mkdir() + mirror_data = { + "mirror": True, + "session_name": "devpulse", + "work_dir": str(tmp_path), + } + mirror_file = mirror_dir / "bot-1.json" + mirror_file.write_text(json.dumps(mirror_data), encoding="utf-8") + + with patch(LOGGER_PATCH), patch(f"{MOD}.find_pending_file", return_value=mirror_file): + handle( + { + "hook_event_name": "Stop", + "session_id": "session-abc", + } + ) + + assert mirror_file.exists() + + def test_mirror_no_processing_message_sends_fresh(self, tmp_path): + """Mirror sessions have no processing_message_id — always send fresh.""" + from aipass.hooks.apps.handlers.notification.telegram_response import handle + + transcript = tmp_path / "transcript.jsonl" + lines = [ + _jsonl_line("user", "Hello"), + _jsonl_line("assistant", "Hi there"), + ] + transcript.write_text("\n".join(lines), encoding="utf-8") + + mirror_dir = tmp_path / "telegram_bots" + mirror_dir.mkdir() + mirror_data = { + "chat_id": 999, + "bot_token": "tok:ABC", + "session_name": "devpulse", + "work_dir": str(tmp_path), + "mirror": True, + "transcript_line_after": 0, + } + mirror_file = mirror_dir / "bot-1.json" + mirror_file.write_text(json.dumps(mirror_data), encoding="utf-8") + + with ( + patch(LOGGER_PATCH), + patch(f"{MOD}.find_pending_file", return_value=mirror_file), + patch(f"{MOD}._send_with_retry", return_value=_ok_result()) as mock_send, + patch(f"{MOD}.edit_telegram_message") as mock_edit, + patch(f"{MOD}._check_log_streamer_active", return_value=False), + patch(f"{MOD}.Path.cwd", return_value=tmp_path), + patch(f"{MOD}._write_delivery_log"), + ): + handle( + { + "hook_event_name": "Stop", + "session_id": "session-abc", + "transcript_path": str(transcript), + } + ) + + mock_send.assert_called_once() + mock_edit.assert_not_called() + + +# =========================================================================== +# _advance_pending — mirror protection +# =========================================================================== + + +class TestAdvancePendingMirror: + """Mirror files are never deleted by _advance_pending.""" + + def test_mirror_no_transcript_kept(self, tmp_path): + from aipass.hooks.apps.handlers.notification.telegram_response import _advance_pending + + pending_file = tmp_path / "pending.json" + pending_data = {"chat_id": 1, "mirror": True} + pending_file.write_text(json.dumps(pending_data), encoding="utf-8") + + with patch(LOGGER_PATCH): + _advance_pending(pending_file, pending_data, "") + + assert pending_file.exists() + + def test_mirror_transcript_failure_kept(self, tmp_path): + from aipass.hooks.apps.handlers.notification.telegram_response import _advance_pending + + pending_file = tmp_path / "pending.json" + pending_data = {"chat_id": 1, "mirror": True} + pending_file.write_text(json.dumps(pending_data), encoding="utf-8") + + with patch(LOGGER_PATCH): + _advance_pending(pending_file, pending_data, "/nonexistent/transcript.jsonl") + + assert pending_file.exists() diff --git a/src/aipass/memory/.seedgo/bypass.json b/src/aipass/memory/.seedgo/bypass.json index bb398233..2bff3ff9 100644 --- a/src/aipass/memory/.seedgo/bypass.json +++ b/src/aipass/memory/.seedgo/bypass.json @@ -141,6 +141,11 @@ "standard": "handlers", "reason": "Architectural: imports json.memory_files for metadata updates and monitor.detector for registry reading." }, + { + "file": "apps/handlers/tracking/tab_renderer.py", + "standard": "handlers", + "reason": "Architectural: imports json.memory_files, json.config_loader, and monitor.detector for config-driven tab rendering across all branches." + }, { "file": "apps/handlers/search/vector_search.py", "standard": "handlers", @@ -451,6 +456,12 @@ "standard": "unused_function", "reason": "is_plan_vectorized is a public API function for external callers." }, + { + "file": "apps/handlers/tracking/tab_renderer.py", + "standard": "unused_function", + "functions": ["render_all_meta_tabs"], + "reason": "Cross-branch public API — called by @spawn's build_replacements_dict to resolve {{*_META}} placeholders at branch creation." + }, { "file": "apps/modules/templates.py", "standard": "deep_nesting", @@ -559,31 +570,13 @@ { "file": "apps/handlers/learnings/manager.py", "standard": "unused_function", - "lines": [759], + "functions": ["update_status_counts"], "reason": "Public API for learning extraction pipeline — called dynamically by symbolic extraction." }, - { - "file": "apps/handlers/search/vector_search.py", - "standard": "unused_function", - "lines": [247], - "reason": "Public API surface for vector search — legacy handler retained for direct-import consumers." - }, - { - "file": "apps/handlers/search/vector_search.py", - "standard": "unused_function", - "lines": [316], - "reason": "Public API surface for vector search — legacy handler retained for direct-import consumers." - }, - { - "file": "apps/handlers/search/vector_search.py", - "standard": "unused_function", - "lines": [380], - "reason": "Public API surface for vector search — legacy handler retained for direct-import consumers." - }, { "file": "apps/handlers/symbolic/extractor.py", "standard": "unused_function", - "lines": [455], + "functions": ["analyze_conversation_llm"], "reason": "LLM-based extraction function — called conditionally when API key is available." }, { @@ -711,6 +704,16 @@ "standard": "meta", "reason": "Test file — META block present at lines 1-7; hook false-positive on test file format." }, + { + "file": "tests/test_tab_renderer.py", + "standard": "architecture", + "reason": "Test file — lives in tests/ by design, not in 3-layer apps/ structure." + }, + { + "file": "tests/test_tab_renderer.py", + "standard": "documentation", + "reason": "Test file — test functions don't require docstrings." + }, { "file": "tests/test_handlers.py", "standard": "architecture", diff --git a/src/aipass/memory/README.md b/src/aipass/memory/README.md index 0bd04cbf..abe78592 100644 --- a/src/aipass/memory/README.md +++ b/src/aipass/memory/README.md @@ -64,11 +64,11 @@ memory/ │ ├── storage/ # chroma.py, chroma_subprocess.py │ ├── symbolic/ # chroma_client, deduplicator, extractor, hook, retriever, storage │ ├── templates/ # pusher.py, differ.py, spawn_pusher.py -│ ├── tracking/ # line_counter.py +│ ├── tracking/ # line_counter.py, tab_renderer.py │ ├── vector/ # embedder.py, embed_subprocess.py │ └── central_writer.py ├── templates/ # LOCAL.template.json, OBSERVATIONS.template.json -├── tests/ # 949 tests (31 test files) +├── tests/ # 978 tests ├── .chroma/ # ChromaDB vector store └── memory_json/ # Operation logs + custom_config/memory.config.json ``` @@ -87,12 +87,46 @@ detector.check_all_branches() # scan AIPASS_REGISTRY.json + external regi → trim source file # write back with oldest removed ``` +Rollover writes safety copies (`rollover_backup_*.json`) into `/.backup/` — a shared runtime namespace (see `@backup`'s README for all writers). + ### Subprocess Isolation All ML operations (fastembed, chromadb) run via subprocess. The main process never imports these libraries. Python interpreter resolved via `_get_memory_python()` (env var `AIPASS_MEMORY_PYTHON` → `memory/.venv/bin/python` → `sys.executable`). --- +## State-Tabs (`*_meta` keys) + +Every `.trinity/local.json` and `.trinity/observations.json` carries inline `*_meta` banner strings that tell the editing agent what rollover rules apply to each section. Example: + +``` +"sessions_meta": "⟦ rollover ON → oldest archived to @memory · keep 15 · summary ≤300 chars ⟧" +``` + +**Source of truth:** `memory.config.json` — rollover counts (defaults + per-branch overrides) and entry char limits. Tab strings are *generated*, never hand-written. + +**Sections:** `todos_meta` (rollover OFF — operational, never trimmed), `key_learnings_meta`, `sessions_meta`, `observations_meta` (all rollover ON). + +### Two value flows + +| Scenario | How tabs arrive | +|---|---| +| **Live branches** | `refresh_all_tabs()` walks the registry, renders tabs from config with per-branch overrides, writes them into `.trinity/` files. Wired after rollover, sync-lines, and push-templates. | +| **New branches** | Templates carry `{{TODOS_META}}`, `{{KEY_LEARNINGS_META}}`, `{{SESSIONS_META}}`, `{{OBSERVATIONS_META}}` placeholders. `spawn_pusher` propagates these (unresolved) from memory templates → spawn template sets. At branch creation, @spawn calls `render_all_meta_tabs()` to get rendered defaults and resolves the placeholders. | + +### Public API + +```python +from aipass.memory.apps.handlers.tracking.tab_renderer import render_all_meta_tabs + +tabs = render_all_meta_tabs() +# → {"TODOS_META": "⟦ rollover OFF ...", "KEY_LEARNINGS_META": "⟦ rollover ON ...", ...} +``` + +Returns defaults (not per-branch overrides) — appropriate for template resolution at branch creation. + +--- + ## Integration Points **Depends on:** @@ -115,9 +149,8 @@ All ML operations (fastembed, chromadb) run via subprocess. The main process nev ## Quality -- **Tests:** 949 passed, 0 failures, 0 skips -- **Test files:** 31 -- **Seedgo:** 100% — maintained since s12 +- **Tests:** 978 passed, 0 failures, 0 skips +- **Seedgo:** 100% --- @@ -129,7 +162,7 @@ All ML operations (fastembed, chromadb) run via subprocess. The main process nev --- -*Last Updated: 2026-05-16* +*Last Updated: 2026-06-25* --- [← Back to AIPass](../../../README.md) diff --git a/src/aipass/memory/apps/handlers/json/config_loader.py b/src/aipass/memory/apps/handlers/json/config_loader.py index 423229f8..cb22aa25 100644 --- a/src/aipass/memory/apps/handlers/json/config_loader.py +++ b/src/aipass/memory/apps/handlers/json/config_loader.py @@ -114,7 +114,6 @@ "local": { "sessions": {"count": 20}, "key_learnings": {"count": 25}, - "todos": {"count": 10}, }, "observations": { "observations": {"count": 25}, diff --git a/src/aipass/memory/apps/handlers/templates/pusher.py b/src/aipass/memory/apps/handlers/templates/pusher.py index f1c63a67..8fcecc0c 100644 --- a/src/aipass/memory/apps/handlers/templates/pusher.py +++ b/src/aipass/memory/apps/handlers/templates/pusher.py @@ -160,6 +160,12 @@ def _merge_metadata(curr_meta: dict, tmpl_meta: dict) -> List[str]: curr_meta[key] = tmpl_val changes.append(f"document_metadata.{key}: {old} -> {tmpl_val}") + # _usage + tmpl_usage = tmpl_meta.get("_usage") + if tmpl_usage and curr_meta.get("_usage") != tmpl_usage: + curr_meta["_usage"] = tmpl_usage + changes.append("document_metadata._usage: updated from template") + # Tags tmpl_tags = tmpl_meta.get("tags", []) if tmpl_tags and set(curr_meta.get("tags", [])) != set(tmpl_tags): diff --git a/src/aipass/memory/apps/handlers/tracking/tab_renderer.py b/src/aipass/memory/apps/handlers/tracking/tab_renderer.py new file mode 100644 index 00000000..aec86f24 --- /dev/null +++ b/src/aipass/memory/apps/handlers/tracking/tab_renderer.py @@ -0,0 +1,332 @@ +# =================== AIPass ==================== +# Name: tab_renderer.py +# Description: Config-generated state-tabs for .trinity memory files +# Version: 1.0.0 +# Created: 2026-06-25 +# Modified: 2026-06-25 +# ============================================= + +""" +Tab Renderer Handler + +Generates per-section state-tab strings (e.g. ``⟦ rollover ON ... ⟧``) from +``memory.config.json`` and writes them as ``*_meta`` keys into every branch's +``.trinity/local.json`` and ``.trinity/observations.json``. + +Purpose: + Make memory files self-documenting. Each section carries a single-line + banner that tells the editing agent whether rollover is ON/OFF, the keep + count, and the char cap — all derived from config so they never drift. + +Independence: + Uses config_loader for config, detector helpers for branch discovery, + and memory_files for safe I/O. No service or module dependencies. +""" + +from typing import Any, Dict + +from aipass.prax.apps.modules.logger import get_system_logger +from aipass.memory.apps.handlers.json import json_handler + +logger = get_system_logger() + +_CORRECTED_USAGE_LOCAL = ( + "Automated file — add entries within your sections, newest on top. " + "Rollover auto-archives sessions/key_learnings (+ observations.json) to @memory; " + "todos[] are OPERATIONAL and NEVER rolled — prune done ones by hand at /prep. " + "Limits live in @memory’s memory.config.json." +) +_CORRECTED_USAGE_OBS = ( + "Automated file — add entries within your sections, newest on top. " + "Rollover auto-archives the oldest observations to @memory. " + "Limits live in @memory’s memory.config.json." +) + + +# ============================================================================= +# KEY ORDERING +# ============================================================================= + +# Canonical key order for local.json +_LOCAL_KEY_ORDER = [ + "document_metadata", + "todos_meta", + "todos", + "key_learnings_meta", + "key_learnings", + "sessions_meta", + "sessions", +] + +# Canonical key order for observations.json +_OBSERVATIONS_KEY_ORDER = [ + "document_metadata", + "guidelines", + "observations_meta", + "observations", +] + + +def _reorder_keys(data: Dict[str, Any], key_order: list[str]) -> Dict[str, Any]: + """Rebuild *data* with keys in *key_order* first, then any remaining keys.""" + ordered: Dict[str, Any] = {} + for key in key_order: + if key in data: + ordered[key] = data[key] + # Append any keys not in the canonical order + for key in data: + if key not in ordered: + ordered[key] = data[key] + return ordered + + +# ============================================================================= +# TAB RENDERING +# ============================================================================= + + +def render_tab( + section_name: str, + rollover_cfg: dict, + entry_limits_cfg: dict, + branch_name: str, +) -> str: + """Generate the state-tab string for a section. + + Args: + section_name: One of 'key_learnings', 'sessions', 'observations', 'todos'. + rollover_cfg: The ``rollover`` section from memory.config.json. + entry_limits_cfg: The ``entry_limits`` section from memory.config.json. + branch_name: Branch name (lowercase) for per-branch overrides. + + Returns: + The rendered tab string (e.g. ``⟦ rollover ON ... ⟧``). + """ + # --- Resolve entry-limits for this section -------------------------------- + entry_types = entry_limits_cfg.get("entry_types", {}) + section_limits = entry_types.get(section_name, {}) + max_chars = section_limits.get("max_chars", 300) + field = section_limits.get("field", "value") + + # --- Todos are special: rollover OFF, static shape ------------------------ + if section_name == "todos": + return ( + f"⟦ rollover OFF — operational, never trimmed · " + f"cap ~10 entries · task ≤{max_chars} chars ⟧ " + f"RULE: DELETE each todo when done (never leave status:done) " + f"+ reconcile on load; proof goes in the session entry, " + f"not the todo. Add freely — BAU." + ) + + # --- Rollover sections: look up count ------------------------------------- + per_branch = rollover_cfg.get("per_branch", {}) + defaults = rollover_cfg.get("defaults", {}) + branch_cfg = per_branch.get(branch_name, defaults) + + # Determine which file-level block to read + if section_name == "observations": + file_block = branch_cfg.get("observations", {}) + else: + file_block = branch_cfg.get("local", {}) + + section_cfg = file_block.get(section_name, {}) + count = section_cfg.get("count", 15) + + return f"⟦ rollover ON → oldest archived to @memory · keep {count} · {field} ≤{max_chars} chars ⟧" + + +# ============================================================================= +# PER-FILE TAB WRITERS +# ============================================================================= + + +def _refresh_local(branch_name, local_path, rollover_cfg, entry_limits_cfg): + """Inject *_meta tabs into a branch's local.json. Returns (ok, error_msg).""" + from aipass.memory.apps.handlers.json.memory_files import ( + read_memory_file_data, + write_memory_file_simple, + ) + + data = read_memory_file_data(local_path) + if data is None: + return False, None # file unreadable, skip silently + + meta = data.get("document_metadata", {}) + if meta.get("_usage") != _CORRECTED_USAGE_LOCAL: + meta["_usage"] = _CORRECTED_USAGE_LOCAL + + data["todos_meta"] = render_tab( + "todos", + rollover_cfg, + entry_limits_cfg, + branch_name, + ) + data["key_learnings_meta"] = render_tab( + "key_learnings", + rollover_cfg, + entry_limits_cfg, + branch_name, + ) + data["sessions_meta"] = render_tab( + "sessions", + rollover_cfg, + entry_limits_cfg, + branch_name, + ) + data = _reorder_keys(data, _LOCAL_KEY_ORDER) + + if write_memory_file_simple(local_path, data): + return True, None + return False, f"{branch_name}/local.json: write failed" + + +def _refresh_observations(branch_name, obs_path, rollover_cfg, entry_limits_cfg): + """Inject observations_meta tab into a branch's observations.json. Returns (ok, error_msg).""" + from aipass.memory.apps.handlers.json.memory_files import ( + read_memory_file_data, + write_memory_file_simple, + ) + + data = read_memory_file_data(obs_path) + if data is None: + return False, None # file unreadable, skip silently + + meta = data.get("document_metadata", {}) + if meta.get("_usage") != _CORRECTED_USAGE_OBS: + meta["_usage"] = _CORRECTED_USAGE_OBS + + data["observations_meta"] = render_tab( + "observations", + rollover_cfg, + entry_limits_cfg, + branch_name, + ) + data = _reorder_keys(data, _OBSERVATIONS_KEY_ORDER) + + if write_memory_file_simple(obs_path, data): + return True, None + return False, f"{branch_name}/observations.json: write failed" + + +# ============================================================================= +# REFRESH ALL BRANCHES +# ============================================================================= + + +def refresh_all_tabs() -> dict: + """Render and write state-tabs to all branch .trinity files. + + Walks the registry, reads each branch's memory files, computes tab + strings from config, injects them as ``*_meta`` keys, and writes back + with correct key ordering. + + Returns: + Dict with success status and counts. + """ + from aipass.memory.apps.handlers.json.config_loader import ( + load as load_config, + ) + from aipass.memory.apps.handlers.monitor.detector import ( + _read_registry, + _get_memory_file_path, + ) + + config = load_config() + rollover_cfg = config.get("rollover", {}) + entry_limits_cfg = config.get("entry_limits", {}) + + branches = _read_registry() + if not branches: + return { + "success": True, + "updated": 0, + "skipped": 0, + "message": "No branches in registry", + } + + updated = 0 + skipped = 0 + errors: list[str] = [] + + for branch in branches: + branch_name = branch.get("name", "UNKNOWN").lower() + for mem_type in ("local", "observations"): + u, s, e = _refresh_one_file( + branch, + branch_name, + mem_type, + rollover_cfg, + entry_limits_cfg, + _get_memory_file_path, + ) + updated += u + skipped += s + errors.extend(e) + + json_handler.log_operation( + "refresh_all_tabs", + {"updated": updated, "skipped": skipped, "errors": len(errors)}, + module_name="tab_renderer", + ) + logger.info( + f"[tab_renderer] Refreshed tabs: {updated} updated, {skipped} skipped, {len(errors)} errors", + ) + + return { + "success": True, + "updated": updated, + "skipped": skipped, + "errors": errors, + } + + +def render_all_meta_tabs() -> dict[str, str]: + """Render all four *_meta tab strings from memory.config.json defaults. + + Public API for @spawn (and any other consumer) to resolve ``{{*_META}}`` + placeholders at branch-creation time. + + Returns: + Dict with keys TODOS_META, KEY_LEARNINGS_META, SESSIONS_META, + OBSERVATIONS_META — each a rendered state-tab string. + """ + from aipass.memory.apps.handlers.json.config_loader import ( + load as load_config, + ) + + config = load_config() + rollover_cfg = config.get("rollover", {}) + entry_limits_cfg = config.get("entry_limits", {}) + + _default = "__template_default__" + return { + "TODOS_META": render_tab("todos", rollover_cfg, entry_limits_cfg, _default), + "KEY_LEARNINGS_META": render_tab("key_learnings", rollover_cfg, entry_limits_cfg, _default), + "SESSIONS_META": render_tab("sessions", rollover_cfg, entry_limits_cfg, _default), + "OBSERVATIONS_META": render_tab("observations", rollover_cfg, entry_limits_cfg, _default), + } + + +def _refresh_one_file(branch, branch_name, mem_type, rollover_cfg, entry_limits_cfg, get_path_fn): + """Refresh tabs for a single memory file. Returns (updated, skipped, errors).""" + file_path = get_path_fn(branch, mem_type) + if file_path is None: + return 0, 1, [] + + refresher = _refresh_local if mem_type == "local" else _refresh_observations + try: + ok, err = refresher( + branch_name, + file_path, + rollover_cfg, + entry_limits_cfg, + ) + except Exception as e: + logger.warning(f"[tab_renderer] {branch_name}/{mem_type}.json: {e}") + return 0, 0, [f"{branch_name}/{mem_type}.json: {e}"] + + if ok: + return 1, 0, [] + if err: + return 0, 0, [err] + return 0, 1, [] diff --git a/src/aipass/memory/apps/modules/rollover.py b/src/aipass/memory/apps/modules/rollover.py index 7b29d034..e4014b85 100755 --- a/src/aipass/memory/apps/modules/rollover.py +++ b/src/aipass/memory/apps/modules/rollover.py @@ -236,6 +236,15 @@ def run_rollover() -> bool: error(f"{fail['trigger']} - {fail['stage']}: {fail['error']}") json_handler.log_operation("rollover_execute", {"triggers": triggers_count, "success_count": success_count}) + + # Refresh state-tabs after rollover (counts may have changed) + try: + from aipass.memory.apps.handlers.tracking.tab_renderer import refresh_all_tabs + + refresh_all_tabs() + except Exception as e: + logger.warning(f"[rollover] Tab refresh failed: {e}") + return success_count > 0 @@ -325,6 +334,14 @@ def sync_line_counts() -> None: else: error("Failed to sync line counts") + # Refresh state-tabs after line count sync + try: + from aipass.memory.apps.handlers.tracking.tab_renderer import refresh_all_tabs + + refresh_all_tabs() + except Exception as e: + logger.warning(f"[rollover] Tab refresh failed: {e}") + console.print() diff --git a/src/aipass/memory/apps/modules/templates.py b/src/aipass/memory/apps/modules/templates.py index e3437e36..4a8c80e8 100644 --- a/src/aipass/memory/apps/modules/templates.py +++ b/src/aipass/memory/apps/modules/templates.py @@ -119,6 +119,14 @@ def handle_command(command: str, args: List[str]) -> bool: except Exception as e: error(f"Spawn template push crashed: {e}") logger.error(f"[templates] spawn push crashed: {e}") + # Refresh state-tabs after template push + if not dry_run: + try: + from aipass.memory.apps.handlers.tracking.tab_renderer import refresh_all_tabs + + refresh_all_tabs() + except Exception as e: + logger.warning(f"[templates] Tab refresh failed: {e}") return True if sub == "diff-templates": @@ -162,6 +170,14 @@ def handle_command(command: str, args: List[str]) -> bool: except Exception as e: error(f"Spawn template push crashed: {e}") logger.error(f"[templates] spawn push crashed: {e}") + # Refresh state-tabs after template push + if not dry_run: + try: + from aipass.memory.apps.handlers.tracking.tab_renderer import refresh_all_tabs + + refresh_all_tabs() + except Exception as e: + logger.warning(f"[templates] Tab refresh failed: {e}") return True elif command == "diff-templates": diff --git a/src/aipass/memory/templates/.template_version.json b/src/aipass/memory/templates/.template_version.json index bf10ddd1..0d154e48 100644 --- a/src/aipass/memory/templates/.template_version.json +++ b/src/aipass/memory/templates/.template_version.json @@ -1,7 +1,11 @@ { - "last_push": "2026-06-07 21:40:48", + "last_push": "2026-06-25 01:55:01", "last_push_branches": [ + "COMMONS", + "DAEMON", "HOOKS", + "SKILLS", + "ai_mail", "aipass", "api", "cli", diff --git a/src/aipass/memory/templates/LOCAL.template.json b/src/aipass/memory/templates/LOCAL.template.json index 2613c10a..9fa49282 100644 --- a/src/aipass/memory/templates/LOCAL.template.json +++ b/src/aipass/memory/templates/LOCAL.template.json @@ -12,14 +12,17 @@ "work_log", "{{BRANCHNAME}}" ], - "_usage": "Automated file — add entries within your sections; rollover trims automatically. Limits live in @memory's memory.config.json.", + "_usage": "Automated file — add entries within your sections, newest on top. Rollover auto-archives sessions/key_learnings (+ observations.json) to @memory; todos[] are OPERATIONAL and NEVER rolled — prune done ones by hand at /prep. Limits live in @memory's memory.config.json.", "status": { "health": "healthy", "last_health_check": "{{DATE}}" } }, - "key_learnings": [], + "todos_meta": "{{TODOS_META}}", "todos": [], + "key_learnings_meta": "{{KEY_LEARNINGS_META}}", + "key_learnings": [], + "sessions_meta": "{{SESSIONS_META}}", "sessions": [ { "number": 1, diff --git a/src/aipass/memory/templates/OBSERVATIONS.template.json b/src/aipass/memory/templates/OBSERVATIONS.template.json index 8184b599..1f3938e0 100644 --- a/src/aipass/memory/templates/OBSERVATIONS.template.json +++ b/src/aipass/memory/templates/OBSERVATIONS.template.json @@ -12,7 +12,7 @@ "patterns", "{{BRANCHNAME}}" ], - "_usage": "Automated file — add entries within your sections; rollover trims automatically. Limits live in @memory's memory.config.json.", + "_usage": "Automated file — add entries within your sections, newest on top. Rollover auto-archives sessions/key_learnings (+ observations.json) to @memory; todos[] are OPERATIONAL and NEVER rolled — prune done ones by hand at /prep. Limits live in @memory's memory.config.json.", "status": { "health": "healthy", "last_health_check": "{{DATE}}" @@ -22,6 +22,7 @@ "purpose": "Capture collaboration patterns and experiential insights over time", "chronological_order": "Newest entries at TOP, oldest at BOTTOM - NEVER reorder" }, + "observations_meta": "{{OBSERVATIONS_META}}", "observations": [ { "number": 1, diff --git a/src/aipass/memory/tests/test_tab_renderer.py b/src/aipass/memory/tests/test_tab_renderer.py new file mode 100644 index 00000000..0937a2ce --- /dev/null +++ b/src/aipass/memory/tests/test_tab_renderer.py @@ -0,0 +1,571 @@ +# =================== AIPass ==================== +# Name: test_tab_renderer.py +# Description: Tests for tab_renderer handler (FPLAN-0285) +# Version: 1.0.0 +# Created: 2026-06-25 +# Modified: 2026-06-25 +# ============================================= + +""" +Tests for the tab_renderer handler. + +Covers: + 1. render_tab() — correct strings for each section type. + 2. render_tab() — per-branch overrides from config. + 3. render_tab() — fallback to defaults when branch not in per_branch. + 4. _reorder_keys() — canonical key ordering. + 5. refresh_all_tabs() — reads config and writes tabs (mocked I/O). + 6. Key ordering verification after tab insertion. +""" + +import importlib +import json +import sys +from pathlib import Path +from unittest.mock import patch + +import pytest + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +@pytest.fixture(autouse=True) +def _fresh_tab_renderer(monkeypatch): + """Drop cached module so each test gets a fresh import.""" + for mod_name in list(sys.modules): + if "tab_renderer" in mod_name: + sys.modules.pop(mod_name, None) + # Also clear json sub-modules that conftest replaces with MagicMock + sys.modules.pop("aipass.memory.apps.handlers.json", None) + sys.modules.pop("aipass.memory.apps.handlers.json.json_handler", None) + yield + + +def _get_module(): + """Import and return the tab_renderer module.""" + return importlib.import_module( + "aipass.memory.apps.handlers.tracking.tab_renderer", + ) + + +# --------------------------------------------------------------------------- +# Shared config fixtures +# --------------------------------------------------------------------------- + +SAMPLE_ROLLOVER_CFG = { + "defaults": { + "local": { + "sessions": {"count": 15}, + "key_learnings": {"count": 15}, + }, + "observations": { + "observations": {"count": 15}, + }, + }, + "per_branch": { + "devpulse": { + "local": { + "sessions": {"count": 20}, + "key_learnings": {"count": 25}, + }, + "observations": { + "observations": {"count": 30}, + }, + }, + }, +} + +SAMPLE_ENTRY_LIMITS_CFG = { + "entry_types": { + "key_learnings": {"field": "value", "max_chars": 200}, + "sessions": {"field": "summary", "max_chars": 300}, + "todos": {"field": "task", "max_chars": 150}, + "observations": {"field": "note", "max_chars": 300}, + }, +} + + +# =========================================================================== +# 1. render_tab — key_learnings (rollover ON) +# =========================================================================== + + +class TestRenderTabKeyLearnings: + def test_default_branch(self): + mod = _get_module() + tab = mod.render_tab( + "key_learnings", + SAMPLE_ROLLOVER_CFG, + SAMPLE_ENTRY_LIMITS_CFG, + "memory", + ) + assert tab.startswith("⟦") + assert tab.endswith("⟧") + assert "rollover ON" in tab + assert "keep 15" in tab + assert "value ≤20" in tab # ≤200 + + def test_per_branch_override(self): + mod = _get_module() + tab = mod.render_tab( + "key_learnings", + SAMPLE_ROLLOVER_CFG, + SAMPLE_ENTRY_LIMITS_CFG, + "devpulse", + ) + assert "keep 25" in tab + + +# =========================================================================== +# 2. render_tab — sessions (rollover ON) +# =========================================================================== + + +class TestRenderTabSessions: + def test_sessions_default(self): + mod = _get_module() + tab = mod.render_tab( + "sessions", + SAMPLE_ROLLOVER_CFG, + SAMPLE_ENTRY_LIMITS_CFG, + "memory", + ) + assert "rollover ON" in tab + assert "keep 15" in tab + assert "summary" in tab + assert "≤30" in tab # ≤300 + + def test_sessions_per_branch(self): + mod = _get_module() + tab = mod.render_tab( + "sessions", + SAMPLE_ROLLOVER_CFG, + SAMPLE_ENTRY_LIMITS_CFG, + "devpulse", + ) + assert "keep 20" in tab + + +# =========================================================================== +# 3. render_tab — observations (rollover ON) +# =========================================================================== + + +class TestRenderTabObservations: + def test_observations_default(self): + mod = _get_module() + tab = mod.render_tab( + "observations", + SAMPLE_ROLLOVER_CFG, + SAMPLE_ENTRY_LIMITS_CFG, + "memory", + ) + assert "rollover ON" in tab + assert "keep 15" in tab + assert "note" in tab + + def test_observations_per_branch(self): + mod = _get_module() + tab = mod.render_tab( + "observations", + SAMPLE_ROLLOVER_CFG, + SAMPLE_ENTRY_LIMITS_CFG, + "devpulse", + ) + assert "keep 30" in tab + + +# =========================================================================== +# 4. render_tab — todos (rollover OFF, static shape) +# =========================================================================== + + +class TestRenderTabTodos: + def test_todos_static(self): + mod = _get_module() + tab = mod.render_tab( + "todos", + SAMPLE_ROLLOVER_CFG, + SAMPLE_ENTRY_LIMITS_CFG, + "memory", + ) + assert "rollover OFF" in tab + assert "cap ~10 entries" in tab + assert "task ≤15" in tab # ≤150 + assert "RULE: DELETE" in tab + assert "BAU" in tab + + def test_todos_ignores_per_branch_rollover(self): + """Todos are always rollover OFF regardless of per_branch config.""" + mod = _get_module() + tab = mod.render_tab( + "todos", + SAMPLE_ROLLOVER_CFG, + SAMPLE_ENTRY_LIMITS_CFG, + "devpulse", + ) + assert "rollover OFF" in tab + + +# =========================================================================== +# 5. _reorder_keys — canonical key ordering +# =========================================================================== + + +class TestReorderKeys: + def test_local_key_order(self): + mod = _get_module() + data = { + "sessions": [], + "document_metadata": {}, + "todos": [], + "key_learnings": [], + "extra_field": "preserved", + } + ordered = mod._reorder_keys(data, mod._LOCAL_KEY_ORDER) + keys = list(ordered.keys()) + assert keys[0] == "document_metadata" + # todos before key_learnings before sessions + assert keys.index("todos") < keys.index("key_learnings") + assert keys.index("key_learnings") < keys.index("sessions") + # extra_field at the end + assert keys[-1] == "extra_field" + + def test_observations_key_order(self): + mod = _get_module() + data = { + "observations": [], + "document_metadata": {}, + "guidelines": {}, + "observations_meta": "tab", + } + ordered = mod._reorder_keys(data, mod._OBSERVATIONS_KEY_ORDER) + keys = list(ordered.keys()) + assert keys == [ + "document_metadata", + "guidelines", + "observations_meta", + "observations", + ] + + def test_meta_before_array(self): + """Meta key must appear immediately before its corresponding array.""" + mod = _get_module() + data = { + "document_metadata": {}, + "todos": [], + "todos_meta": "tab-todos", + "key_learnings": [], + "key_learnings_meta": "tab-kl", + "sessions": [], + "sessions_meta": "tab-sessions", + } + ordered = mod._reorder_keys(data, mod._LOCAL_KEY_ORDER) + keys = list(ordered.keys()) + # Each *_meta must be immediately before its array + assert keys.index("todos_meta") + 1 == keys.index("todos") + assert keys.index("key_learnings_meta") + 1 == keys.index( + "key_learnings", + ) + assert keys.index("sessions_meta") + 1 == keys.index("sessions") + + +# =========================================================================== +# 6. refresh_all_tabs — integration with mocked I/O +# =========================================================================== + + +class TestRefreshAllTabs: + def _make_local_data(self): + return { + "document_metadata": {"document_type": "session_history"}, + "todos": [{"task": "test"}], + "key_learnings": [], + "sessions": [], + } + + def _make_obs_data(self): + return { + "document_metadata": { + "document_type": "collaboration_patterns", + }, + "guidelines": {}, + "observations": [], + } + + def test_writes_tabs_to_files(self, tmp_path): + """refresh_all_tabs reads config, walks branches, writes tabs.""" + mod = _get_module() + + # Set up branch dir with .trinity files + branch_dir = tmp_path / "src" / "aipass" / "test_branch" + trinity = branch_dir / ".trinity" + trinity.mkdir(parents=True) + + local_path = trinity / "local.json" + obs_path = trinity / "observations.json" + local_path.write_text( + json.dumps(self._make_local_data(), indent=2), + encoding="utf-8", + ) + obs_path.write_text( + json.dumps(self._make_obs_data(), indent=2), + encoding="utf-8", + ) + + # Mock registry to return our test branch + mock_branches = [ + {"name": "test_branch", "path": str(branch_dir)}, + ] + + def mock_get_path(branch, mem_type): + p = Path(branch["path"]) / ".trinity" / f"{mem_type}.json" + return p if p.exists() else None + + mock_config = { + "rollover": SAMPLE_ROLLOVER_CFG, + "entry_limits": SAMPLE_ENTRY_LIMITS_CFG, + } + + with ( + patch( + "aipass.memory.apps.handlers.json.config_loader.load", + return_value=mock_config, + ), + patch( + "aipass.memory.apps.handlers.monitor.detector._read_registry", + return_value=mock_branches, + ), + patch( + "aipass.memory.apps.handlers.monitor.detector._get_memory_file_path", + side_effect=mock_get_path, + ), + ): + result = mod.refresh_all_tabs() + + assert result["success"] is True + assert result["updated"] == 2 # local + observations + + # Verify local.json has tabs + local_data = json.loads(local_path.read_text(encoding="utf-8")) + assert "todos_meta" in local_data + assert "key_learnings_meta" in local_data + assert "sessions_meta" in local_data + assert "rollover OFF" in local_data["todos_meta"] + assert "rollover ON" in local_data["key_learnings_meta"] + assert "rollover ON" in local_data["sessions_meta"] + + # Verify observations.json has tab + obs_data = json.loads(obs_path.read_text(encoding="utf-8")) + assert "observations_meta" in obs_data + assert "rollover ON" in obs_data["observations_meta"] + + def test_key_order_after_refresh(self, tmp_path): + """After refresh, keys are in canonical order.""" + mod = _get_module() + + branch_dir = tmp_path / "src" / "aipass" / "ordered_branch" + trinity = branch_dir / ".trinity" + trinity.mkdir(parents=True) + + local_path = trinity / "local.json" + local_path.write_text( + json.dumps(self._make_local_data(), indent=2), + encoding="utf-8", + ) + + obs_path = trinity / "observations.json" + obs_path.write_text( + json.dumps(self._make_obs_data(), indent=2), + encoding="utf-8", + ) + + mock_branches = [ + {"name": "ordered_branch", "path": str(branch_dir)}, + ] + + def mock_get_path(branch, mem_type): + p = Path(branch["path"]) / ".trinity" / f"{mem_type}.json" + return p if p.exists() else None + + mock_config = { + "rollover": SAMPLE_ROLLOVER_CFG, + "entry_limits": SAMPLE_ENTRY_LIMITS_CFG, + } + + with ( + patch( + "aipass.memory.apps.handlers.json.config_loader.load", + return_value=mock_config, + ), + patch( + "aipass.memory.apps.handlers.monitor.detector._read_registry", + return_value=mock_branches, + ), + patch( + "aipass.memory.apps.handlers.monitor.detector._get_memory_file_path", + side_effect=mock_get_path, + ), + ): + mod.refresh_all_tabs() + + local_data = json.loads(local_path.read_text(encoding="utf-8")) + keys = list(local_data.keys()) + expected_prefix = [ + "document_metadata", + "todos_meta", + "todos", + "key_learnings_meta", + "key_learnings", + "sessions_meta", + "sessions", + ] + assert keys[: len(expected_prefix)] == expected_prefix + + def test_empty_registry(self): + """refresh_all_tabs returns early if no branches in registry.""" + mod = _get_module() + + mock_config = { + "rollover": SAMPLE_ROLLOVER_CFG, + "entry_limits": SAMPLE_ENTRY_LIMITS_CFG, + } + + with ( + patch( + "aipass.memory.apps.handlers.json.config_loader.load", + return_value=mock_config, + ), + patch( + "aipass.memory.apps.handlers.monitor.detector._read_registry", + return_value=[], + ), + ): + result = mod.refresh_all_tabs() + + assert result["success"] is True + assert result["updated"] == 0 + assert "No branches" in result.get("message", "") + + def test_no_templates_updated_key(self, tmp_path): + """refresh_all_tabs result dict has no templates_updated key (literal-baking removed).""" + mod = _get_module() + mock_config = { + "rollover": SAMPLE_ROLLOVER_CFG, + "entry_limits": SAMPLE_ENTRY_LIMITS_CFG, + } + with ( + patch( + "aipass.memory.apps.handlers.json.config_loader.load", + return_value=mock_config, + ), + patch( + "aipass.memory.apps.handlers.monitor.detector._read_registry", + return_value=[], + ), + ): + result = mod.refresh_all_tabs() + assert "templates_updated" not in result + + def test_missing_file_skipped(self, tmp_path): + """Branch with missing .trinity files is skipped, not errored.""" + mod = _get_module() + + branch_dir = tmp_path / "src" / "aipass" / "empty_branch" + branch_dir.mkdir(parents=True) + # No .trinity directory at all + + mock_branches = [ + {"name": "empty_branch", "path": str(branch_dir)}, + ] + + def mock_get_path(branch, mem_type): + p = Path(branch["path"]) / ".trinity" / f"{mem_type}.json" + return p if p.exists() else None + + mock_config = { + "rollover": SAMPLE_ROLLOVER_CFG, + "entry_limits": SAMPLE_ENTRY_LIMITS_CFG, + } + + with ( + patch( + "aipass.memory.apps.handlers.json.config_loader.load", + return_value=mock_config, + ), + patch( + "aipass.memory.apps.handlers.monitor.detector._read_registry", + return_value=mock_branches, + ), + patch( + "aipass.memory.apps.handlers.monitor.detector._get_memory_file_path", + side_effect=mock_get_path, + ), + ): + result = mod.refresh_all_tabs() + + assert result["success"] is True + assert result["skipped"] == 2 # local + observations + assert result["updated"] == 0 + + +# =========================================================================== +# 8. render_all_meta_tabs — public API for spawn +# =========================================================================== + + +class TestRenderAllMetaTabs: + def test_returns_four_keys(self): + mod = _get_module() + mock_config = { + "rollover": SAMPLE_ROLLOVER_CFG, + "entry_limits": SAMPLE_ENTRY_LIMITS_CFG, + } + with patch( + "aipass.memory.apps.handlers.json.config_loader.load", + return_value=mock_config, + ): + tabs = mod.render_all_meta_tabs() + + assert set(tabs.keys()) == { + "TODOS_META", + "KEY_LEARNINGS_META", + "SESSIONS_META", + "OBSERVATIONS_META", + } + + def test_values_are_rendered_strings(self): + mod = _get_module() + mock_config = { + "rollover": SAMPLE_ROLLOVER_CFG, + "entry_limits": SAMPLE_ENTRY_LIMITS_CFG, + } + with patch( + "aipass.memory.apps.handlers.json.config_loader.load", + return_value=mock_config, + ): + tabs = mod.render_all_meta_tabs() + + assert "rollover OFF" in tabs["TODOS_META"] + assert "rollover ON" in tabs["KEY_LEARNINGS_META"] + assert "rollover ON" in tabs["SESSIONS_META"] + assert "rollover ON" in tabs["OBSERVATIONS_META"] + assert "{{" not in tabs["TODOS_META"] + + def test_uses_defaults_not_per_branch(self): + mod = _get_module() + mock_config = { + "rollover": SAMPLE_ROLLOVER_CFG, + "entry_limits": SAMPLE_ENTRY_LIMITS_CFG, + } + with patch( + "aipass.memory.apps.handlers.json.config_loader.load", + return_value=mock_config, + ): + tabs = mod.render_all_meta_tabs() + + assert "keep 15" in tabs["KEY_LEARNINGS_META"] + assert "keep 15" in tabs["SESSIONS_META"] diff --git a/src/aipass/prax/.backupignore b/src/aipass/prax/.backupignore deleted file mode 100644 index aef1a733..00000000 --- a/src/aipass/prax/.backupignore +++ /dev/null @@ -1,39 +0,0 @@ -# Backup System ignore patterns (gitignore-style) -# Lines starting with # are comments. Blank lines are ignored. - -# Backup system's own directory -.backup_system/ - -# Version control -.git/ -.svn/ -.hg/ - -# Python -__pycache__/ -*.pyc -*.pyo -*.egg-info/ -.venv/ -venv/ -.tox/ - -# Node -node_modules/ - -# IDE -.vscode/ -.idea/ -*.swp -*.swo - -# OS -.DS_Store -Thumbs.db - -# Build artifacts -build/ -dist/ - -# Logs -*.log diff --git a/src/aipass/prax/CLOSED_PLANS.local.json b/src/aipass/prax/CLOSED_PLANS.local.json index 48d24f15..b8f76701 100644 --- a/src/aipass/prax/CLOSED_PLANS.local.json +++ b/src/aipass/prax/CLOSED_PLANS.local.json @@ -73,25 +73,19 @@ ], "document_metadata": { "version": "2.0.0", - "schema_version": "2.0.0", + "schema_version": "3.0.0", "document_type": "session_history", "tags": [ "session_tracking", "work_log", "prax" ], - "limits": { - "max_sessions": 20, - "max_key_learnings": 25, - "session_summary_max_chars": 150, - "learning_value_max_chars": 200, - "note": "DO NOT trim, prune, or delete entries. Rollover to @memory handles overflow automatically. Just add new entries." - }, "status": { "health": "healthy", "last_health_check": "2026-04-22", "current_lines": 0 } }, - "key_learnings": {} + "key_learnings": {}, + "todos": [] } diff --git a/src/aipass/prax/apps/handlers/monitoring/telegram_relay.py b/src/aipass/prax/apps/handlers/monitoring/telegram_relay.py new file mode 100644 index 00000000..6def5e8f --- /dev/null +++ b/src/aipass/prax/apps/handlers/monitoring/telegram_relay.py @@ -0,0 +1,217 @@ +# =================== AIPass ==================== +# Name: telegram_relay.py +# Description: Telegram relay for prax monitor feed +# Version: 1.0.0 +# Created: 2026-06-24 +# Modified: 2026-06-24 +# ============================================= + +""" +Telegram relay for prax monitor — mirrors the live console feed to a Telegram chat. + +Buffers events in a thread-safe list, flushes every 5s via a daemon thread. +Activation requires --relay flag or AIPASS_PRAX_MONITOR_RELAY=1, plus a valid +bot config passed by the module layer (monitor.py loads from @api secrets). +""" + +import json +import os +import threading +from datetime import datetime +from typing import Optional +from urllib.error import URLError +from urllib.request import Request +from urllib.request import urlopen as _http_fetch + +from aipass.prax.apps.modules.logger import get_direct_logger +from aipass.prax.apps.handlers.json import json_handler + +logger = get_direct_logger() + +BATCH_INTERVAL = 5.0 +TELEGRAM_MAX_LENGTH = 4000 +FLOOD_CAP = 150 + +_lock = threading.Lock() +_buffer: list[str] = [] +_thread: Optional[threading.Thread] = None +_stop_event = threading.Event() +_bot_token: Optional[str] = None +_chat_id: Optional[int] = None +_RELAY_ACTIVE = False + + +def init_relay(enabled: bool, config: Optional[dict] = None) -> None: + """Start the relay if enabled and config is valid. Safe no-op otherwise. + + Args: + enabled: Whether the relay flag is set. + config: Bot config dict with 'bot_token' and 'chat_id' keys. + Loaded by the module layer from @api secrets. + """ + global _RELAY_ACTIVE, _bot_token, _chat_id, _thread + + if not enabled: + return + + json_handler.log_operation("relay_init", {"enabled": enabled, "has_config": config is not None}) + + if config is None: + logger.info("[telegram_relay] No config provided — relay inactive") + return + + token = config.get("bot_token", "") + chat = config.get("chat_id") + if not token or not chat: + logger.info("[telegram_relay] Incomplete config (missing bot_token or chat_id) — relay inactive") + return + + _bot_token = token + _chat_id = int(chat) + _RELAY_ACTIVE = True + + _stop_event.clear() + _thread = threading.Thread(target=_flush_loop, name="telegram-relay", daemon=True) + _thread.start() + logger.info("[telegram_relay] Relay started (chat_id=%s)", _chat_id) + + +def relay_event(event) -> None: + """Format a MonitoringEvent and buffer for Telegram delivery. No-op when inactive.""" + if not _RELAY_ACTIVE: + return + + line = _format_event(event) + if not line: + return + + with _lock: + _buffer.append(line) + + +def stop_relay() -> None: + """Final flush and thread shutdown. Safe no-op when inactive.""" + global _RELAY_ACTIVE, _thread + + if not _RELAY_ACTIVE: + return + + _RELAY_ACTIVE = False + _stop_event.set() + + _flush_buffer() + + if _thread is not None: + _thread.join(timeout=BATCH_INTERVAL + 2) + _thread = None + + json_handler.log_operation("relay_stopped", {}) + logger.info("[telegram_relay] Relay stopped") + + +def _format_event(event) -> Optional[str]: + """Format a MonitoringEvent as a plain-text line (no Rich markup).""" + ts = datetime.now().strftime("%H:%M:%S") + + if event.event_type == "command": + parts = [f"▶ {event.message}"] + caller = getattr(event, "caller", None) + target = None + if hasattr(event, "action") and event.action and ":" in event.action: + action_parts = event.action.split(":", 1) + if len(action_parts) == 2 and action_parts[1]: + target = action_parts[1] + if caller and caller.upper() != "UNKNOWN": + attr = caller + if target: + attr = f"{caller} → {target}" + parts.insert(0, f" {attr}") + return "\n".join(parts) + + if event.event_type == "hook": + action = getattr(event, "action", "unknown") + if action == "fired": + return f"⚡ HOOK {event.message}" + if action == "skipped": + return f"· HOOK {event.message}" + return f"? HOOK {event.message}" + + branch_label = event.branch.upper() + pid = getattr(event, "pid", None) + if pid: + branch_label = f"{branch_label}:{pid}" + return f"[{ts}] [{branch_label}] {event.message}" + + +def _flush_buffer() -> None: + """Drain buffer and send to Telegram.""" + with _lock: + if not _buffer: + return + lines = list(_buffer) + _buffer.clear() + + if not _bot_token or not _chat_id: + return + + if len(lines) > FLOOD_CAP: + suppressed = len(lines) - FLOOD_CAP + lines = lines[:FLOOD_CAP] + lines.append(f"…({suppressed} more suppressed)") + + _send_batched(lines) + + +def _flush_loop() -> None: + """Background thread: flush buffer every BATCH_INTERVAL seconds.""" + while not _stop_event.is_set(): + _stop_event.wait(BATCH_INTERVAL) + if _buffer: + _flush_buffer() + + +def _send_batched(lines: list[str]) -> None: + """Split lines into ≤4000-char chunks and POST each to Telegram.""" + if not lines: + return + + batch: list[str] = [] + batch_len = 0 + + for line in lines: + line_len = len(line) + (1 if batch else 0) + if batch_len + line_len > TELEGRAM_MAX_LENGTH and batch: + _send_message("\n".join(batch)) + batch = [] + batch_len = 0 + batch.append(line) + batch_len += line_len + + if batch: + _send_message("\n".join(batch)) + + +def _send_message(text: str) -> bool: + """POST a single message to the Telegram Bot API.""" + url = f"https://api.telegram.org/bot{_bot_token}/sendMessage" + payload = json.dumps( + { + "chat_id": _chat_id, + "text": text, + "disable_notification": True, + } + ).encode("utf-8") + req = Request(url, data=payload, headers={"Content-Type": "application/json"}) + + try: + with _http_fetch(req, timeout=10) as resp: + result = json.loads(resp.read().decode("utf-8")) + return result.get("ok", False) + except (URLError, Exception) as e: + logger.warning("[telegram_relay] Send failed: %s", e) + return False + + +def is_relay_enabled_by_env() -> bool: + """Check if the relay is enabled via environment variable.""" + return os.environ.get("AIPASS_PRAX_MONITOR_RELAY", "").strip() in ("1", "true", "yes") diff --git a/src/aipass/prax/apps/modules/monitor.py b/src/aipass/prax/apps/modules/monitor.py index 4b04251d..373c7287 100755 --- a/src/aipass/prax/apps/modules/monitor.py +++ b/src/aipass/prax/apps/modules/monitor.py @@ -43,6 +43,12 @@ ModuleTracker, # module_tracker.py ) from aipass.prax.apps.handlers.monitoring.event_queue import MonitoringEvent +from aipass.prax.apps.handlers.monitoring.telegram_relay import ( + init_relay, + relay_event, + stop_relay, + is_relay_enabled_by_env, +) # ============================================================================= @@ -188,6 +194,10 @@ def print_help(): console.print(" Monitor specific branches (comma-separated)") console.print(" Example: drone @prax monitor run seedgo,cli,flow") console.print() + console.print(" [cyan]drone @prax monitor run --relay[/cyan]") + console.print(" Enable Telegram relay (mirrors feed to prax_monitor bot)") + console.print(" Also enabled by env AIPASS_PRAX_MONITOR_RELAY=1") + console.print() console.print(" [cyan]drone @prax monitor --help[/cyan]") console.print(" Show this help") console.print() @@ -249,6 +259,17 @@ def handle_command(command: str, args: List[str]) -> bool: return True +def _load_relay_config() -> Optional[dict]: + """Load Telegram relay config from @api secrets.""" + try: + from aipass.api.apps.modules.secrets import get_secret + + return get_secret("telegram", "prax_monitor", as_json=True) + except Exception as e: + logger.info("[monitor] Could not load relay config: %s", e) + return None + + def _run_monitor(args: List[str]) -> bool: """Launch Mission Control live monitoring.""" global _event_queue, _module_tracker @@ -262,6 +283,14 @@ def _run_monitor(args: List[str]) -> bool: _module_tracker = ModuleTracker() _stop_event.clear() + # Initialize Telegram relay (--relay flag or env var) + _relay_enabled = "--relay" in args or is_relay_enabled_by_env() + if _relay_enabled: + args = [a for a in args if a != "--relay"] + init_relay(_relay_enabled, _load_relay_config() if _relay_enabled else None) + if _relay_enabled: + console.print("[green]monitor → Telegram relay ON (prax_monitor)[/green]") + _is_tty = sys.stdin.isatty() # Display header @@ -311,10 +340,11 @@ def _start_threads(): def _stop_threads(): - """Stop all monitoring threads""" + """Stop all monitoring threads and Telegram relay""" global _event_queue _stop_event.set() + stop_relay() if _event_queue: _event_queue.stop() @@ -328,7 +358,7 @@ def _stop_threads(): def _render_event(event) -> None: - """Render a single monitoring event to the console.""" + """Render a single monitoring event to the console, and relay to Telegram.""" branch_pid = _get_pid_for_branch(event.branch) if event.event_type == "command": @@ -344,6 +374,8 @@ def _render_event(event) -> None: else: print_event(event.event_type, event.branch, event.message, event.level, pid=branch_pid) + relay_event(event) + def _display_worker(): """Display thread - pulls events from queue and displays them. No filtering.""" diff --git a/src/aipass/prax/prax-monitor.service b/src/aipass/prax/prax-monitor.service new file mode 100644 index 00000000..a1fd6b4b --- /dev/null +++ b/src/aipass/prax/prax-monitor.service @@ -0,0 +1,34 @@ +# Systemd user service for the prax monitor with Telegram relay. +# +# Install: +# cp prax-monitor.service ~/.config/systemd/user/ +# systemctl --user daemon-reload +# +# Usage: +# systemctl --user start prax-monitor +# systemctl --user enable prax-monitor # auto-start on login +# systemctl --user status prax-monitor + +[Unit] +Description=AIPass Prax Monitor — Telegram relay +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +# NOTE: the module __main__ takes a single positional (the subcommand); 'run' → +# _run_monitor([]) = all branches. The relay is enabled by the env var below +# (the __main__ argparse rejects extra tokens like 'all --relay'). +ExecStart=%h/Projects/AIPass/.venv/bin/python3 -m aipass.prax.apps.modules.monitor run +WorkingDirectory=%h/Projects/AIPass +Environment=AIPASS_PRAX_MONITOR_RELAY=1 +Restart=always +RestartSec=5 +# IMPORTANT: log OUTSIDE system_logs/ — the monitor tails system_logs/*.log and +# @trigger watches it too. Writing the monitor's own output there creates a +# feedback loop (monitor output → re-tailed/recorded → reported → more output). +StandardOutput=append:%h/.aipass/prax-monitor.service.log +StandardError=append:%h/.aipass/prax-monitor.service.log + +[Install] +WantedBy=default.target diff --git a/src/aipass/prax/tests/test_monitor_module.py b/src/aipass/prax/tests/test_monitor_module.py index 4beffded..31b7332f 100644 --- a/src/aipass/prax/tests/test_monitor_module.py +++ b/src/aipass/prax/tests/test_monitor_module.py @@ -40,6 +40,7 @@ "aipass.prax.apps.handlers.monitoring.interactive_filter": MagicMock(), "aipass.prax.apps.handlers.monitoring.monitoring_filters": MagicMock(), "aipass.prax.apps.handlers.monitoring.file_watcher_integration": MagicMock(), + "aipass.prax.apps.handlers.monitoring.telegram_relay": MagicMock(), } diff --git a/src/aipass/prax/tests/test_telegram_relay.py b/src/aipass/prax/tests/test_telegram_relay.py new file mode 100644 index 00000000..fdba8e93 --- /dev/null +++ b/src/aipass/prax/tests/test_telegram_relay.py @@ -0,0 +1,411 @@ +# =================== AIPass ==================== +# Name: test_telegram_relay.py +# Description: Tests for the Telegram relay handler +# Version: 1.0.0 +# Created: 2026-06-24 +# Modified: 2026-06-24 +# ============================================= + +"""Tests for apps/handlers/monitoring/telegram_relay.py + +Covers: +- Event formatting (log, command, hook types, PID labels) +- init_relay: disabled path, missing config, incomplete config, successful start +- Fail-silent-once: exactly one log line when config absent, zero sends +- stop_relay: final flush and thread join, no-op when inactive +- relay_event: buffering when active, no-op when inactive +- Batching: 4000-char split across messages +- Flood cap: truncation at 150 lines with suppression notice +- _render_event calls relay_event in monitor.py +- is_relay_enabled_by_env for env var detection +""" + +import importlib +import sys +from dataclasses import dataclass, field +from datetime import datetime +from typing import Optional +from unittest.mock import MagicMock, patch + + +@dataclass +class FakeEvent: + """Minimal MonitoringEvent stand-in for tests.""" + + priority: int = 3 + timestamp: datetime = field(default_factory=datetime.now) + event_type: str = "" + branch: str = "" + action: str = "" + message: str = "" + level: str = "info" + caller: Optional[str] = None + pid: Optional[int] = None + + +def _import_relay(): + """Import (or reload) telegram_relay with mocked dependencies.""" + fresh_mocks = { + "aipass.prax.apps.modules.logger": MagicMock(), + "aipass.prax.apps.handlers.json": MagicMock(), + "aipass.prax.apps.handlers.json.json_handler": MagicMock(), + } + with patch.dict(sys.modules, fresh_mocks): + if "aipass.prax.apps.handlers.monitoring.telegram_relay" in sys.modules: + mod = importlib.reload(sys.modules["aipass.prax.apps.handlers.monitoring.telegram_relay"]) + else: + mod = importlib.import_module("aipass.prax.apps.handlers.monitoring.telegram_relay") + + setattr(mod, "_RELAY_ACTIVE", False) + setattr(mod, "_bot_token", None) + setattr(mod, "_chat_id", None) + mod._buffer.clear() + mod._stop_event.clear() + setattr(mod, "_thread", None) + return mod + + +# --------------------------------------------------------------------------- +# Event formatting +# --------------------------------------------------------------------------- + + +class TestFormatEvent: + """Test _format_event produces correct plain-text lines.""" + + def test_log_event_basic(self): + """Log event includes uppercased branch and message.""" + relay = _import_relay() + event = FakeEvent(event_type="log", branch="seedgo", message="Audit started") + result = relay._format_event(event) + assert "[SEEDGO]" in result + assert "Audit started" in result + + def test_log_event_with_pid(self): + """Log event with PID shows BRANCH:PID label.""" + relay = _import_relay() + event = FakeEvent(event_type="log", branch="devpulse", message="Working", pid=12345) + result = relay._format_event(event) + assert "[DEVPULSE:12345]" in result + + def test_command_event(self): + """Command event shows arrow prefix.""" + relay = _import_relay() + event = FakeEvent(event_type="command", branch="drone", message="seedgo audit", action="") + result = relay._format_event(event) + assert "▶ seedgo audit" in result + + def test_command_event_with_caller_and_target(self): + """Command event with caller and target shows attribution line.""" + relay = _import_relay() + event = FakeEvent( + event_type="command", + branch="drone", + message="seedgo audit", + action="run:prax", + caller="devpulse", + ) + result = relay._format_event(event) + assert "devpulse → prax" in result + assert "▶ seedgo audit" in result + + def test_command_event_unknown_caller_omitted(self): + """Command event with UNKNOWN caller omits caller line.""" + relay = _import_relay() + event = FakeEvent( + event_type="command", + branch="drone", + message="test", + caller="UNKNOWN", + ) + result = relay._format_event(event) + assert "UNKNOWN" not in result + + def test_hook_event_fired(self): + """Fired hook event uses lightning bolt symbol.""" + relay = _import_relay() + event = FakeEvent(event_type="hook", branch="hooks", message="cadence:fired", action="fired") + result = relay._format_event(event) + assert result == "⚡ HOOK cadence:fired" + + def test_hook_event_skipped(self): + """Skipped hook event uses dot symbol.""" + relay = _import_relay() + event = FakeEvent(event_type="hook", branch="hooks", message="cadence:skipped", action="skipped") + result = relay._format_event(event) + assert result == "· HOOK cadence:skipped" + + def test_hook_event_unknown_action(self): + """Unknown hook action uses question mark symbol.""" + relay = _import_relay() + event = FakeEvent(event_type="hook", branch="hooks", message="something", action="other") + result = relay._format_event(event) + assert result.startswith("? HOOK") + + def test_file_event(self): + """File event formats like a log event with branch and message.""" + relay = _import_relay() + event = FakeEvent(event_type="file", branch="prax", message="monitor.py modified") + result = relay._format_event(event) + assert "[PRAX]" in result + assert "monitor.py modified" in result + + +# --------------------------------------------------------------------------- +# init_relay +# --------------------------------------------------------------------------- + + +class TestInitRelay: + """Test init_relay activation and fail-silent behavior.""" + + def test_disabled_is_noop(self): + """Disabled flag skips all initialization.""" + relay = _import_relay() + relay.init_relay(enabled=False) + assert relay._RELAY_ACTIVE is False + assert relay._thread is None + + def test_no_config_stays_inactive(self): + """None config keeps relay inactive.""" + relay = _import_relay() + relay.init_relay(enabled=True, config=None) + assert relay._RELAY_ACTIVE is False + + def test_no_config_logs_exactly_once(self): + """Missing config produces exactly one info log, not per-cycle spam.""" + relay = _import_relay() + relay.init_relay(enabled=True, config=None) + calls = [c for c in relay.logger.info.call_args_list if "inactive" in str(c)] + assert len(calls) == 1 + + def test_incomplete_config_missing_chat_id(self): + """Config without chat_id stays inactive.""" + relay = _import_relay() + relay.init_relay(enabled=True, config={"bot_token": "tok123"}) + assert relay._RELAY_ACTIVE is False + + def test_incomplete_config_missing_token(self): + """Config without bot_token stays inactive.""" + relay = _import_relay() + relay.init_relay(enabled=True, config={"chat_id": 123}) + assert relay._RELAY_ACTIVE is False + + def test_valid_config_activates(self): + """Valid config sets active flag and stores credentials.""" + relay = _import_relay() + relay.init_relay(enabled=True, config={"bot_token": "tok123", "chat_id": 456}) + assert relay._RELAY_ACTIVE is True + assert relay._bot_token == "tok123" + assert relay._chat_id == 456 + assert relay._thread is not None + relay.stop_relay() + + def test_valid_config_starts_daemon_thread(self): + """Valid config starts a named daemon thread.""" + relay = _import_relay() + relay.init_relay(enabled=True, config={"bot_token": "t", "chat_id": 1}) + assert relay._thread.daemon is True + assert relay._thread.name == "telegram-relay" + relay.stop_relay() + + +# --------------------------------------------------------------------------- +# relay_event +# --------------------------------------------------------------------------- + + +class TestRelayEvent: + """Test relay_event buffering.""" + + def test_noop_when_inactive(self): + """Inactive relay does not buffer events.""" + relay = _import_relay() + event = FakeEvent(event_type="log", branch="test", message="hello") + relay.relay_event(event) + assert len(relay._buffer) == 0 + + def test_buffers_when_active(self): + """Active relay appends formatted line to buffer.""" + relay = _import_relay() + setattr(relay, "_RELAY_ACTIVE", True) + event = FakeEvent(event_type="log", branch="test", message="hello") + relay.relay_event(event) + assert len(relay._buffer) == 1 + assert "hello" in relay._buffer[0] + + +# --------------------------------------------------------------------------- +# stop_relay +# --------------------------------------------------------------------------- + + +class TestStopRelay: + """Test stop_relay cleanup.""" + + def test_noop_when_inactive(self): + """Stopping an inactive relay is a safe no-op.""" + relay = _import_relay() + relay.stop_relay() + assert relay._RELAY_ACTIVE is False + + def test_flushes_and_joins(self): + """Stopping an active relay clears state and joins thread.""" + relay = _import_relay() + relay.init_relay(enabled=True, config={"bot_token": "t", "chat_id": 1}) + assert relay._RELAY_ACTIVE is True + relay.stop_relay() + assert relay._RELAY_ACTIVE is False + assert relay._thread is None + + +# --------------------------------------------------------------------------- +# Batching +# --------------------------------------------------------------------------- + + +class TestBatching: + """Test _send_batched 4000-char splitting.""" + + def test_single_message_under_limit(self): + """Short content sends as one message.""" + relay = _import_relay() + sent = [] + setattr(relay, "_send_message", lambda text: sent.append(text) or True) + relay._send_batched(["short line"]) + assert len(sent) == 1 + assert sent[0] == "short line" + + def test_splits_at_4000_chars(self): + """Lines exceeding 4000 chars are split across multiple messages.""" + relay = _import_relay() + sent = [] + setattr(relay, "_send_message", lambda text: sent.append(text) or True) + lines = [f"line-{i:04d}-" + "x" * 90 for i in range(50)] + relay._send_batched(lines) + assert len(sent) > 1 + for msg in sent: + assert len(msg) <= relay.TELEGRAM_MAX_LENGTH + 200 + + def test_empty_lines_sends_nothing(self): + """Empty list sends no messages.""" + relay = _import_relay() + sent = [] + setattr(relay, "_send_message", lambda text: sent.append(text) or True) + relay._send_batched([]) + assert len(sent) == 0 + + +# --------------------------------------------------------------------------- +# Flood cap +# --------------------------------------------------------------------------- + + +class TestFloodCap: + """Test flood cap truncation.""" + + def test_under_cap_sends_all(self): + """Lines under FLOOD_CAP are all delivered.""" + relay = _import_relay() + setattr(relay, "_bot_token", "t") + setattr(relay, "_chat_id", 1) + sent = [] + setattr(relay, "_send_batched", lambda lines: sent.extend(lines)) + relay._buffer.extend([f"line {i}" for i in range(100)]) + setattr(relay, "_RELAY_ACTIVE", True) + relay._flush_buffer() + assert len(sent) == 100 + + def test_over_cap_truncates_with_notice(self): + """Lines over FLOOD_CAP are truncated with a suppression notice.""" + relay = _import_relay() + setattr(relay, "_bot_token", "t") + setattr(relay, "_chat_id", 1) + sent_lines = [] + setattr(relay, "_send_batched", lambda lines: sent_lines.extend(lines)) + relay._buffer.extend([f"line {i}" for i in range(200)]) + setattr(relay, "_RELAY_ACTIVE", True) + relay._flush_buffer() + assert len(sent_lines) == relay.FLOOD_CAP + 1 + assert "50 more suppressed" in sent_lines[-1] + + +# --------------------------------------------------------------------------- +# _render_event calls relay_event +# --------------------------------------------------------------------------- + + +class TestRenderEventCallsRelay: + """Test that monitor._render_event calls relay_event.""" + + def test_render_event_calls_relay(self): + """_render_event in monitor.py calls relay_event after console render.""" + fresh_mocks = { + "aipass.prax.apps.handlers.monitoring": MagicMock(), + "aipass.prax.apps.handlers.monitoring.event_queue": MagicMock(), + "aipass.prax.apps.handlers.monitoring.filesystem_handler": MagicMock(), + "aipass.prax.apps.handlers.monitoring.log_watcher": MagicMock(), + "aipass.prax.apps.handlers.monitoring.unified_stream": MagicMock(), + "aipass.prax.apps.handlers.monitoring.module_tracker": MagicMock(), + "aipass.prax.apps.handlers.monitoring.branch_detector": MagicMock(), + "aipass.prax.apps.handlers.monitoring.interactive_filter": MagicMock(), + "aipass.prax.apps.handlers.monitoring.monitoring_filters": MagicMock(), + "aipass.prax.apps.handlers.monitoring.file_watcher_integration": MagicMock(), + "aipass.prax.apps.handlers.monitoring.telegram_relay": MagicMock(), + } + with patch.dict(sys.modules, fresh_mocks): + if "aipass.prax.apps.modules.monitor" in sys.modules: + mod = importlib.reload(sys.modules["aipass.prax.apps.modules.monitor"]) + else: + mod = importlib.import_module("aipass.prax.apps.modules.monitor") + + event = MagicMock() + event.event_type = "log" + event.branch = "TEST" + event.message = "hello" + event.level = "info" + event.action = "" + + with patch.object(mod, "_get_pid_for_branch", return_value=None): + mod._render_event(event) + + mod.relay_event.assert_called_once_with(event) + + +# --------------------------------------------------------------------------- +# is_relay_enabled_by_env +# --------------------------------------------------------------------------- + + +class TestRelayEnabledByEnv: + """Test environment variable detection.""" + + def test_not_set(self): + """Unset env var returns False.""" + relay = _import_relay() + with patch.dict("os.environ", {}, clear=True): + assert relay.is_relay_enabled_by_env() is False + + def test_set_to_1(self): + """AIPASS_PRAX_MONITOR_RELAY=1 returns True.""" + relay = _import_relay() + with patch.dict("os.environ", {"AIPASS_PRAX_MONITOR_RELAY": "1"}): + assert relay.is_relay_enabled_by_env() is True + + def test_set_to_true(self): + """AIPASS_PRAX_MONITOR_RELAY=true returns True.""" + relay = _import_relay() + with patch.dict("os.environ", {"AIPASS_PRAX_MONITOR_RELAY": "true"}): + assert relay.is_relay_enabled_by_env() is True + + def test_set_to_yes(self): + """AIPASS_PRAX_MONITOR_RELAY=yes returns True.""" + relay = _import_relay() + with patch.dict("os.environ", {"AIPASS_PRAX_MONITOR_RELAY": "yes"}): + assert relay.is_relay_enabled_by_env() is True + + def test_set_to_0(self): + """AIPASS_PRAX_MONITOR_RELAY=0 returns False.""" + relay = _import_relay() + with patch.dict("os.environ", {"AIPASS_PRAX_MONITOR_RELAY": "0"}): + assert relay.is_relay_enabled_by_env() is False diff --git a/src/aipass/seedgo/.seedgo/README.md b/src/aipass/seedgo/.seedgo/README.md index dd56a69b..db291616 100644 --- a/src/aipass/seedgo/.seedgo/README.md +++ b/src/aipass/seedgo/.seedgo/README.md @@ -3,3 +3,26 @@ Seedgo audit bypass config for `SEEDGO`. When an audit flags a false positive that doesn't apply to your architecture, add a bypass entry in `bypass.json` with a reason explaining why it's justified. + +## Fields + +| Field | Required | Description | +|-------|----------|-------------| +| `file` | yes | Relative path from branch root | +| `standard` | yes | Standard name (cli, imports, naming, etc.) | +| `lines` | no | Specific line numbers to bypass | +| `functions` | no | Function names for name-scoped bypass (required for `unused_function`) | +| `reason` | yes | Why this bypass exists | + +## Name-scoped bypass (unused_function) + +Use `functions` instead of `lines` for `unused_function` bypasses — function names are stable across edits, line numbers drift silently: + +```json +{ + "file": "apps/handlers/registry.py", + "standard": "unused_function", + "functions": ["get_skill", "get_skill_names"], + "reason": "Public API surface — called by external consumers" +} +``` diff --git a/src/aipass/seedgo/apps/handlers/aipass_standards/cli.md b/src/aipass/seedgo/apps/handlers/aipass_standards/cli.md index c3a570f7..b2c1b07d 100644 --- a/src/aipass/seedgo/apps/handlers/aipass_standards/cli.md +++ b/src/aipass/seedgo/apps/handlers/aipass_standards/cli.md @@ -135,7 +135,9 @@ console.print("[cyan]This is the ONLY approved way to output text[/cyan]") ### Help Output: Manual Rich Formatting -**DO NOT use `parser.print_help()`** - it outputs plain text. +**DO NOT use `parser.print_help()` or `parser.format_help()`** - both produce plain argparse text. + +Wrapping `format_help()` in `console.print()` still renders plain text — it launders argparse output through the approved API without adding Rich formatting. **Argparse is for PARSING arguments only, NOT for help output.** @@ -161,6 +163,9 @@ def print_help(): # DON'T DO THIS - outputs plain text parser = argparse.ArgumentParser(...) parser.print_help() + +# DON'T DO THIS EITHER - format_help() returns plain text +console.print(parser.format_help()) ``` --- diff --git a/src/aipass/seedgo/apps/handlers/aipass_standards/cli_check.py b/src/aipass/seedgo/apps/handlers/aipass_standards/cli_check.py index ff2ab6de..364f3579 100644 --- a/src/aipass/seedgo/apps/handlers/aipass_standards/cli_check.py +++ b/src/aipass/seedgo/apps/handlers/aipass_standards/cli_check.py @@ -333,6 +333,7 @@ def check_print_usage( # Find print() statements and raw stdout/stderr writes print_lines = [] parser_print_help_lines = [] + format_help_lines = [] raw_write_lines = [] # Track if we're inside an if __name__ == '__main__': block @@ -376,6 +377,15 @@ def check_print_usage( else: parser_print_help_lines.append(i) + # Check for .format_help() - returns plain argparse text + if ".format_help()" in stripped: + if "#" in line: + code_part = line.split("#")[0] + if ".format_help()" in code_part: + format_help_lines.append(i) + else: + format_help_lines.append(i) + # Check for raw sys.stdout.write() / sys.stderr.write() (bypasses Rich) if "sys.stdout.write(" in stripped or "sys.stderr.write(" in stripped: if "#" in line: @@ -412,6 +422,16 @@ def check_print_usage( ), } + if format_help_lines: + return { + "name": "print() usage", + "passed": False, + "message": ( + f"Found .format_help() in {filename} on lines " + f"{format_help_lines[:3]} (returns plain argparse text - write Rich help instead)" + ), + } + if raw_write_lines: return { "name": "print() usage", diff --git a/src/aipass/seedgo/apps/handlers/aipass_standards/cli_content.py b/src/aipass/seedgo/apps/handlers/aipass_standards/cli_content.py index e356db2b..1ca7c59f 100644 --- a/src/aipass/seedgo/apps/handlers/aipass_standards/cli_content.py +++ b/src/aipass/seedgo/apps/handlers/aipass_standards/cli_content.py @@ -35,10 +35,11 @@ def get_cli_standards() -> str: " • Only in test/temp code", " • Remove before commit", "", - "[red]✗ Never use:[/red] parser.print_help()", - " • Outputs plain text (violates standard)", + "[red]✗ Never use:[/red] parser.print_help() or parser.format_help()", + " • Both produce plain argparse text (violates standard)", + " • console.print(parser.format_help()) still renders plain text", " • Argparse is for PARSING only, not help output", - " • Write custom print_help() with console.print()", + " • Write custom print_help() with console.print() and Rich markup", "", "─" * 70, "", @@ -84,7 +85,8 @@ def get_cli_standards() -> str: "[bold cyan]RICH FORMATTING QUICK REFERENCE:[/bold cyan]", "", "[bold]Colors:[/bold]", - " [red]red[/red], [green]green[/green], [yellow]yellow[/yellow], [blue]blue[/blue], [cyan]cyan[/cyan], [magenta]magenta[/magenta]", + " [red]red[/red], [green]green[/green], [yellow]yellow[/yellow]," + " [blue]blue[/blue], [cyan]cyan[/cyan], [magenta]magenta[/magenta]", "", "[bold]Styles:[/bold]", " [bold]bold[/bold], [italic]italic[/italic], [dim]dim[/dim], [underline]underline[/underline]", diff --git a/src/aipass/seedgo/apps/handlers/aipass_standards/readme_check.py b/src/aipass/seedgo/apps/handlers/aipass_standards/readme_check.py index bdf23912..f691dc28 100644 --- a/src/aipass/seedgo/apps/handlers/aipass_standards/readme_check.py +++ b/src/aipass/seedgo/apps/handlers/aipass_standards/readme_check.py @@ -30,6 +30,7 @@ from aipass.prax import logger from aipass.seedgo.apps.handlers.json import json_handler from aipass.seedgo.apps.handlers.bypass.utils import is_bypassed +from aipass.seedgo.apps.handlers.aipass_standards.skip_dirs import SOURCE_SKIP_DIRS, is_disabled_file # Audit scope: entry points only (apps/{name}.py) AUDIT_SCOPE = "entry_point" @@ -395,6 +396,8 @@ def check_module_list(lines: List[str], branch_root: Path, file_path: str, bypas for py_file in sorted(modules_dir.glob("*.py")): if py_file.name == "__init__.py": continue + if is_disabled_file(py_file.name): + continue module_files.append(py_file.stem) if not module_files: @@ -502,6 +505,10 @@ def _count_test_functions(tests_dir: Path) -> int: count = 0 test_func_pattern = re.compile(r"^\s*def\s+test_", re.MULTILINE) for test_file in tests_dir.rglob("test_*.py"): + if any(part in SOURCE_SKIP_DIRS for part in test_file.relative_to(tests_dir).parts): + continue + if is_disabled_file(test_file.name): + continue try: source = test_file.read_text(encoding="utf-8") count += len(test_func_pattern.findall(source)) diff --git a/src/aipass/seedgo/apps/handlers/aipass_standards/test_quality_check.py b/src/aipass/seedgo/apps/handlers/aipass_standards/test_quality_check.py index 2735b4ba..c627cde0 100644 --- a/src/aipass/seedgo/apps/handlers/aipass_standards/test_quality_check.py +++ b/src/aipass/seedgo/apps/handlers/aipass_standards/test_quality_check.py @@ -195,7 +195,7 @@ def _find_test_files_broad(branch_path: Path) -> list[Path]: for py_file in sorted(tests_dir.rglob("*.py")): if py_file.name in ("__init__.py", "conftest.py"): continue - if "__pycache__" in py_file.parts: + if any(_should_skip_dir(part) for part in py_file.relative_to(tests_dir).parts): continue resolved = py_file.resolve() if resolved not in seen: diff --git a/src/aipass/seedgo/apps/handlers/aipass_standards/unused_function_check.py b/src/aipass/seedgo/apps/handlers/aipass_standards/unused_function_check.py index 1f12df36..b866c6e6 100644 --- a/src/aipass/seedgo/apps/handlers/aipass_standards/unused_function_check.py +++ b/src/aipass/seedgo/apps/handlers/aipass_standards/unused_function_check.py @@ -277,8 +277,7 @@ def check_branch(branch_path: str, bypass_rules: list | None = None) -> dict: unused_functions: list[dict] = [] for func_name, lineno, py_file in all_functions: - # Check bypass at file+line level - if is_bypassed(str(py_file), "unused_function", lineno, bypass_rules): + if is_bypassed(str(py_file), "unused_function", lineno, bypass_rules, name=func_name): continue total_refs = _count_references_in_corpus(func_name, corpus) diff --git a/src/aipass/seedgo/apps/handlers/bypass/bypass_handler.py b/src/aipass/seedgo/apps/handlers/bypass/bypass_handler.py index 98d7bc82..4c62c7ea 100644 --- a/src/aipass/seedgo/apps/handlers/bypass/bypass_handler.py +++ b/src/aipass/seedgo/apps/handlers/bypass/bypass_handler.py @@ -37,14 +37,13 @@ "file": "apps/modules/logger.py", "standard": "cli", "lines": [146, 177], - "pattern": "if __name__ == '__main__'", "reason": "Circular dependency - logger cannot import CLI", }, "fields": { "file": "Relative path from branch root (required)", "standard": "Standard name: cli, imports, naming, etc. (required)", "lines": "Optional - specific line numbers to bypass", - "pattern": "Optional - pattern to match (e.g. 'if __name__')", + "functions": "Optional - list of function names for name-scoped bypass (required for unused_function)", "reason": "Required - why this bypass exists", }, }, diff --git a/src/aipass/seedgo/apps/handlers/bypass/utils.py b/src/aipass/seedgo/apps/handlers/bypass/utils.py index 8fe216ca..a1c7633f 100644 --- a/src/aipass/seedgo/apps/handlers/bypass/utils.py +++ b/src/aipass/seedgo/apps/handlers/bypass/utils.py @@ -18,6 +18,7 @@ def is_bypassed( standard: str, line: int | None = None, bypass_rules: list | None = None, + name: str | None = None, ) -> bool: """Check if a violation should be bypassed. @@ -26,6 +27,7 @@ def is_bypassed( standard: Standard name (e.g., 'cli', 'imports') line: Optional specific line number of the violation bypass_rules: List of bypass rules from .seedgo/bypass.json + name: Optional function/symbol name for name-scoped bypasses Returns: True if this violation should be bypassed @@ -40,15 +42,20 @@ def is_bypassed( rule_file = rule.get("file", "") if rule_file and rule_file not in file_path_posix: continue - rule_lines = rule.get("lines", []) - if rule_lines and line is not None and line not in rule_lines: - continue + functions = rule.get("functions") + if functions and name is not None: + if name not in functions: + continue + elif rule.get("lines") and line is not None: + if line not in rule["lines"]: + continue json_handler.log_operation( "bypass_matched", { "file": file_path, "standard": standard, "line": line, + "name": name, "rule_file": rule_file, }, ) diff --git a/src/aipass/seedgo/tests/test_bypass.py b/src/aipass/seedgo/tests/test_bypass.py index 1f39c7fa..464104fc 100644 --- a/src/aipass/seedgo/tests/test_bypass.py +++ b/src/aipass/seedgo/tests/test_bypass.py @@ -44,6 +44,7 @@ def _mock_infrastructure(monkeypatch): for mod_name in [ "aipass.seedgo.apps.handlers.bypass.bypass_handler", "aipass.seedgo.apps.handlers.bypass.ignore_handler", + "aipass.seedgo.apps.handlers.bypass.utils", ]: monkeypatch.delitem(sys.modules, mod_name, raising=False) @@ -210,3 +211,214 @@ def test_get_deprecated_patterns_returns_dict(): for key, value in patterns.items(): assert isinstance(key, str) assert isinstance(value, str) + + +# --------------------------------------------------------------------------- +# Tests -- utils.is_bypassed name-scoped bypass +# --------------------------------------------------------------------------- + + +def test_utils_name_match_suppresses_regardless_of_line(): + """Name-scoped bypass matches by function name, ignoring line number.""" + from aipass.seedgo.apps.handlers.bypass.utils import is_bypassed + + rules = [ + { + "file": "apps/ops.py", + "standard": "unused_function", + "functions": ["update_command"], + "reason": "public API", + } + ] + assert ( + is_bypassed( + "/branch/apps/ops.py", + "unused_function", + line=999, + bypass_rules=rules, + name="update_command", + ) + is True + ) + + +def test_utils_name_not_in_functions_list(): + """Name-scoped bypass rejects function not in the functions list.""" + from aipass.seedgo.apps.handlers.bypass.utils import is_bypassed + + rules = [ + { + "file": "apps/ops.py", + "standard": "unused_function", + "functions": ["update_command"], + "reason": "public API", + } + ] + assert ( + is_bypassed( + "/branch/apps/ops.py", + "unused_function", + line=232, + bypass_rules=rules, + name="delete_command", + ) + is False + ) + + +def test_utils_line_drift_no_longer_breaks_name_scoped(): + """Line drift doesn't affect name-scoped bypass — name is stable.""" + from aipass.seedgo.apps.handlers.bypass.utils import is_bypassed + + rules = [ + { + "file": "apps/ops.py", + "standard": "unused_function", + "functions": ["get_skill", "get_skill_names"], + "reason": "public API", + } + ] + assert ( + is_bypassed( + "/branch/apps/ops.py", + "unused_function", + line=52, + bypass_rules=rules, + name="get_skill", + ) + is True + ) + assert ( + is_bypassed( + "/branch/apps/ops.py", + "unused_function", + line=9999, + bypass_rules=rules, + name="get_skill", + ) + is True + ) + + +def test_utils_functions_present_name_none_falls_back_to_lines(): + """When functions is set but name=None (other checker), fall back to line matching.""" + from aipass.seedgo.apps.handlers.bypass.utils import is_bypassed + + rules = [ + { + "file": "apps/ops.py", + "standard": "unused_function", + "functions": ["update_command"], + "lines": [10], + "reason": "test", + } + ] + assert ( + is_bypassed( + "/branch/apps/ops.py", + "unused_function", + line=10, + bypass_rules=rules, + name=None, + ) + is True + ) + + +def test_utils_existing_lines_only_rules_still_work(): + """Existing line-only rules (no functions field) still match by line.""" + from aipass.seedgo.apps.handlers.bypass.utils import is_bypassed + + rules = [ + { + "file": "apps/foo.py", + "standard": "cli", + "lines": [10, 20], + "reason": "circular", + } + ] + assert ( + is_bypassed( + "/branch/apps/foo.py", + "cli", + line=10, + bypass_rules=rules, + ) + is True + ) + assert ( + is_bypassed( + "/branch/apps/foo.py", + "cli", + line=99, + bypass_rules=rules, + ) + is False + ) + + +def test_utils_file_only_bypass_still_matches(): + """File-level bypass (no lines, no functions) still works.""" + from aipass.seedgo.apps.handlers.bypass.utils import is_bypassed + + rules = [ + { + "file": "apps/foo.py", + "standard": "unused_function", + "reason": "whole file bypassed", + } + ] + assert ( + is_bypassed( + "/branch/apps/foo.py", + "unused_function", + line=50, + bypass_rules=rules, + name="anything", + ) + is True + ) + + +def test_utils_multiple_functions_in_one_rule(): + """A single rule can list multiple function names.""" + from aipass.seedgo.apps.handlers.bypass.utils import is_bypassed + + rules = [ + { + "file": "apps/registry.py", + "standard": "unused_function", + "functions": ["get_skill", "get_skill_names"], + "reason": "public API", + } + ] + assert ( + is_bypassed( + "/branch/apps/registry.py", + "unused_function", + line=1, + bypass_rules=rules, + name="get_skill", + ) + is True + ) + assert ( + is_bypassed( + "/branch/apps/registry.py", + "unused_function", + line=1, + bypass_rules=rules, + name="get_skill_names", + ) + is True + ) + assert ( + is_bypassed( + "/branch/apps/registry.py", + "unused_function", + line=1, + bypass_rules=rules, + name="other_func", + ) + is False + ) diff --git a/src/aipass/seedgo/tests/test_checkers_batch6.py b/src/aipass/seedgo/tests/test_checkers_batch6.py index 11d35485..f603329f 100644 --- a/src/aipass/seedgo/tests/test_checkers_batch6.py +++ b/src/aipass/seedgo/tests/test_checkers_batch6.py @@ -50,17 +50,27 @@ def _mock_infrastructure(monkeypatch): json_mod, ) - # -- bypass handler (used by architecture_check) ------------------------ + # -- bypass handler (used by architecture_check + cli_check) ------------- bypass_pkg = MagicMock() bypass_ignore = MagicMock() bypass_ignore.get_template_ignore_patterns = MagicMock(return_value=[]) bypass_pkg.ignore_handler = bypass_ignore + from aipass.seedgo.apps.handlers.bypass.utils import is_bypassed as _real_is_bypassed + + bypass_utils = MagicMock() + bypass_utils.is_bypassed = _real_is_bypassed + bypass_pkg.utils = bypass_utils monkeypatch.setitem(sys.modules, "aipass.seedgo.apps.handlers.bypass", bypass_pkg) monkeypatch.setitem( sys.modules, "aipass.seedgo.apps.handlers.bypass.ignore_handler", bypass_ignore, ) + monkeypatch.setitem( + sys.modules, + "aipass.seedgo.apps.handlers.bypass.utils", + bypass_utils, + ) # Force re-imports so checkers pick up fresh mocks for mod_name in [ @@ -545,6 +555,30 @@ def test_parser_print_help_fails(self): assert result["passed"] is False assert "parser.print_help()" in result["message"] + def test_format_help_fails(self): + """console.print(parser.format_help()) fails — plain argparse text.""" + from aipass.seedgo.apps.handlers.aipass_standards.cli_check import ( + check_print_usage, + ) + + content = "console.print(parser.format_help())\n" + lines = _lines(content) + result = check_print_usage(content, lines, "/module.py") + assert result is not None + assert result["passed"] is False + assert ".format_help()" in result["message"] + + def test_format_help_in_comment_ignored(self): + """.format_help() inside a comment is not flagged.""" + from aipass.seedgo.apps.handlers.aipass_standards.cli_check import ( + check_print_usage, + ) + + content = "# parser.format_help() should not be used\n" + lines = _lines(content) + result = check_print_usage(content, lines, "/module.py") + assert result is None + def test_sys_stdout_write_fails(self): """sys.stdout.write() usage fails.""" from aipass.seedgo.apps.handlers.aipass_standards.cli_check import ( diff --git a/src/aipass/seedgo/tests/test_readme_content_checks.py b/src/aipass/seedgo/tests/test_readme_content_checks.py index ec2f55f3..c43b5ff4 100644 --- a/src/aipass/seedgo/tests/test_readme_content_checks.py +++ b/src/aipass/seedgo/tests/test_readme_content_checks.py @@ -498,6 +498,41 @@ def test_is_runtime_artifact_known_dirs(): assert _is_runtime_artifact(Path("/any/path/tests")) is False +def test_module_list_skips_disabled_file(tmp_path): + """A (disabled) .py in apps/modules/ must not trigger a 'missing module' violation.""" + from aipass.seedgo.apps.handlers.aipass_standards.readme_check import ( + check_module_list, + ) + + modules_dir = tmp_path / "apps" / "modules" + modules_dir.mkdir(parents=True) + (modules_dir / "__init__.py").write_text("", encoding="utf-8") + (modules_dir / "real_module.py").write_text("# real\n", encoding="utf-8") + (modules_dir / "old_module(disabled).py").write_text("# disabled\n", encoding="utf-8") + + readme_lines = _lines("# Branch\n\nreal_module is mentioned here.\n") + result = check_module_list(readme_lines, tmp_path, "fake.py") + assert result["passed"] is True + assert "old_module" not in result["message"] + + +def test_count_test_functions_skips_disabled_file(tmp_path): + """_count_test_functions must exclude test_*(disabled).py files.""" + from aipass.seedgo.apps.handlers.aipass_standards.readme_check import ( + _count_test_functions, + ) + + tests_dir = tmp_path / "tests" + tests_dir.mkdir() + (tests_dir / "test_active.py").write_text("def test_one(): pass\ndef test_two(): pass\n", encoding="utf-8") + (tests_dir / "test_old(disabled).py").write_text( + "def test_ghost(): pass\ndef test_phantom(): pass\ndef test_zombie(): pass\n", + encoding="utf-8", + ) + + assert _count_test_functions(tests_dir) == 2 + + def test_directory_tree_passes_absent_runtime_dir(tmp_path): """Parity regression: README tree lists runtime dir (logs), dir absent on disk, no git available — tree check passes via _is_runtime_artifact.""" diff --git a/src/aipass/skills/.aipass/aipass_local_prompt.md b/src/aipass/skills/.aipass/aipass_local_prompt.md index a00be480..02b476ec 100644 --- a/src/aipass/skills/.aipass/aipass_local_prompt.md +++ b/src/aipass/skills/.aipass/aipass_local_prompt.md @@ -1,5 +1,5 @@ # SKILLS — Branch Context - + Capability framework for AI agents. Discoverable, validatable, executable skill units across three tiers: markdown-only, with handler, full 3-layer. @@ -34,7 +34,7 @@ apps/ │ ├── validator.py # Requirement checking (pip, bins, config) │ └── template.py # Template resolution and copying ├── plugins/ # Extension point (empty) -catalog/ # Built-in skills: drone_commands, github, system_status +lib/ # Built-in skills: branch_health, drone_commands, github, inbox_check, system_status, telegram templates/ # Skill creation templates (markdown_only, with_handler, full) ``` @@ -42,7 +42,7 @@ templates/ # Skill creation templates (markdown_only, with_handl 1. `.aipass/skills/` — Project-local skills 2. `~/.aipass/skills/` — Global user skills -3. `src/skills/catalog/` — Built-in skills +3. `src/aipass/skills/lib/` — Built-in skills ## Three Skill Tiers diff --git a/src/aipass/skills/.aipass/skills/another_test/SKILL.md b/src/aipass/skills/.aipass/skills/another_test/SKILL.md deleted file mode 100644 index bc7f5601..00000000 --- a/src/aipass/skills/.aipass/skills/another_test/SKILL.md +++ /dev/null @@ -1,27 +0,0 @@ ---- -name: another_test -description: TODO — describe what this skill does -version: 1.0.0 -tags: [] -requires: - pip: [] - bins: [] - config: [] -has_handler: true ---- - -# another_test - -## What This Does -TODO - -## When to Use -TODO - -## Steps -1. TODO - -## Example -``` -TODO -``` diff --git a/src/aipass/skills/.aipass/skills/another_test/handler.py b/src/aipass/skills/.aipass/skills/another_test/handler.py deleted file mode 100644 index aa687a64..00000000 --- a/src/aipass/skills/.aipass/skills/another_test/handler.py +++ /dev/null @@ -1,30 +0,0 @@ -""" -another_test skill handler - -Called by: drone @skills run another_test [args] -""" - - -def run(action, args=None, config=None): - """Execute a skill action. - - Args: - action: What to do - args: Dict of action arguments - config: Dict of resolved config values - - Returns: - {"success": bool, "output": str, "error": str|None} - """ - args = args or {} - config = config or {} - - if action == "example": - return {"success": True, "output": "It works!", "error": None} - - return {"success": False, "output": "", "error": f"Unknown action: {action}"} - - -def get_actions(): - """List available actions for this skill.""" - return ["example"] diff --git a/src/aipass/skills/.aipass/skills/full_test/SKILL.md b/src/aipass/skills/.aipass/skills/full_test/SKILL.md deleted file mode 100644 index 14ccde01..00000000 --- a/src/aipass/skills/.aipass/skills/full_test/SKILL.md +++ /dev/null @@ -1,27 +0,0 @@ ---- -name: full_test -description: TODO — describe what this skill does -version: 1.0.0 -tags: [] -requires: - pip: [] - bins: [] - config: [] -has_handler: true ---- - -# full_test - -## What This Does -TODO - -## When to Use -TODO - -## Steps -1. TODO - -## Example -``` -TODO -``` diff --git a/src/aipass/skills/.aipass/skills/full_test/apps/__init__.py b/src/aipass/skills/.aipass/skills/full_test/apps/__init__.py deleted file mode 100644 index 254aa8dd..00000000 --- a/src/aipass/skills/.aipass/skills/full_test/apps/__init__.py +++ /dev/null @@ -1,7 +0,0 @@ -# ===================AIPASS==================== -# META DATA HEADER -# Name: __init__.py - full_test apps package -# Date: 2026-03-07 -# Version: 1.0.0 -# Category: skills/catalog/full_test/apps -# ============================================= diff --git a/src/aipass/skills/.aipass/skills/full_test/apps/handlers/__init__.py b/src/aipass/skills/.aipass/skills/full_test/apps/handlers/__init__.py deleted file mode 100644 index d8469cca..00000000 --- a/src/aipass/skills/.aipass/skills/full_test/apps/handlers/__init__.py +++ /dev/null @@ -1,13 +0,0 @@ -# ===================AIPASS==================== -# META DATA HEADER -# Name: __init__.py - full_test handlers package -# Date: 2026-03-07 -# Version: 1.0.0 -# Category: skills/catalog/full_test/apps/handlers -# -# CHANGELOG (Max 5 entries): -# - v1.0.0 (2026-03-07): Initial scaffold -# -# CODE STANDARDS: -# - Handlers layer: returns dicts, NEVER prints -# ============================================= diff --git a/src/aipass/skills/.aipass/skills/full_test/apps/modules/__init__.py b/src/aipass/skills/.aipass/skills/full_test/apps/modules/__init__.py deleted file mode 100644 index f1d4d7f5..00000000 --- a/src/aipass/skills/.aipass/skills/full_test/apps/modules/__init__.py +++ /dev/null @@ -1,13 +0,0 @@ -# ===================AIPASS==================== -# META DATA HEADER -# Name: __init__.py - full_test modules package -# Date: 2026-03-07 -# Version: 1.0.0 -# Category: skills/catalog/full_test/apps/modules -# -# CHANGELOG (Max 5 entries): -# - v1.0.0 (2026-03-07): Initial scaffold -# -# CODE STANDARDS: -# - Modules layer: orchestration (can print) -# ============================================= diff --git a/src/aipass/skills/.aipass/skills/full_test/handler.py b/src/aipass/skills/.aipass/skills/full_test/handler.py deleted file mode 100644 index d213f2ba..00000000 --- a/src/aipass/skills/.aipass/skills/full_test/handler.py +++ /dev/null @@ -1,24 +0,0 @@ -""" -full_test — Full 3-layer skill handler. - -Scaffolded by: drone @skills create full_test --full -""" - - -def run(action: str, args: list, config: dict) -> dict: - """ - Execute the skill. - - Args: - action: The action to perform - args: Command arguments - config: Skill configuration from SKILL.md - - Returns: - dict with keys: success (bool), output (str), error (str|None) - """ - return { - "success": True, - "output": f"full_test executed action: {action}", - "error": None, - } diff --git a/src/aipass/skills/.aipass/skills/test_skill/SKILL.md b/src/aipass/skills/.aipass/skills/test_skill/SKILL.md deleted file mode 100644 index a9e3aa22..00000000 --- a/src/aipass/skills/.aipass/skills/test_skill/SKILL.md +++ /dev/null @@ -1,27 +0,0 @@ ---- -name: test_skill -description: TODO — describe what this skill does -version: 1.0.0 -tags: [] -requires: - pip: [] - bins: [] - config: [] -has_handler: false ---- - -# test_skill - -## What This Does -TODO - -## When to Use -TODO - -## Steps -1. TODO - -## Example -``` -TODO -``` diff --git a/src/aipass/skills/.gitignore b/src/aipass/skills/.gitignore index 9cf1dfc4..a926f1c5 100644 --- a/src/aipass/skills/.gitignore +++ b/src/aipass/skills/.gitignore @@ -12,3 +12,4 @@ build/ *.log *.tmp *.swp +.local/ diff --git a/src/aipass/skills/.seedgo/bypass.json b/src/aipass/skills/.seedgo/bypass.json index cbbfffc4..33c6c9b4 100644 --- a/src/aipass/skills/.seedgo/bypass.json +++ b/src/aipass/skills/.seedgo/bypass.json @@ -6,15 +6,156 @@ }, "bypass": [ { - "file": ".aipass/skills/telegram/tests/test_response_router.py", + "file": "lib/telegram/tests/conftest.py", + "standard": "architecture", + "reason": "Test infrastructure — conftest.py lives in tests/ by pytest convention, not the 3-layer app structure. Test support files are exempt." + }, + { + "file": "lib/telegram/tests/conftest.py", + "standard": "encapsulation", + "reason": "Test infrastructure — imports aipass.prax.apps.handlers.logging.direct to redirect log output during tests. Necessary to clear cached logger state; no module entry point exists for this internal reset." + }, + { + "file": "lib/telegram/tests/conftest.py", + "standard": "imports", + "reason": "Test infrastructure — sys.path manipulation is intentional: adds src/ root for aipass.* imports and skill root for local apps.handlers.* imports. Required because tests run without a full pip install of the skill." + }, + { + "file": "lib/telegram/tests/test_response_router.py", "standard": "architecture", "reason": "Test file — lives in tests/ by convention, not in the 3-layer app structure. Test files are exempt from layer architecture standard." }, { - "file": ".aipass/skills/telegram/tests/test_response_router.py", + "file": "lib/telegram/tests/test_response_router.py", "standard": "encapsulation", "reason": "Test file — imports handler module directly for unit testing. Tests need direct access to monkeypatch module-level attributes and verify handler behavior." }, + { + "file": "lib/telegram/tests/test_monitor.py", + "standard": "architecture", + "reason": "Test file — lives in tests/ by convention. Test files are exempt from layer architecture standard." + }, + { + "file": "lib/telegram/tests/test_monitor.py", + "standard": "encapsulation", + "reason": "Test file — imports handler directly for unit testing. Tests need direct access to patch module-level state and verify handler internals." + }, + { + "file": "lib/telegram/tests/test_monitor.py", + "standard": "documentation", + "reason": "Test file — public functions are pytest test methods; docstrings on individual test methods are optional when class docstring and test name are self-documenting." + }, + { + "file": "lib/telegram/tests/test_multi_bot.py", + "standard": "architecture", + "reason": "Test file — lives in tests/ by convention. Test files are exempt from layer architecture standard." + }, + { + "file": "lib/telegram/tests/test_multi_bot.py", + "standard": "encapsulation", + "reason": "Test file — imports handler directly for unit testing. Tests need direct access to patch module-level state." + }, + { + "file": "lib/telegram/tests/test_multi_bot.py", + "standard": "documentation", + "reason": "Test file — pytest test methods are self-documenting via class/method names; docstrings on individual test cases are optional." + }, + { + "file": "lib/telegram/tests/test_multi_bot.py", + "standard": "hardcoded_path", + "reason": "Test data — /home/aipass/* paths are mock return values passed to validate_branch() and create_bot() mocks, not real filesystem paths. They represent synthetic registry entries in test fixtures." + }, + { + "file": "lib/telegram/tests/test_multi_bot.py", + "standard": "trigger", + "lines": [409], + "reason": "Test-only teardown — base_bot.pending_file.unlink() simulates file absence to verify heartbeat backward-compat behavior. No trigger event is appropriate for test fixture manipulation." + }, + { + "file": "lib/telegram/tests/test_attach_only.py", + "standard": "architecture", + "reason": "Test file — lives in tests/ by convention. Test files are exempt from layer architecture standard." + }, + { + "file": "lib/telegram/tests/test_attach_only.py", + "standard": "encapsulation", + "reason": "Test file — imports handler directly for unit testing." + }, + { + "file": "lib/telegram/tests/test_attach_only.py", + "standard": "documentation", + "reason": "Test file — pytest test methods are self-documenting via class/method names." + }, + { + "file": "lib/telegram/tests/test_heartbeat_delivered.py", + "standard": "architecture", + "reason": "Test file — lives in tests/ by convention. Test files are exempt from layer architecture standard." + }, + { + "file": "lib/telegram/tests/test_heartbeat_delivered.py", + "standard": "encapsulation", + "reason": "Test file — imports handler directly for unit testing." + }, + { + "file": "lib/telegram/tests/test_heartbeat_delivered.py", + "standard": "documentation", + "reason": "Test file — pytest test methods are self-documenting via class/method names." + }, + { + "file": "lib/telegram/tests/test_status_reset.py", + "standard": "architecture", + "reason": "Test file — lives in tests/ by convention. Test files are exempt from layer architecture standard." + }, + { + "file": "lib/telegram/tests/test_status_reset.py", + "standard": "encapsulation", + "reason": "Test file — imports handler directly for unit testing." + }, + { + "file": "lib/telegram/tests/test_status_reset.py", + "standard": "documentation", + "reason": "Test file — pytest test methods are self-documenting via class/method names." + }, + { + "file": "lib/telegram/tests/test_mirror_session.py", + "standard": "architecture", + "reason": "Test file — lives in tests/ by convention. Test files are exempt from layer architecture standard." + }, + { + "file": "lib/telegram/tests/test_mirror_session.py", + "standard": "encapsulation", + "reason": "Test file — imports handler directly for unit testing." + }, + { + "file": "lib/telegram/tests/test_mirror_session.py", + "standard": "hardcoded_path", + "reason": "Test data — /home/test/api is a mock return value for validate_branch(), not a real filesystem path." + }, + { + "file": "lib/telegram/tests/test_mirror_session.py", + "standard": "permission_flags", + "reason": "Test file — asserts that launch_mirror_session() correctly passes --dangerously-skip-permissions. Testing the TDPLAN-0009 feature, not bypassing permissions." + }, + { + "file": "lib/telegram/apps/handlers/bot_factory.py", + "standard": "handlers", + "reason": "DPLAN-0220 incomplete port — bot_factory imports _api_set_secret from aipass.api.apps.modules.secrets for config persistence. Same pattern as config.py." + }, + { + "file": "lib/telegram/apps/handlers/bot_factory.py", + "standard": "json_structure", + "reason": "DPLAN-0220 incomplete port — factory functions predate json_handler.log_operation convention. Adding log_operation to every function is a separate cleanup task." + }, + { + "file": "lib/telegram/apps/handlers/bot_factory.py", + "standard": "meta", + "reason": "DPLAN-0220 incomplete port — file uses legacy header format from Dev-Pass port, not the META block format." + }, + { + "file": "lib/telegram/apps/handlers/bot_factory.py", + "standard": "permission_flags", + "reason": "TDPLAN-0009 — launch_mirror_session() intentionally uses --dangerously-skip-permissions. Detached tmux mirror sessions have no operator to approve prompts; without it the session hangs forever. Patrick's explicit ask." + }, { "file": "apps/handlers/loader_handler.py", "standard": "handlers", @@ -39,9 +180,56 @@ { "file": "apps/handlers/registry.py", "standard": "unused_function", - "lines": [52, 68], - "pattern": "def get_skill|def get_skill_names", + "functions": ["get_skill", "get_skill_names"], "reason": "Public API functions — used by test_registry.py and available for external callers; part of the registry module's contract" + }, + { + "file": "lib/telegram/apps/handlers/base_bot.py", + "standard": "unused_function", + "functions": ["chunk_text", "on_response"], + "reason": "DPLAN-0220 incomplete port — ported-but-unwired from the 9k-line Dev-Pass telegram port. chunk_text (long-message splitting) and on_response (response hook) await wiring; on_response is a pending Wave-2 design call. See README → Ported-but-unwired." + }, + { + "file": "lib/telegram/apps/handlers/bot_operations.py", + "standard": "unused_function", + "functions": ["get_all_bots"], + "reason": "DPLAN-0220 incomplete port — multi-bot listing helper, ported-but-unwired pending multi-bot wiring. See README → Ported-but-unwired." + }, + { + "file": "lib/telegram/apps/handlers/bot_registry.py", + "standard": "unused_function", + "functions": ["get_bot_by_work_dir"], + "reason": "DPLAN-0220 incomplete port — used by the response router to match CWD→bot; awaits the response_router wiring (td-38 import-vs-delete). See README → Ported-but-unwired." + }, + { + "file": "lib/telegram/apps/handlers/branch_plugin.py", + "standard": "unused_function", + "functions": ["on_response"], + "reason": "DPLAN-0220 incomplete port — branch-plugin response hook, ported-but-unwired; pending Wave-2 on_response design call (td-38). See README → Ported-but-unwired." + }, + { + "file": "lib/telegram/apps/handlers/config.py", + "standard": "unused_function", + "functions": ["get_allowed_user_ids", "validate_config"], + "reason": "DPLAN-0220 incomplete port — config accessors/validator ported-but-unwired, pending wiring. See README → Ported-but-unwired." + }, + { + "file": "lib/telegram/apps/handlers/file_handler.py", + "standard": "unused_function", + "functions": ["download_telegram_file", "cleanup_file"], + "reason": "DPLAN-0220 incomplete port — file up/download feature ported-but-unwired, pending wiring. See README → Ported-but-unwired." + }, + { + "file": "lib/telegram/apps/handlers/response_router.py", + "standard": "unused_function", + "functions": ["find_pending_bot", "clean_expired_pending"], + "reason": "DPLAN-0220 incomplete port — response-router helpers, pending the response_router import-vs-delete design call (td-38). See README → Ported-but-unwired." + }, + { + "file": "lib/telegram/apps/handlers/tmux_manager.py", + "standard": "unused_function", + "functions": ["_send_rename", "has_tmux", "kill_session", "list_sessions", "get_session_pane"], + "reason": "DPLAN-0220 incomplete port — interactive tmux session management, ported-but-unwired pending wiring (td-38 tmux). See README → Ported-but-unwired." } ], "notes": { diff --git a/src/aipass/skills/README.md b/src/aipass/skills/README.md index 94f0afe6..97ed0ca4 100644 --- a/src/aipass/skills/README.md +++ b/src/aipass/skills/README.md @@ -104,7 +104,7 @@ Skills are discovered in this order (first match wins for same name): 1. **Project**: `.aipass/skills/` in the current working directory 2. **Global**: `~/.aipass/skills/` in the user's home directory -3. **Built-in**: `src/skills/catalog/` in the AIPass codebase +3. **Built-in**: `src/aipass/skills/lib/` in the AIPass codebase ## Commands / Usage @@ -124,7 +124,7 @@ drone @skills --help # Show help ## Directory Structure ``` -src/skills/ +src/aipass/skills/ apps/ skills.py # Entry point (handle_command) modules/ @@ -140,7 +140,7 @@ src/skills/ validator.py # Check requirements template.py # Skill templates plugins/ # Plugin extensions - catalog/ # Built-in skills (branch_health, drone_commands, github, inbox_check, system_status) + lib/ # Built-in skills (branch_health, drone_commands, github, inbox_check, system_status, telegram) templates/ # Skill creation templates skills_json/ # JSON tracking directory dropbox/ # External storage sync diff --git a/src/aipass/skills/apps/handlers/discovery_handler.py b/src/aipass/skills/apps/handlers/discovery_handler.py index 86644465..8f49474e 100644 --- a/src/aipass/skills/apps/handlers/discovery_handler.py +++ b/src/aipass/skills/apps/handlers/discovery_handler.py @@ -39,7 +39,7 @@ def get_search_paths(): Search order (first match wins for same name): 1. Current project: .aipass/skills/ 2. Global user: ~/.aipass/skills/ - 3. Built-in: src/skills/catalog/ + 3. Built-in: src/aipass/skills/lib/ Returns: list[tuple[Path, str]]: List of (path, source_label) tuples. @@ -54,8 +54,8 @@ def get_search_paths(): global_path = Path.home() / ".aipass" / "skills" paths.append((global_path, "global")) - # 3. Built-in catalog - builtin_path = Path(__file__).resolve().parent.parent.parent / "catalog" + # 3. Built-in lib + builtin_path = Path(__file__).resolve().parent.parent.parent / "lib" paths.append((builtin_path, "builtin")) return paths diff --git a/src/aipass/skills/apps/handlers/template.py b/src/aipass/skills/apps/handlers/template.py index 9ded5643..989c2e25 100644 --- a/src/aipass/skills/apps/handlers/template.py +++ b/src/aipass/skills/apps/handlers/template.py @@ -13,7 +13,7 @@ from aipass.skills.apps.handlers.json import json_handler -# Template directory lives at src/skills/templates/ +# Template directory lives at src/aipass/skills/templates/ TEMPLATES_DIR = Path(__file__).resolve().parent.parent.parent / "templates" VALID_TYPES = ("markdown_only", "with_handler", "full") diff --git a/src/aipass/skills/apps/skills.py b/src/aipass/skills/apps/skills.py index a774988f..d8e75b78 100644 --- a/src/aipass/skills/apps/skills.py +++ b/src/aipass/skills/apps/skills.py @@ -123,7 +123,7 @@ def print_help(): console.print("Search paths (first match wins):") console.print(" 1. .aipass/skills/ Project-local skills") console.print(" 2. ~/.aipass/skills/ Global user skills") - console.print(" 3. src/skills/catalog/ Built-in skills") + console.print(" 3. src/aipass/skills/lib/ Built-in skills") def _cmd_list(): diff --git a/src/aipass/skills/catalog/.gitkeep b/src/aipass/skills/lib/.gitkeep similarity index 100% rename from src/aipass/skills/catalog/.gitkeep rename to src/aipass/skills/lib/.gitkeep diff --git a/src/aipass/skills/.aipass/skills/telegram/tests/__init__.py b/src/aipass/skills/lib/__init__.py similarity index 100% rename from src/aipass/skills/.aipass/skills/telegram/tests/__init__.py rename to src/aipass/skills/lib/__init__.py diff --git a/src/aipass/skills/catalog/branch_health/SKILL.md b/src/aipass/skills/lib/branch_health/SKILL.md similarity index 100% rename from src/aipass/skills/catalog/branch_health/SKILL.md rename to src/aipass/skills/lib/branch_health/SKILL.md diff --git a/src/aipass/skills/catalog/branch_health/handler.py b/src/aipass/skills/lib/branch_health/handler.py similarity index 98% rename from src/aipass/skills/catalog/branch_health/handler.py rename to src/aipass/skills/lib/branch_health/handler.py index c9d74b8d..cb6665fd 100644 --- a/src/aipass/skills/catalog/branch_health/handler.py +++ b/src/aipass/skills/lib/branch_health/handler.py @@ -3,7 +3,7 @@ # Name: handler.py - Branch Health skill handler # Date: 2026-03-29 # Version: 1.0.0 -# Category: skills/catalog/branch_health +# Category: skills/lib/branch_health # ============================================= """ @@ -58,7 +58,7 @@ def get_actions(): def _src_root(): """Return the src/ directory by navigating up from this handler.""" - # handler.py -> branch_health/ -> catalog/ -> skills/ -> aipass/ -> src/ + # handler.py -> branch_health/ -> lib/ -> skills/ -> aipass/ -> src/ return Path(__file__).resolve().parents[4] diff --git a/src/aipass/skills/catalog/drone_commands/SKILL.md b/src/aipass/skills/lib/drone_commands/SKILL.md similarity index 100% rename from src/aipass/skills/catalog/drone_commands/SKILL.md rename to src/aipass/skills/lib/drone_commands/SKILL.md diff --git a/src/aipass/skills/catalog/drone_commands/apps/__init__.py b/src/aipass/skills/lib/drone_commands/apps/__init__.py similarity index 87% rename from src/aipass/skills/catalog/drone_commands/apps/__init__.py rename to src/aipass/skills/lib/drone_commands/apps/__init__.py index 1db46e55..faa1eed7 100644 --- a/src/aipass/skills/catalog/drone_commands/apps/__init__.py +++ b/src/aipass/skills/lib/drone_commands/apps/__init__.py @@ -3,7 +3,7 @@ # Name: __init__.py - drone_commands apps package # Date: 2026-03-07 # Version: 1.0.0 -# Category: skills/catalog/drone_commands/apps +# Category: skills/lib/drone_commands/apps # # CHANGELOG (Max 5 entries): # - v1.0.0 (2026-03-07): Initial implementation diff --git a/src/aipass/skills/catalog/drone_commands/apps/handlers/__init__.py b/src/aipass/skills/lib/drone_commands/apps/handlers/__init__.py similarity index 86% rename from src/aipass/skills/catalog/drone_commands/apps/handlers/__init__.py rename to src/aipass/skills/lib/drone_commands/apps/handlers/__init__.py index a38b5733..b9161bf0 100644 --- a/src/aipass/skills/catalog/drone_commands/apps/handlers/__init__.py +++ b/src/aipass/skills/lib/drone_commands/apps/handlers/__init__.py @@ -3,7 +3,7 @@ # Name: __init__.py - drone_commands handlers package # Date: 2026-03-07 # Version: 1.0.0 -# Category: skills/catalog/drone_commands/apps/handlers +# Category: skills/lib/drone_commands/apps/handlers # # CHANGELOG (Max 5 entries): # - v1.0.0 (2026-03-07): Initial implementation diff --git a/src/aipass/skills/catalog/drone_commands/apps/handlers/executor.py b/src/aipass/skills/lib/drone_commands/apps/handlers/executor.py similarity index 98% rename from src/aipass/skills/catalog/drone_commands/apps/handlers/executor.py rename to src/aipass/skills/lib/drone_commands/apps/handlers/executor.py index a25910ff..d2b12738 100644 --- a/src/aipass/skills/catalog/drone_commands/apps/handlers/executor.py +++ b/src/aipass/skills/lib/drone_commands/apps/handlers/executor.py @@ -3,7 +3,7 @@ # Name: executor.py - Runs drone commands via subprocess # Date: 2026-03-07 # Version: 1.0.0 -# Category: skills/catalog/drone_commands/apps/handlers +# Category: skills/lib/drone_commands/apps/handlers # # CHANGELOG (Max 5 entries): # - v1.0.0 (2026-03-07): Initial implementation diff --git a/src/aipass/skills/catalog/drone_commands/apps/handlers/parser.py b/src/aipass/skills/lib/drone_commands/apps/handlers/parser.py similarity index 98% rename from src/aipass/skills/catalog/drone_commands/apps/handlers/parser.py rename to src/aipass/skills/lib/drone_commands/apps/handlers/parser.py index 74737a98..b79748aa 100644 --- a/src/aipass/skills/catalog/drone_commands/apps/handlers/parser.py +++ b/src/aipass/skills/lib/drone_commands/apps/handlers/parser.py @@ -3,7 +3,7 @@ # Name: parser.py - Parses drone command output # Date: 2026-03-07 # Version: 1.0.0 -# Category: skills/catalog/drone_commands/apps/handlers +# Category: skills/lib/drone_commands/apps/handlers # # CHANGELOG (Max 5 entries): # - v1.0.0 (2026-03-07): Initial implementation diff --git a/src/aipass/skills/catalog/drone_commands/apps/modules/__init__.py b/src/aipass/skills/lib/drone_commands/apps/modules/__init__.py similarity index 86% rename from src/aipass/skills/catalog/drone_commands/apps/modules/__init__.py rename to src/aipass/skills/lib/drone_commands/apps/modules/__init__.py index 310625a8..cb856a82 100644 --- a/src/aipass/skills/catalog/drone_commands/apps/modules/__init__.py +++ b/src/aipass/skills/lib/drone_commands/apps/modules/__init__.py @@ -3,7 +3,7 @@ # Name: __init__.py - drone_commands modules package # Date: 2026-03-07 # Version: 1.0.0 -# Category: skills/catalog/drone_commands/apps/modules +# Category: skills/lib/drone_commands/apps/modules # # CHANGELOG (Max 5 entries): # - v1.0.0 (2026-03-07): Initial implementation diff --git a/src/aipass/skills/catalog/drone_commands/apps/modules/command_runner.py b/src/aipass/skills/lib/drone_commands/apps/modules/command_runner.py similarity index 98% rename from src/aipass/skills/catalog/drone_commands/apps/modules/command_runner.py rename to src/aipass/skills/lib/drone_commands/apps/modules/command_runner.py index ba34f1dd..a52ab77e 100644 --- a/src/aipass/skills/catalog/drone_commands/apps/modules/command_runner.py +++ b/src/aipass/skills/lib/drone_commands/apps/modules/command_runner.py @@ -3,7 +3,7 @@ # Name: command_runner.py - Orchestrates drone command execution # Date: 2026-03-07 # Version: 1.0.0 -# Category: skills/catalog/drone_commands/apps/modules +# Category: skills/lib/drone_commands/apps/modules # # CHANGELOG (Max 5 entries): # - v1.0.0 (2026-03-07): Initial implementation diff --git a/src/aipass/skills/catalog/drone_commands/handler.py b/src/aipass/skills/lib/drone_commands/handler.py similarity index 98% rename from src/aipass/skills/catalog/drone_commands/handler.py rename to src/aipass/skills/lib/drone_commands/handler.py index 88f679e4..dd303535 100644 --- a/src/aipass/skills/catalog/drone_commands/handler.py +++ b/src/aipass/skills/lib/drone_commands/handler.py @@ -3,7 +3,7 @@ # Name: handler.py - Drone Commands skill handler # Date: 2026-03-07 # Version: 1.0.0 -# Category: skills/catalog/drone_commands +# Category: skills/lib/drone_commands # # CHANGELOG (Max 5 entries): # - v1.0.0 (2026-03-07): Initial implementation diff --git a/src/aipass/skills/catalog/github/SKILL.md b/src/aipass/skills/lib/github/SKILL.md similarity index 100% rename from src/aipass/skills/catalog/github/SKILL.md rename to src/aipass/skills/lib/github/SKILL.md diff --git a/src/aipass/skills/catalog/inbox_check/SKILL.md b/src/aipass/skills/lib/inbox_check/SKILL.md similarity index 100% rename from src/aipass/skills/catalog/inbox_check/SKILL.md rename to src/aipass/skills/lib/inbox_check/SKILL.md diff --git a/src/aipass/skills/catalog/inbox_check/handler.py b/src/aipass/skills/lib/inbox_check/handler.py similarity index 98% rename from src/aipass/skills/catalog/inbox_check/handler.py rename to src/aipass/skills/lib/inbox_check/handler.py index c5d23ff3..ed2a3935 100644 --- a/src/aipass/skills/catalog/inbox_check/handler.py +++ b/src/aipass/skills/lib/inbox_check/handler.py @@ -3,7 +3,7 @@ # Name: handler.py - Inbox Check skill handler # Date: 2026-03-29 # Version: 1.0.0 -# Category: skills/catalog/inbox_check +# Category: skills/lib/inbox_check # ============================================= """ @@ -57,7 +57,7 @@ def get_actions(): def _src_root(): """Return the src/ directory by navigating up from this handler.""" - # handler.py -> inbox_check/ -> catalog/ -> skills/ -> aipass/ -> src/ + # handler.py -> inbox_check/ -> lib/ -> skills/ -> aipass/ -> src/ return Path(__file__).resolve().parents[4] diff --git a/src/aipass/skills/catalog/system_status/SKILL.md b/src/aipass/skills/lib/system_status/SKILL.md similarity index 100% rename from src/aipass/skills/catalog/system_status/SKILL.md rename to src/aipass/skills/lib/system_status/SKILL.md diff --git a/src/aipass/skills/catalog/system_status/handler.py b/src/aipass/skills/lib/system_status/handler.py similarity index 99% rename from src/aipass/skills/catalog/system_status/handler.py rename to src/aipass/skills/lib/system_status/handler.py index 072ab15e..e2e5cc54 100644 --- a/src/aipass/skills/catalog/system_status/handler.py +++ b/src/aipass/skills/lib/system_status/handler.py @@ -3,7 +3,7 @@ # Name: handler.py - System Status skill handler # Date: 2026-03-07 # Version: 1.0.0 -# Category: skills/catalog/system_status +# Category: skills/lib/system_status # ============================================= """ diff --git a/src/aipass/skills/.aipass/skills/telegram/SKILL.md b/src/aipass/skills/lib/telegram/SKILL.md similarity index 58% rename from src/aipass/skills/.aipass/skills/telegram/SKILL.md rename to src/aipass/skills/lib/telegram/SKILL.md index 96c625e9..4f17aa50 100644 --- a/src/aipass/skills/.aipass/skills/telegram/SKILL.md +++ b/src/aipass/skills/lib/telegram/SKILL.md @@ -4,7 +4,7 @@ description: Multi-bot Telegram bridge — routes messages between Telegram and version: 1.0.0 tags: [communication, bridge, telegram, bot] requires: - pip: [] + pip: [telethon] bins: [tmux, claude] config: [] aipass: [api, prax, hooks, cli] @@ -46,3 +46,22 @@ drone @skills run telegram notify "message" Bot tokens and config accessed via the in-process `aipass.api.apps.modules.secrets.get_secret` API. State files (offset, lock, registry) stay with the skill in `.local/`. + +## Ported-but-unwired (DPLAN-0220) + +This bridge is a partial port of the ~9k-line "Dev-Pass" telegram system. Several +functions are **ported but not yet wired** — they have no caller today and will be +connected as DPLAN-0220 completes. They are *not* dead code (do not delete them; see +S249), so seedgo's `unused_function` check is bypassed for them in +`.seedgo/bypass.json`. As each one is wired up, remove its bypass entry. + +| File | Function(s) | Awaiting | +|---|---|---| +| `base_bot.py` | `chunk_text`, `on_response` | long-message split + response hook (on_response = Wave-2 design call) | +| `branch_plugin.py` | `on_response` | per-branch response hook (Wave-2 design call) | +| `response_router.py` | `find_pending_bot`, `clean_expired_pending` | response_router import-vs-delete decision | +| `bot_registry.py` | `get_bot_by_work_dir` | CWD→bot match for the response router | +| `bot_operations.py` | `get_all_bots` | multi-bot listing | +| `config.py` | `get_allowed_user_ids`, `validate_config` | config accessor/validator wiring | +| `file_handler.py` | `download_telegram_file`, `cleanup_file` | file up/download feature | +| `tmux_manager.py` | `_send_rename`, `has_tmux`, `kill_session`, `list_sessions`, `get_session_pane` | interactive tmux session management | diff --git a/src/aipass/skills/lib/telegram/__init__.py b/src/aipass/skills/lib/telegram/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/aipass/skills/.aipass/skills/telegram/apps/__init__.py b/src/aipass/skills/lib/telegram/apps/__init__.py similarity index 82% rename from src/aipass/skills/.aipass/skills/telegram/apps/__init__.py rename to src/aipass/skills/lib/telegram/apps/__init__.py index 54987023..259abe5d 100644 --- a/src/aipass/skills/.aipass/skills/telegram/apps/__init__.py +++ b/src/aipass/skills/lib/telegram/apps/__init__.py @@ -3,5 +3,5 @@ # Name: __init__.py - telegram apps package # Date: 2026-03-07 # Version: 1.0.0 -# Category: skills/catalog/telegram/apps +# Category: skills/lib/telegram/apps # ============================================= diff --git a/src/aipass/skills/.aipass/skills/telegram/apps/handlers/__init__.py b/src/aipass/skills/lib/telegram/apps/handlers/__init__.py similarity index 100% rename from src/aipass/skills/.aipass/skills/telegram/apps/handlers/__init__.py rename to src/aipass/skills/lib/telegram/apps/handlers/__init__.py diff --git a/src/aipass/skills/.aipass/skills/telegram/apps/handlers/base_bot.py b/src/aipass/skills/lib/telegram/apps/handlers/base_bot.py similarity index 71% rename from src/aipass/skills/.aipass/skills/telegram/apps/handlers/base_bot.py rename to src/aipass/skills/lib/telegram/apps/handlers/base_bot.py index cf475aec..30a07a5b 100644 --- a/src/aipass/skills/.aipass/skills/telegram/apps/handlers/base_bot.py +++ b/src/aipass/skills/lib/telegram/apps/handlers/base_bot.py @@ -84,9 +84,14 @@ ) from .bot_factory import ( create_bot, + launch_mirror_session, # noqa: F401 + set_bot_commands, + start_service, # noqa: F401 validate_branch, validate_token, ) +from .telegram_standards import build_botfather_commands + from .bot_registry import ( list_bots as registry_list_bots, get_bot_by_branch, @@ -127,6 +132,7 @@ def check_telethon_setup() -> tuple[bool, str]: # type: ignore[misc] SEND_KEYS_DELAY = 0.5 HEARTBEAT_INTERVAL = 30 # seconds CLAUDE_BIN = str(Path.home() / ".local" / "bin" / "claude") +MIRROR_SESSION_TYPE = "interactive-mirror" TEMP_DIR = Path("/tmp/telegram_uploads") MAX_FILE_SIZE = 10 * 1024 * 1024 # 10MB @@ -155,6 +161,7 @@ def __init__( custom_commands: Optional[dict] = None, branch_name: Optional[str] = None, shared_session: Optional[str] = None, + attach_only: bool = False, ) -> None: """ Initialize BaseBot. @@ -190,11 +197,16 @@ def __init__( # Shared-session mode: inject into an existing tmux session instead of creating own self._shared_session_name = shared_session self._using_shared_session = False + self._attach_only = attach_only + self._mirror_mapping_written = False + self._last_transcript_path: str | None = None + self._config_chat_id: int | None = None self.state = { "running": True, "message_count": 0, "start_time": time.time(), + "conversation_start": time.time(), "last_message_time": 0.0, } @@ -218,6 +230,9 @@ def __init__( self._log_streamer: Optional[LogStreamer] = None self._active_chat_id: Optional[int] = None + # Monitor streamer (system-wide, persisted across restarts) + self._monitor_streamer: Optional[LogStreamer] = None + # Lock file self._lock_file = Path.home() / ".aipass" / "telegram_bots" / f".{bot_id}.lock" @@ -247,12 +262,17 @@ def run(self) -> int: self._health["started_at"] = datetime.now().isoformat() json_handler.log_operation("bot_started", {"bot_id": self.bot_id}) - # Check for existing lock - if self._check_lock(): - logger.error("Another instance of bot-%s is already running", self.bot_id) - return 1 + # Set Telegram command menu (idempotent, runs once per startup) + self._set_command_menu() - # Create lock file + # Boot-start monitor if a subscription was persisted + self._boot_monitor() + + # Check for existing lock (prevents duplicate pollers for the same bot_id). + # Return 0 (not 1) so systemd Restart=on-failure does not restart-loop. + if self._check_lock(): + logger.error("Another instance of bot-%s is already running — exiting cleanly", self.bot_id) + return 0 self._create_lock() # Signal handlers @@ -478,6 +498,7 @@ def process_update(self, update: dict) -> None: # Start log streamer on first valid message (if branch has a name) if self._active_chat_id is None and chat_id: self._active_chat_id = chat_id + self._write_mirror_mapping() if self.branch_name is not None and self._log_streamer is None: self._log_streamer = LogStreamer(self.bot_token, chat_id, self.branch_name) self._log_streamer.start() @@ -541,6 +562,11 @@ def _dispatch_command(self, chat_id: int, parsed: tuple) -> bool: """ cmd_name, cmd_args = parsed + # /monitor command — system-wide log subscription + if cmd_name == "monitor": + self._handle_monitor_command(chat_id, cmd_args) + return True + # /create command — multi-step bot creation if cmd_name == "create": self._handle_create_command(chat_id, cmd_args) @@ -555,11 +581,16 @@ def _dispatch_command(self, chat_id: int, parsed: tuple) -> bool: self.send_message(chat_id, "Nothing to cancel.") return True - # Compute uptime for /status - elapsed = time.time() - self.state["start_time"] - hours, remainder = divmod(int(elapsed), 3600) - minutes, seconds = divmod(remainder, 60) - uptime_str = f"{hours}h {minutes}m {seconds}s" + # Compute conversation uptime (resets on /new) and daemon uptime (since boot) + conv_elapsed = time.time() - self.state.get("conversation_start", self.state["start_time"]) + conv_h, conv_rem = divmod(int(conv_elapsed), 3600) + conv_m, conv_s = divmod(conv_rem, 60) + uptime_str = f"{conv_h}h {conv_m}m {conv_s}s" + + daemon_elapsed = time.time() - self.state["start_time"] + d_h, d_rem = divmod(int(daemon_elapsed), 3600) + d_m, d_s = divmod(d_rem, 60) + daemon_uptime_str = f"{d_h}h {d_m}m {d_s}s" # Merge custom commands from constructor and hook merged_commands = {**self.custom_commands, **self.get_custom_commands()} @@ -572,6 +603,7 @@ def _dispatch_command(self, chat_id: int, parsed: tuple) -> bool: uptime=uptime_str, message_count=self.state.get("message_count"), chat_id=chat_id, + daemon_uptime=daemon_uptime_str, ) registry_text = self._build_registry_status() if registry_text: @@ -596,8 +628,10 @@ def _dispatch_command(self, chat_id: int, parsed: tuple) -> bool: action, response_text = result if action == "new": self._kill_tmux_session() + self.state["message_count"] = 0 + self.state["conversation_start"] = time.time() self.send_message(chat_id, response_text) - logger.info("Handled /new command - session killed") + logger.info("Handled /new command - session killed, counters reset") else: self.send_message(chat_id, result) logger.info("Handled /%s command", cmd_name) @@ -634,8 +668,13 @@ def handle_message(self, chat_id: int, text: str, message: dict) -> None: # Ensure tmux session if not self.ensure_tmux_session(): - logger.error("Cannot process message - tmux session unavailable") - self.send_message(chat_id, "Failed to start Claude session. Check logs.") + logger.error("Cannot process message - no live session to mirror") + branch = self.branch_name or self.work_dir.name + self.send_message( + chat_id, + f"⚠️ No live Claude session found for branch '{branch}'.\n" + "Start a Claude session in the branch directory first.", + ) return # Send processing indicator @@ -725,8 +764,13 @@ def handle_file(self, chat_id: int, message: dict) -> None: # Ensure tmux session if not self.ensure_tmux_session(): - logger.error("Cannot process file - tmux session unavailable") - self.send_message(chat_id, "Failed to start Claude session. Check logs.") + logger.error("Cannot process file - no live session to mirror") + branch = self.branch_name or self.work_dir.name + self.send_message( + chat_id, + f"⚠️ No live Claude session found for branch '{branch}'.\n" + "Start a Claude session in the branch directory first.", + ) if file_type == "text": file_path.unlink(missing_ok=True) return @@ -885,8 +929,10 @@ def _handle_create_command(self, chat_id: int, args: str) -> None: self.send_message( chat_id, f"Branch @{branch_name} found at {branch_path}.\n\n" - "Now paste the BotFather token for the new bot.\n" - "(Get one from @BotFather -> /newbot)\n\n" + f"⚠️ BotFather automation unavailable: {telethon_reason}\n" + "Falling back to manual token flow.\n\n" + "Paste the BotFather token for the new bot.\n" + "(Get one from @BotFather → /newbot)\n\n" "/cancel to abort.", ) logger.info( @@ -1207,16 +1253,38 @@ def ensure_tmux_session(self) -> bool: """ Ensure a tmux session is available for message injection. - In shared-session mode: attaches to an existing tmux session (e.g., - the user's running Claude Code session). Falls back to own session if - the shared session is not found. + Resolution order: + 1. Central presence pointer (.ai_central/PRESENCE.central.json) + 2. Explicit shared_session from config + 3. Already-running own tmux session (transitional) - In normal mode: creates telegram-{bot_id} session with Claude Code. + The bot NEVER spawns its own Claude brain — it is a thin relay that + follows the live session via the presence pointer (FPLAN-0289 P2). Returns: True if session is ready """ - # Shared-session mode: attach to existing session if available + # Strategy 1: follow the central presence pointer + presence = self._read_presence_pointer() + if presence: + tmux_target = self._find_tmux_for_presence(presence) + if tmux_target: + self.session_name = tmux_target + self._using_shared_session = True + logger.info( + "Presence pointer: attaching to '%s' (PID %d, type=%s)", + tmux_target, + presence.get("pid", 0), + presence.get("session_type", "unknown"), + ) + self._write_mirror_mapping() + return True + logger.warning( + "Presence pointer found (PID %d) but no matching tmux session", + presence.get("pid", 0), + ) + + # Strategy 2: explicit shared-session from config if self._shared_session_name: try: result = subprocess.run( @@ -1230,94 +1298,29 @@ def ensure_tmux_session(self) -> bool: "Shared session '%s' found — injecting into existing session", self._shared_session_name, ) + self._write_mirror_mapping() return True else: - self._using_shared_session = False - self.session_name = f"telegram-{self.bot_id}" logger.warning( - "Shared session '%s' not found — falling back to own session", + "Shared session '%s' not found", self._shared_session_name, ) except FileNotFoundError: logger.warning("tmux not found while checking shared session '%s'", self._shared_session_name) - self._using_shared_session = False - self.session_name = f"telegram-{self.bot_id}" + # Strategy 3: already-running own tmux session (transitional — pre-existing sessions only) if self._tmux_session_exists(): return True - # Validate work_dir exists — tmux silently falls back to HOME on bad paths - if not self.work_dir.is_dir(): - logger.error("work_dir does not exist: %s — refusing to create tmux session", self.work_dir) - return False - - logger.info("Creating tmux session '%s' at %s", self.session_name, self.work_dir) - - try: - # env -u CLAUDECODE prevents "cannot run inside another Claude" error - env = os.environ.copy() - env.pop("CLAUDECODE", None) - - subprocess.run( - [ - "tmux", - "new-session", - "-d", - "-s", - self.session_name, - "-c", - str(self.work_dir), - ], - check=True, - capture_output=True, - env=env, - ) - - # Set AIPASS_BOT_ID environment variable in the tmux session - subprocess.run( - [ - "tmux", - "send-keys", - "-t", - self.session_name, - f"export AIPASS_BOT_ID={self.bot_id}", - "Enter", - ], - capture_output=True, - ) - time.sleep(0.3) - - # Launch Claude with session type so drone/hooks can identify this as a telegram session - claude_cmd = f"AIPASS_SESSION_TYPE=telegram {CLAUDE_BIN} --permission-mode bypassPermissions" - subprocess.run( - [ - "tmux", - "send-keys", - "-t", - self.session_name, - claude_cmd, - "Enter", - ], - capture_output=True, - ) - - logger.info("tmux session created, waiting 5s for Claude to initialize...") - time.sleep(5) - - # Hook: post-creation - self.on_session_create(self.session_name, self.work_dir) - - return True - - except subprocess.CalledProcessError as e: - logger.error( - "Failed to create tmux session: %s", - e.stderr.decode() if e.stderr else str(e), - ) - return False - except FileNotFoundError: - logger.error("tmux not found - is it installed?") - return False + # PRESENCE GUARD: bot never spawns its own Claude brain (FPLAN-0289 P2). + # Legacy AIPASS_SESSION_TYPE=telegram own-session spawn RETIRED. + # The bot is a thin relay — it follows the presence pointer or an + # explicit shared_session. Start a Claude session in the branch first. + logger.error( + "No live session to mirror — presence pointer empty, no shared session. " + "Start a Claude session in the branch directory first." + ) + return False def inject_message(self, text: str) -> bool: """ @@ -1452,31 +1455,264 @@ def clean_stale_pending(self) -> None: except OSError as e: logger.warning("Failed to clean stale pending file: %s", e) - def _get_transcript_line_count(self) -> int: - """ - Count lines in the Claude JSONL transcript for Layer 3 position tracking. + def _resolve_active_transcript(self) -> tuple[str | None, int]: + """Identify the ACTIVE Claude JSONL transcript and return its path and line count. - Returns: - Line count of the JSONL transcript, or 0 if unavailable + Uses the tmux session's pane PID to walk the process tree and find which + JSONL file the Claude process has open. Falls back to most-recently-modified + JSONL that was touched in the last 5 minutes. Returns (None, 0) when no + active transcript can be identified — safe default that resets the cursor. """ - slug = str(self.work_dir).replace("/", "-") - # Look for transcript files matching the session pattern + slug = str(self.work_dir).replace("\\", "-").replace("/", "-") projects_dir = Path.home() / ".claude" / "projects" / slug if not projects_dir.exists(): - return 0 + return None, 0 - # Find the most recent JSONL transcript - jsonl_files = sorted(projects_dir.glob("*.jsonl"), key=lambda p: p.stat().st_mtime, reverse=True) + jsonl_files = list(projects_dir.glob("*.jsonl")) if not jsonl_files: - return 0 + return None, 0 + + # Strategy 1: find the JSONL open by a child of the tmux pane + pane_pid = self._get_tmux_pane_pid() + if pane_pid: + target_names = {f.name for f in jsonl_files} + found = self._find_open_jsonl(pane_pid, projects_dir, target_names) + if found: + return str(found), self._count_file_lines(found) + + # Strategy 2: most recently modified JSONL, but only if touched < 5 min ago + now = time.time() + recent = sorted( + ((f, f.stat().st_mtime) for f in jsonl_files), + key=lambda x: x[1], + reverse=True, + ) + if recent and (now - recent[0][1]) < 300: + return str(recent[0][0]), self._count_file_lines(recent[0][0]) + + return None, 0 + + def _find_open_jsonl(self, pane_pid: int, projects_dir: Path, target_names: set[str]) -> Path | None: + """Scan /proc fd links for descendant PIDs to find an open JSONL in *projects_dir*.""" + child_pids = self._get_descendant_pids(pane_pid) + for pid in child_pids: + match = self._scan_pid_fds(pid, projects_dir, target_names) + if match: + return match + return None + + @staticmethod + def _scan_pid_fds(pid: int, projects_dir: Path, target_names: set[str]) -> Path | None: + """Check /proc//fd/ for an open JSONL matching *target_names*.""" + fd_dir = Path(f"/proc/{pid}/fd") + try: + entries = list(fd_dir.iterdir()) + except OSError as e: + logger.info("Cannot list fds for pid %d: %s", pid, e) + return None + for fd in entries: + try: + target = fd.resolve() + except OSError as e: + logger.info("Cannot resolve fd %s: %s", fd.name, e) + continue + if target.parent == projects_dir and target.name in target_names: + return target + return None + + def _get_tmux_pane_pid(self) -> int | None: + """Get the shell PID of the first pane in the current tmux session.""" + try: + result = subprocess.run( + ["tmux", "list-panes", "-t", self.session_name, "-F", "#{pane_pid}"], + capture_output=True, + text=True, + timeout=5, + ) + if result.returncode == 0 and result.stdout.strip(): + return int(result.stdout.strip().split("\n")[0]) + except (subprocess.TimeoutExpired, OSError, ValueError) as e: + logger.info("Could not get tmux pane PID: %s", e) + return None + + @staticmethod + def _get_descendant_pids(parent_pid: int) -> list[int]: + """Walk /proc to collect all descendant PIDs of *parent_pid*.""" + children: dict[int, list[int]] = {} + try: + for entry in Path("/proc").iterdir(): + if not entry.name.isdigit(): + continue + try: + stat_text = (entry / "stat").read_text(encoding="utf-8") + ppid = int(stat_text.split(") ")[1].split()[1]) + children.setdefault(ppid, []).append(int(entry.name)) + except (OSError, IndexError, ValueError) as e: + logger.info("Skipping /proc/%s/stat: %s", entry.name, e) + continue + except OSError as e: + logger.info("Cannot scan /proc for descendant PIDs: %s", e) + return [] + result: list[int] = [] + queue = children.get(parent_pid, [])[:] + while queue: + pid = queue.pop() + result.append(pid) + queue.extend(children.get(pid, [])) + return result + @staticmethod + def _count_file_lines(path: Path) -> int: + """Count newline-delimited lines in *path*.""" try: - text = jsonl_files[0].read_text(encoding="utf-8").strip() + text = path.read_text(encoding="utf-8").strip() return len(text.split("\n")) if text else 0 except OSError as e: logger.warning("Could not read transcript for line count: %s", e) return 0 + def _get_transcript_line_count(self) -> int: + """Count lines in the active JSONL transcript (compat shim).""" + _, count = self._resolve_active_transcript() + return count + + # ============================================= + # PRESENCE POINTER + # ============================================= + + def _find_presence_file(self) -> Path | None: + """Locate .ai_central/PRESENCE.central.json by walking up from work_dir.""" + current = self.work_dir.resolve() + for _ in range(20): + candidate = current / ".ai_central" / "PRESENCE.central.json" + if candidate.exists(): + return candidate + if current.parent == current: + break + current = current.parent + return None + + def _read_presence_pointer(self) -> dict | None: + """Read the central presence pointer for this bot's branch. + + Returns the presence entry dict if a live session is registered, + None if absent/empty/stale/dead. + """ + presence_file = self._find_presence_file() + if presence_file is None: + return None + try: + data = json.loads(presence_file.read_text(encoding="utf-8")) + except (json.JSONDecodeError, OSError) as exc: + logger.info("Failed to read presence file %s: %s", presence_file, exc) + return None + branch = self.branch_name or self.work_dir.name + entry = data.get(branch) + if not entry: + return None + pid = entry.get("pid") + if pid is None: + return None + try: + os.kill(pid, 0) + except ProcessLookupError: + logger.info("Presence holder PID %d is dead — stale pointer", pid) + return None + except PermissionError: + logger.info("PID %d exists but permission denied — treating as alive", pid) + except OSError as exc: + logger.info("PID %d liveness check failed: %s — treating as dead", pid, exc) + return None + return entry + + def _find_tmux_for_presence(self, entry: dict) -> str | None: + """Find the tmux session for a presence entry. + + Prefers attach_handle when populated; falls back to scanning tmux + sessions for a pane whose CWD matches the entry's work_dir. + """ + handle = entry.get("attach_handle", "") + if handle: + try: + result = subprocess.run( + ["tmux", "has-session", "-t", handle], + capture_output=True, + timeout=5, + ) + if result.returncode == 0: + return handle + except (FileNotFoundError, subprocess.TimeoutExpired, OSError) as exc: + logger.info("attach_handle '%s' tmux check failed: %s", handle, exc) + + work_dir = entry.get("work_dir", "") + if not work_dir: + return None + try: + result = subprocess.run( + ["tmux", "list-panes", "-a", "-F", "#{session_name}:#{pane_current_path}"], + capture_output=True, + text=True, + timeout=5, + ) + if result.returncode != 0: + return None + target = str(Path(work_dir).resolve()) + for line in result.stdout.strip().split("\n"): + if ":" not in line: + continue + session_name, pane_path = line.split(":", 1) + if str(Path(pane_path).resolve()) == target: + return session_name + except (subprocess.TimeoutExpired, FileNotFoundError, OSError) as exc: + logger.info("tmux pane scan failed: %s", exc) + return None + + def _write_mirror_mapping(self) -> None: + """Write persistent mirror mapping file per THE CONTRACT (TDPLAN-0009). + + Written at first successful attach AND rewritten whenever the active + transcript changes (session restart). @hooks reads this every turn + to find the chat_id and cursor for mirror delivery. + """ + if not self._attach_only: + return + + chat_id = self._active_chat_id or getattr(self, "_config_chat_id", None) + if chat_id is None: + return + + transcript_path, line_count = self._resolve_active_transcript() + + if self._mirror_mapping_written: + if transcript_path == self._last_transcript_path: + return + logger.info( + "Transcript changed (%s → %s) — rewriting mirror mapping", + self._last_transcript_path, + transcript_path, + ) + + mapping_dir = Path.home() / ".aipass" / "telegram_bots" + mapping_dir.mkdir(parents=True, exist_ok=True) + mapping_file = mapping_dir / f"bot-{self.bot_id}.json" + + mapping = { + "chat_id": chat_id, + "bot_token": self.bot_token, + "session_name": self.session_name, + "work_dir": str(self.work_dir), + "mirror": True, + "transcript_line_after": line_count, + } + + try: + mapping_file.write_text(json.dumps(mapping, indent=2), encoding="utf-8") + self._mirror_mapping_written = True + self._last_transcript_path = transcript_path + logger.info("Mirror mapping written: %s (cursor=%d)", mapping_file, line_count) + except OSError as e: + logger.error("Failed to write mirror mapping: %s", e) + # ============================================= # HEARTBEAT THREAD # ============================================= @@ -1500,8 +1736,8 @@ def _heartbeat_loop(): if self._heartbeat_stop.is_set(): break - # Only update if pending file still exists and tmux alive - if not self.pending_file.exists(): + # Stop if response has been delivered or tmux died + if self._is_pending_delivered(): break if not self._tmux_session_exists(): break @@ -1520,6 +1756,17 @@ def _stop_heartbeat(self) -> None: self._heartbeat_thread.join(timeout=5) self._heartbeat_thread = None + def _is_pending_delivered(self) -> bool: + """Check if the pending file has been marked as delivered by @hooks.""" + if not self.pending_file.exists(): + return True + try: + data = json.loads(self.pending_file.read_text(encoding="utf-8")) + return bool(data.get("delivered")) + except (json.JSONDecodeError, OSError) as e: + logger.warning("Failed to read pending file %s: %s", self.pending_file, e) + return False + @staticmethod def _format_elapsed(seconds: float) -> str: """ @@ -1569,18 +1816,160 @@ def on_response(self, text: str) -> str: """ return text - def on_session_create(self, session_name: str, work_dir: Path) -> None: - """ - Hook: called after a new tmux session is created. + def _set_command_menu(self) -> None: + """Set the Telegram command menu via setMyCommands on startup.""" + merged_commands = {**self.custom_commands, **self.get_custom_commands()} + commands = build_botfather_commands(custom_commands=merged_commands or None) + if self.bot_token: + ok = set_bot_commands(self.bot_token, commands) + if ok: + logger.info("Command menu set (%d commands)", len(commands)) + else: + logger.warning("Failed to set command menu") - Override in subclasses to perform post-creation setup - (e.g., injecting "hi" to trigger startup protocol). + # ============================================= + # /MONITOR — SYSTEM-WIDE LOG SUBSCRIPTION + # ============================================= - Args: - session_name: The tmux session name that was created - work_dir: The working directory of the session - """ - return + def _handle_monitor_command(self, chat_id: int, args: str) -> None: + """Route /monitor subcommands: on, all, off, status.""" + subcmd = args.strip().lower().split()[0] if args.strip() else "" + + if subcmd == "on": + self._monitor_subscribe(chat_id, mode="default") + elif subcmd == "all": + self._monitor_subscribe(chat_id, mode="all") + elif subcmd == "off": + self._monitor_unsubscribe(chat_id) + elif subcmd == "status": + self._monitor_status(chat_id) + else: + self.send_message( + chat_id, + "/monitor on \u2014 errors & warnings\n" + "/monitor all \u2014 everything (firehose)\n" + "/monitor off \u2014 unsubscribe\n" + "/monitor status \u2014 current state", + ) + + def _monitor_subscribe(self, chat_id: int, mode: str) -> None: + """Subscribe this chat to the system-wide log monitor.""" + # Stop any existing monitor streamer + if self._monitor_streamer is not None: + self._monitor_streamer.stop() + self._monitor_streamer = None + + # Persist subscription + if not self._save_monitor_subscription(chat_id, mode): + self.send_message(chat_id, "Failed to save monitor subscription.") + return + + # Start streamer + self._monitor_streamer = LogStreamer( + self.bot_token, + chat_id, + branch_name="monitor", + system_wide=True, + level_filter=mode, + ) + self._monitor_streamer.start() + + mode_label = "errors & warnings" if mode == "default" else "all levels (firehose)" + self.send_message( + chat_id, + f"Monitor subscribed: {mode_label}\n\n/monitor off to unsubscribe\n/monitor all for firehose mode", + ) + logger.info("Monitor subscribed: chat_id=%s, mode=%s", chat_id, mode) + + def _monitor_unsubscribe(self, chat_id: int) -> None: + """Unsubscribe from the system-wide log monitor.""" + if self._monitor_streamer is not None: + self._monitor_streamer.stop() + self._monitor_streamer = None + + self._clear_monitor_subscription() + self.send_message(chat_id, "Monitor unsubscribed. No more log alerts.") + logger.info("Monitor unsubscribed: chat_id=%s", chat_id) + + def _monitor_status(self, chat_id: int) -> None: + """Show current monitor subscription status.""" + sub = self._load_monitor_subscription() + if not sub or not sub.get("chat_id"): + self.send_message(chat_id, "Monitor: not subscribed.\n\n/monitor on to start.") + return + + sub_chat = sub["chat_id"] + mode = sub.get("mode", "default") + mode_label = "errors & warnings" if mode == "default" else "all levels (firehose)" + is_this_chat = "(this chat)" if sub_chat == chat_id else f"(chat {sub_chat})" + running = self._monitor_streamer is not None and self._monitor_streamer._running + state = "streaming" if running else "paused" + + self.send_message( + chat_id, + f"Monitor: {state}\nMode: {mode_label}\nTarget: {is_this_chat}", + ) + + def _boot_monitor(self) -> None: + """Start the monitor streamer from persisted subscription on boot.""" + sub = self._load_monitor_subscription() + if not sub or not sub.get("chat_id"): + return + + chat_id = sub["chat_id"] + mode = sub.get("mode", "default") + + self._monitor_streamer = LogStreamer( + self.bot_token, + chat_id, + branch_name="monitor", + system_wide=True, + level_filter=mode, + ) + self._monitor_streamer.start() + logger.info("Boot-started monitor streamer (chat_id=%s, mode=%s)", chat_id, mode) + + def _monitor_subscription_file(self) -> Path: + """Return path to the local monitor subscription file.""" + return Path.home() / ".aipass" / "telegram_bots" / f".{self.bot_id}_monitor.json" + + def _load_monitor_subscription(self) -> dict | None: + """Load monitor subscription from local state file.""" + sub_file = self._monitor_subscription_file() + if not sub_file.exists(): + return None + try: + data = json.loads(sub_file.read_text(encoding="utf-8")) + if isinstance(data, dict) and data.get("chat_id"): + return data + return None + except (json.JSONDecodeError, OSError) as e: + logger.warning("Failed to load monitor subscription: %s", e) + return None + + def _save_monitor_subscription(self, chat_id: int, mode: str) -> bool: + """Persist monitor subscription to local state file.""" + sub_file = self._monitor_subscription_file() + try: + sub_file.parent.mkdir(parents=True, exist_ok=True) + sub_file.write_text( + json.dumps({"chat_id": chat_id, "mode": mode}, indent=2), + encoding="utf-8", + ) + return True + except OSError as e: + logger.error("Failed to save monitor subscription: %s", e) + return False + + def _clear_monitor_subscription(self) -> bool: + """Clear persisted monitor subscription.""" + sub_file = self._monitor_subscription_file() + try: + sub_file.unlink(missing_ok=True) + return True + except OSError as e: + logger.error("Failed to clear monitor subscription: %s", e) + return False def get_custom_commands(self) -> dict: """ @@ -1593,13 +1982,17 @@ def get_custom_commands(self) -> dict: Dict of commands in telegram_standards format """ return { + "monitor": { + "description": "Subscribe to system-wide log alerts — /monitor on, off, all, status", + "menu_text": "Log monitor", + }, "create": { - "description": "Create a new branch bot: /create chat ", - "menu_text": "Create branch bot", + "description": "Create a Telegram bot for a branch — e.g. /create chat devpulse", + "menu_text": "New branch bot", }, "cancel": { - "description": "Cancel active /create flow", - "menu_text": "Cancel", + "description": "Cancel an in-progress /create", + "menu_text": "Cancel create", }, } @@ -1696,6 +2089,9 @@ def _shutdown_handler(self, signum, _frame) -> None: def _cleanup(self) -> None: """Clean up resources on exit.""" + if self._monitor_streamer is not None: + self._monitor_streamer.stop() + self._monitor_streamer = None if self._log_streamer is not None: self._log_streamer.stop() self._log_streamer = None @@ -1733,6 +2129,11 @@ def _save_offset(self, offset: int) -> None: # CLI ENTRY POINT # ============================================= +_BOT_CLASSES = { + "scheduler": ".scheduler_bot:SchedulerBot", +} + + if __name__ == "__main__": parser = argparse.ArgumentParser(description="AIPass Telegram Bot") parser.add_argument("--bot-id", required=True, help="Bot identifier") @@ -1745,7 +2146,15 @@ def _save_offset(self, offset: int) -> None: print(f"No config found for bot_id={args.bot_id}") sys.exit(1) - bot = BaseBot( + bot_cls = BaseBot + if args.bot_id in _BOT_CLASSES: + mod_name, cls_name = _BOT_CLASSES[args.bot_id].rsplit(":", 1) + import importlib + + mod = importlib.import_module(mod_name, package=__package__) + bot_cls = getattr(mod, cls_name) + + bot = bot_cls( bot_id=args.bot_id, bot_token=config["bot_token"], work_dir=Path(config.get("work_dir", str(Path.home()))), @@ -1753,5 +2162,10 @@ def _save_offset(self, offset: int) -> None: allowed_user_ids=config.get("allowed_user_ids", []), branch_name=config.get("branch_name"), shared_session=config.get("shared_session"), + attach_only=config.get("attach_only", False), ) + + if config.get("chat_id"): + bot._config_chat_id = config["chat_id"] + sys.exit(bot.run()) diff --git a/src/aipass/skills/.aipass/skills/telegram/apps/handlers/bot_factory.py b/src/aipass/skills/lib/telegram/apps/handlers/bot_factory.py similarity index 71% rename from src/aipass/skills/.aipass/skills/telegram/apps/handlers/bot_factory.py rename to src/aipass/skills/lib/telegram/apps/handlers/bot_factory.py index 4001b14a..cddfaa98 100644 --- a/src/aipass/skills/.aipass/skills/telegram/apps/handlers/bot_factory.py +++ b/src/aipass/skills/lib/telegram/apps/handlers/bot_factory.py @@ -3,7 +3,7 @@ # Name: bot_factory.py - Bot creation and deletion factory # Date: 2026-06-15 # Version: 1.0.0 -# Category: skills/catalog/telegram/apps/handlers +# Category: skills/lib/telegram/apps/handlers # # CHANGELOG (Max 5 entries): # - v1.0.0 (2026-06-15): Ported from Dev-Pass — rewired registry, logger, config, base_bot path @@ -31,7 +31,9 @@ # Standard library import json +import shutil import subprocess +import sys from datetime import datetime, timezone from pathlib import Path from typing import Optional @@ -45,6 +47,10 @@ from aipass.skills.apps.handlers.json import json_handler # noqa: F401 # Internal imports +from aipass.api.apps.modules.secrets import set_secret as _api_set_secret + +from .telegram_standards import build_botfather_commands + from .bot_registry import ( deregister_bot, ensure_registry, @@ -60,14 +66,9 @@ TELEGRAM_API = "https://api.telegram.org/bot{token}" SYSTEMD_DIR = Path.home() / ".config" / "systemd" / "user" _BOT_CONFIG_DIR = Path.home() / ".aipass" / "telegram_bots" +CLAUDE_BIN = str(Path.home() / ".local" / "bin" / "claude") -# Default commands set on every new bot via BotFather -DEFAULT_BOT_COMMANDS = [ - {"command": "start", "description": "Start the bot"}, - {"command": "help", "description": "Show available commands"}, - {"command": "status", "description": "Show session status"}, - {"command": "new", "description": "Start a fresh session"}, -] +# Command menu built from telegram_standards (single source of truth) # ============================================= # TELEGRAM API HELPERS @@ -200,11 +201,34 @@ def set_bot_commands(bot_token: str, commands: list[dict]) -> bool: # ============================================= +def _install_service_unit() -> bool: + """Copy telegram-bot@.service into ~/.config/systemd/user/ and reload.""" + UNIT_SRC = Path(__file__).resolve().parents[2] / "telegram-bot@.service" + UNIT_DST_DIR = Path.home() / ".config" / "systemd" / "user" + UNIT_DST = UNIT_DST_DIR / "telegram-bot@.service" + + try: + UNIT_DST_DIR.mkdir(parents=True, exist_ok=True) + shutil.copy2(UNIT_SRC, UNIT_DST) + subprocess.run( + ["systemctl", "--user", "daemon-reload"], + capture_output=True, + text=True, + timeout=10, + ) + logger.info("Installed service unit: %s", UNIT_DST) + return True + except OSError as e: + logger.warning("Failed to install service unit: %s", e) + return False + + def enable_service(bot_id: str) -> bool: """ Enable the systemd user service for a bot (does not start it). - Runs: systemctl --user enable telegram-bot@{bot_id} + Installs the unit file if missing, then runs: + systemctl --user enable telegram-bot@{bot_id} Args: bot_id: Bot identifier used in the service template. @@ -212,6 +236,7 @@ def enable_service(bot_id: str) -> bool: Returns: True if the service was enabled successfully, False otherwise. """ + _install_service_unit() SERVICE_NAME = f"telegram-bot@{bot_id}" try: result = subprocess.run( @@ -283,12 +308,11 @@ def start_bot_process(bot_id: str) -> bool: Returns: True if the process was launched successfully, False otherwise. """ - BASE_BOT_PATH = Path(__file__).parent / "base_bot.py" - PYTHON = str(Path.home() / ".venv" / "bin" / "python3") + MODULE_PATH = "aipass.skills.lib.telegram.apps.handlers.base_bot" try: proc = subprocess.Popen( - [PYTHON, str(BASE_BOT_PATH), "--bot-id", bot_id], + [sys.executable, "-m", MODULE_PATH, "--bot-id", bot_id], start_new_session=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, @@ -301,6 +325,84 @@ def start_bot_process(bot_id: str) -> bool: return False +def launch_mirror_session( + bot_id: str, + work_dir: str, + session_name: str, +) -> bool: + """Launch the canonical tmux mirror session with --dangerously-skip-permissions. + + Creates a detached tmux session running Claude Code in autonomous mirror mode. + The session uses --dangerously-skip-permissions because a detached tmux session + has no operator to approve permission prompts — without it, the session hangs. + + Args: + bot_id: Bot identifier (set as AIPASS_BOT_ID env var in the session). + work_dir: Working directory for the Claude session. + session_name: tmux session name (e.g. "telegram-api"). + + Returns: + True if the session was launched successfully. + """ + import os + + try: + result = subprocess.run( + ["tmux", "has-session", "-t", session_name], + capture_output=True, + ) + if result.returncode == 0: + logger.info("Mirror session '%s' already running — skipping launch", session_name) + return True + except FileNotFoundError: + logger.error("tmux not found — cannot launch mirror session") + return False + + env = os.environ.copy() + env.pop("CLAUDECODE", None) + + try: + subprocess.run( + ["tmux", "new-session", "-d", "-s", session_name, "-c", work_dir], + check=True, + capture_output=True, + env=env, + ) + except subprocess.CalledProcessError as e: + logger.error("Failed to create tmux session '%s': %s", session_name, e) + return False + + subprocess.run( + [ + "tmux", + "send-keys", + "-t", + session_name, + f"export AIPASS_BOT_ID={bot_id}", + "Enter", + ], + capture_output=True, + ) + + import time + + time.sleep(0.3) + + claude_cmd = f"AIPASS_SESSION_TYPE=interactive-mirror {CLAUDE_BIN} --dangerously-skip-permissions" + subprocess.run( + ["tmux", "send-keys", "-t", session_name, claude_cmd, "Enter"], + capture_output=True, + ) + + logger.info( + "Mirror session '%s' launched (bot_id=%s, work_dir=%s, skip-perms=true)", + session_name, + bot_id, + work_dir, + ) + return True + + def stop_service(bot_id: str) -> bool: """ Stop the systemd user service for a bot. @@ -336,6 +438,41 @@ def stop_service(bot_id: str) -> bool: return False +def start_service(bot_id: str) -> bool: + """ + Start the systemd user service for a bot. + + Runs: systemctl --user start telegram-bot@{bot_id} + + Args: + bot_id: Bot identifier used in the service template. + + Returns: + True if the service was started successfully, False otherwise. + """ + SERVICE_NAME = f"telegram-bot@{bot_id}" + try: + result = subprocess.run( + ["systemctl", "--user", "start", SERVICE_NAME], + capture_output=True, + text=True, + timeout=10, + ) + if result.returncode == 0: + logger.info("Started systemd service: %s", SERVICE_NAME) + return True + + logger.warning("Failed to start service %s: %s", SERVICE_NAME, result.stderr.strip()) + return False + + except subprocess.TimeoutExpired: + logger.warning("Timeout starting service: %s", SERVICE_NAME) + return False + except OSError as e: + logger.warning("Error starting service %s: %s", SERVICE_NAME, e) + return False + + # ============================================= # BOT LIFECYCLE # ============================================= @@ -348,6 +485,9 @@ def create_bot( work_dir: Optional[str] = None, bot_name: Optional[str] = None, allowed_user_ids: Optional[list[int]] = None, + shared_session: Optional[str] = None, + attach_only: bool = False, + chat_id: Optional[int] = None, ) -> Optional[dict]: """ Create a new bot: validate, write config, register, setup systemd. @@ -360,7 +500,8 @@ def create_bot( 5. Register in bot registry 6. Set BotFather commands via setMyCommands API 7. Enable systemd service - 8. Auto-start the bot process + 7.5. Mirror mode: launch canonical tmux session with skip-perms + 8. Auto-start the bot (systemd for mirror, Popen for standard) Args: bot_id: Unique identifier for this bot (e.g., "dev_central", "base"). @@ -369,6 +510,9 @@ def create_bot( work_dir: Working directory for Claude sessions. Defaults to home dir if None. bot_name: Human-readable bot name. Auto-generated if None. allowed_user_ids: List of Telegram user IDs allowed to use this bot. + shared_session: tmux session name for mirror mode (attach to existing session). + attach_only: When True, bot only attaches — never spawns its own session. + chat_id: Pre-configured Telegram chat ID for mirror mapping. Returns: Bot info dict on success, None on any failure. @@ -416,11 +560,7 @@ def create_bot( ) return None - # Step 4: Write per-bot config to local shadow file. - # This is intentional: create_bot writes here first, then the config is - # imported into @api (drone @api set-secret) as a separate step. At runtime, - # load_bot_config reads exclusively from @api — the local file is the - # create-then-import staging artifact, not a runtime config source. + # Step 4: Build config and persist to @api secrets store + local shadow. ensure_registry() _BOT_CONFIG_DIR.mkdir(parents=True, exist_ok=True) @@ -435,17 +575,28 @@ def create_bot( "work_dir": RESOLVED_WORK_DIR, "allowed_user_ids": allowed_user_ids or [], "created_at": datetime.now(timezone.utc).isoformat(), + "shared_session": shared_session, + "attach_only": attach_only, + "chat_id": chat_id, } + # Step 4a: Write to @api secrets store (load_bot_config reads from here) + try: + _api_set_secret("telegram", bot_id, config_data, as_json=True) + logger.info("Persisted bot config to @api secrets: telegram/%s", bot_id) + except OSError as e: + logger.error("create_bot failed: could not persist config to @api for '%s': %s", bot_id, e) + return None + + # Step 4b: Write local shadow file (staging artifact, not runtime source) try: CONFIG_PATH.write_text( json.dumps(config_data, indent=2), encoding="utf-8", ) - logger.info("Wrote bot config: %s", CONFIG_PATH) + logger.info("Wrote bot config shadow: %s", CONFIG_PATH) except OSError as e: - logger.warning("Failed to write bot config: %s", e) - return None + logger.warning("Failed to write shadow config (non-fatal): %s", e) # Step 5: Register in bot registry registered = register_bot( @@ -454,6 +605,7 @@ def create_bot( branch_name=branch_name, work_dir=RESOLVED_WORK_DIR, config_path=str(CONFIG_PATH), + bot_token_ref=f"@api:telegram/{bot_id}", ) if not registered: CONFIG_PATH.unlink(missing_ok=True) @@ -461,21 +613,35 @@ def create_bot( return None # Step 6: Set BotFather commands - set_bot_commands(bot_token, DEFAULT_BOT_COMMANDS) + set_bot_commands(bot_token, build_botfather_commands()) # Step 7: Enable systemd service enable_service(bot_id) - # Step 8: Auto-start the bot process - started = start_bot_process(bot_id) + # Step 7.5: Mirror mode — launch canonical tmux session + mirror_launched = False + if shared_session and attach_only: + mirror_launched = launch_mirror_session(session_name=shared_session, bot_id=bot_id, work_dir=RESOLVED_WORK_DIR) + if not mirror_launched: + logger.warning( + "Mirror session launch failed for '%s' — bot may need manual session start", + bot_id, + ) + + # Step 8: Auto-start the bot poller + if shared_session and attach_only: + started = start_service(bot_id) + else: + started = start_bot_process(bot_id) logger.info( - "Bot created: %s (@%s, branch=%s, work_dir=%s, started=%s)", + "Bot created: %s (@%s, branch=%s, work_dir=%s, started=%s, mirror=%s)", bot_id, BOT_USERNAME, branch_name, RESOLVED_WORK_DIR, started, + mirror_launched, ) return { @@ -487,6 +653,9 @@ def create_bot( "config_path": str(CONFIG_PATH), "service_name": f"telegram-bot@{bot_id}", "auto_started": started, + "shared_session": shared_session, + "attach_only": attach_only, + "mirror_launched": mirror_launched, } diff --git a/src/aipass/skills/.aipass/skills/telegram/apps/handlers/bot_operations.py b/src/aipass/skills/lib/telegram/apps/handlers/bot_operations.py similarity index 87% rename from src/aipass/skills/.aipass/skills/telegram/apps/handlers/bot_operations.py rename to src/aipass/skills/lib/telegram/apps/handlers/bot_operations.py index e35c544a..8edbf349 100644 --- a/src/aipass/skills/.aipass/skills/telegram/apps/handlers/bot_operations.py +++ b/src/aipass/skills/lib/telegram/apps/handlers/bot_operations.py @@ -1,17 +1,9 @@ # =================== AIPass ==================== -# Name: bot_operations.py - Bot operation handlers for multi-bot module -# Date: 2026-02-24 -# Version: 1.0.0 -# Category: api/handlers/telegram -# -# CHANGELOG (Max 5 entries): -# - v1.0.0 (2026-02-24): Initial - start, stop, status, list operations for multi-bot system -# -# CODE STANDARDS: -# - Pure functions with proper error handling (graceful - never raise) -# - No Prax imports (handler tier 3) -# - Stdlib only (subprocess for systemd) -# - Returns values for caller to log/display - no handler-level logging +# Name: bot_operations.py +# Description: Bot lifecycle operation handlers — start, stop, status, list +# Version: 1.0.1 +# Created: 2026-02-24 +# Modified: 2026-06-29 # ============================================= """ @@ -32,6 +24,12 @@ import subprocess from pathlib import Path +# Logging +from aipass.prax import logger + +# JSON handler (seedgo standard) +from aipass.skills.apps.handlers.json import json_handler # noqa: F401 + # Internal handler imports from .base_bot import BaseBot from .branch_plugin import BranchPlugin @@ -57,6 +55,7 @@ def start_bot(bot_id: str) -> int | None: Returns: Bot exit code, or None if config loading failed. """ + json_handler.log_operation("start_bot", {"bot_id": bot_id}) config = load_bot_config(bot_id) if not config: return None @@ -69,6 +68,9 @@ def start_bot(bot_id: str) -> int | None: bot_name = config.get("bot_name", f"AIPass {bot_id} Bot") allowed_user_ids = config.get("allowed_user_ids", []) branch_name = config.get("branch_name") + shared_session = config.get("shared_session") + attach_only = config.get("attach_only", False) + chat_id = config.get("chat_id") if branch_name: bot = BranchPlugin( @@ -78,6 +80,8 @@ def start_bot(bot_id: str) -> int | None: work_dir=work_dir, bot_name=bot_name, allowed_user_ids=allowed_user_ids, + shared_session=shared_session, + attach_only=attach_only, ) else: bot = BaseBot( @@ -86,8 +90,13 @@ def start_bot(bot_id: str) -> int | None: work_dir=work_dir, bot_name=bot_name, allowed_user_ids=allowed_user_ids, + shared_session=shared_session, + attach_only=attach_only, ) + if chat_id is not None: + bot._config_chat_id = chat_id + return bot.run() @@ -117,8 +126,10 @@ def stop_bot(bot_id: str) -> tuple[bool, str]: return False, f"Failed to stop {service_name}: {result.stderr.strip()}" except subprocess.TimeoutExpired: + logger.warning("Timeout stopping %s", service_name) return False, f"Timeout stopping {service_name}" except OSError as e: + logger.warning("Error stopping %s: %s", service_name, e) return False, f"Error stopping {service_name}: {e}" diff --git a/src/aipass/skills/.aipass/skills/telegram/apps/handlers/bot_registry.py b/src/aipass/skills/lib/telegram/apps/handlers/bot_registry.py similarity index 83% rename from src/aipass/skills/.aipass/skills/telegram/apps/handlers/bot_registry.py rename to src/aipass/skills/lib/telegram/apps/handlers/bot_registry.py index 21cd4590..992cb16c 100644 --- a/src/aipass/skills/.aipass/skills/telegram/apps/handlers/bot_registry.py +++ b/src/aipass/skills/lib/telegram/apps/handlers/bot_registry.py @@ -1,5 +1,19 @@ +# =================== AIPass ==================== +# Name: bot_registry.py +# Description: Telegram bot registry — JSON store with advisory file locking +# Version: 1.0.0 +# Created: 2026-06-24 +# Modified: 2026-06-25 +# ============================================= + +"""Telegram bot registry. + +Persists registered bots to a JSON file with advisory file locking so concurrent +read-modify-write operations don't corrupt the store. Provides CRUD plus lookup +helpers (by id, branch, work_dir). +""" + # Standard library -import fcntl import json from datetime import datetime, timezone from pathlib import Path @@ -8,6 +22,30 @@ # Logging from aipass.prax import logger +# fcntl is POSIX-only (Linux/macOS); unavailable on Windows. Guard the import and +# skip advisory locking when absent. Matches the codebase convention (hooks/cadence, +# daemon) — see seedgo windows_compat standard. +try: + import fcntl +except ImportError: + fcntl = None # type: ignore[assignment] + logger.info("[TELEGRAM] bot_registry: fcntl unavailable (Windows) — skipping advisory locks") + + +def _lock(f, *, exclusive: bool) -> None: + """Acquire an advisory lock on an open file; no-op without fcntl (Windows).""" + if fcntl is None: + return + fcntl.flock(f.fileno(), fcntl.LOCK_EX if exclusive else fcntl.LOCK_SH) + + +def _unlock(f) -> None: + """Release an advisory lock; no-op when fcntl is unavailable (Windows).""" + if fcntl is None: + return + fcntl.flock(f.fileno(), fcntl.LOCK_UN) + + # ============================================= # CONSTANTS # ============================================= @@ -53,11 +91,11 @@ def ensure_registry() -> None: if not REGISTRY_FILE.exists(): data = _empty_registry() with open(REGISTRY_FILE, "w", encoding="utf-8") as f: - fcntl.flock(f.fileno(), fcntl.LOCK_EX) + _lock(f, exclusive=True) try: json.dump(data, f, indent=2) finally: - fcntl.flock(f.fileno(), fcntl.LOCK_UN) + _unlock(f) logger.info("Created new bot registry at %s", REGISTRY_FILE) except OSError as e: logger.warning("Failed to ensure registry: %s", e) @@ -80,11 +118,11 @@ def load_registry() -> dict: try: with open(REGISTRY_FILE, "r", encoding="utf-8") as f: - fcntl.flock(f.fileno(), fcntl.LOCK_SH) + _lock(f, exclusive=False) try: data = json.load(f) finally: - fcntl.flock(f.fileno(), fcntl.LOCK_UN) + _unlock(f) if not isinstance(data, dict) or "bots" not in data: logger.warning("Registry file has unexpected structure, returning empty") @@ -116,11 +154,11 @@ def save_registry(data: dict) -> bool: data["metadata"]["last_updated"] = _now_iso() with open(REGISTRY_FILE, "w", encoding="utf-8") as f: - fcntl.flock(f.fileno(), fcntl.LOCK_EX) + _lock(f, exclusive=True) try: json.dump(data, f, indent=2) finally: - fcntl.flock(f.fileno(), fcntl.LOCK_UN) + _unlock(f) return True @@ -325,7 +363,8 @@ def get_bot_by_work_dir(work_dir) -> Optional[dict]: try: if str(Path(bot_dir).resolve()) == target: return bot - except (ValueError, OSError): + except (ValueError, OSError) as e: + logger.warning("Skipping unresolvable bot work_dir %r: %s", bot_dir, e) continue return None diff --git a/src/aipass/skills/.aipass/skills/telegram/apps/handlers/botfather_client.py b/src/aipass/skills/lib/telegram/apps/handlers/botfather_client.py similarity index 100% rename from src/aipass/skills/.aipass/skills/telegram/apps/handlers/botfather_client.py rename to src/aipass/skills/lib/telegram/apps/handlers/botfather_client.py diff --git a/src/aipass/skills/.aipass/skills/telegram/apps/handlers/branch_plugin.py b/src/aipass/skills/lib/telegram/apps/handlers/branch_plugin.py similarity index 77% rename from src/aipass/skills/.aipass/skills/telegram/apps/handlers/branch_plugin.py rename to src/aipass/skills/lib/telegram/apps/handlers/branch_plugin.py index de84bdfe..40e65901 100644 --- a/src/aipass/skills/.aipass/skills/telegram/apps/handlers/branch_plugin.py +++ b/src/aipass/skills/lib/telegram/apps/handlers/branch_plugin.py @@ -1,14 +1,20 @@ +# =================== AIPass ==================== +# Name: branch_plugin.py +# Description: Per-branch Telegram bot — hook overrides for message prefixing and response tagging +# Version: 2.0.0 +# Created: 2026-02-24 +# Modified: 2026-06-29 +# ============================================= + # Standard library import argparse import sys -import time from pathlib import Path # Sibling import from .base_bot import BaseBot -# Logging -from aipass.prax import logger +from aipass.skills.apps.handlers.json import json_handler # noqa: F401 # ============================================= @@ -20,8 +26,8 @@ class BranchPlugin(BaseBot): """ Per-branch Telegram bot that extends BaseBot with branch-specific behavior. - Overrides BaseBot hooks to prefix messages, tag responses, and trigger - the AIPass startup protocol when a new tmux session is created. + Overrides BaseBot hooks to prefix messages with sender attribution + and tag responses with the branch name. """ def __init__(self, branch_name: str, **kwargs) -> None: @@ -34,6 +40,7 @@ def __init__(self, branch_name: str, **kwargs) -> None: """ self.branch_name = branch_name super().__init__(**kwargs) + json_handler.log_operation("branch_plugin_init", {"branch_name": branch_name}) # ============================================= # HOOK OVERRIDES @@ -64,24 +71,6 @@ def on_response(self, text: str) -> str: """ return f"@{self.branch_name}\n{text}" - def on_session_create(self, session_name: str, work_dir: Path) -> None: - """ - Inject "hi" after tmux session creation to trigger startup protocol. - - Waits 2 seconds for Claude to fully initialize, then injects "hi" - which triggers the AIPass startup sequence (reading memories, etc.). - - Args: - session_name: The tmux session name that was created - work_dir: The working directory of the session - """ - logger.info( - "Branch session created for @%s, injecting startup greeting", - self.branch_name, - ) - time.sleep(2) - self.inject_message("hi") - # ============================================= # CLI ENTRY POINT diff --git a/src/aipass/skills/.aipass/skills/telegram/apps/handlers/config.py b/src/aipass/skills/lib/telegram/apps/handlers/config.py similarity index 93% rename from src/aipass/skills/.aipass/skills/telegram/apps/handlers/config.py rename to src/aipass/skills/lib/telegram/apps/handlers/config.py index af21e76d..22bd6135 100644 --- a/src/aipass/skills/.aipass/skills/telegram/apps/handlers/config.py +++ b/src/aipass/skills/lib/telegram/apps/handlers/config.py @@ -1,5 +1,5 @@ # Standard library -from pathlib import Path +from pathlib import PurePosixPath, PureWindowsPath from typing import Optional, List # Logging @@ -208,8 +208,11 @@ def validate_bot_config(config: object) -> tuple[bool, str]: return False, "bot_token must be a string in format 'id:hash'" if "work_dir" in config and config["work_dir"] is not None: - work_dir = Path(config["work_dir"]) - if not work_dir.is_absolute(): + # work_dir is a deployment-target (Linux) path. Test absoluteness under + # POSIX *and* Windows semantics so validation is platform-independent — + # a bare host Path().is_absolute() would reject "/home/..." on Windows. + work_dir = str(config["work_dir"]) + if not (PurePosixPath(work_dir).is_absolute() or PureWindowsPath(work_dir).is_absolute()): return False, "work_dir must be an absolute path" if "allowed_user_ids" in config: diff --git a/src/aipass/skills/.aipass/skills/telegram/apps/handlers/file_handler.py b/src/aipass/skills/lib/telegram/apps/handlers/file_handler.py similarity index 100% rename from src/aipass/skills/.aipass/skills/telegram/apps/handlers/file_handler.py rename to src/aipass/skills/lib/telegram/apps/handlers/file_handler.py diff --git a/src/aipass/skills/.aipass/skills/telegram/apps/handlers/log_streamer.py b/src/aipass/skills/lib/telegram/apps/handlers/log_streamer.py similarity index 85% rename from src/aipass/skills/.aipass/skills/telegram/apps/handlers/log_streamer.py rename to src/aipass/skills/lib/telegram/apps/handlers/log_streamer.py index 08dbd87f..c5fd5779 100644 --- a/src/aipass/skills/.aipass/skills/telegram/apps/handlers/log_streamer.py +++ b/src/aipass/skills/lib/telegram/apps/handlers/log_streamer.py @@ -37,23 +37,55 @@ # CONSTANTS # ============================================= -SYSTEM_LOGS_DIR = Path.home() / "system_logs" BATCH_INTERVAL = 5.0 TELEGRAM_MAX_LENGTH = 4000 +def _get_system_logs_dir(): + """Resolve system_logs dir, honoring AIPASS_TEST_LOG_DIR.""" + import os + + test_dir = os.environ.get("AIPASS_TEST_LOG_DIR") + if test_dir: + p = Path(test_dir) / "system" + p.mkdir(parents=True, exist_ok=True) + return p + here = Path(__file__).resolve() + for parent in here.parents: + if (parent / "pyproject.toml").exists(): + d = parent / "system_logs" + d.mkdir(parents=True, exist_ok=True) + return d + return Path.home() / "system_logs" + + +SYSTEM_LOGS_DIR = _get_system_logs_dir() + + # ============================================= # LOG STREAMER # ============================================= +_LEVEL_MARKERS = ("| WARNING |", "| ERROR |", "| CRITICAL |") + + class LogStreamer: """Stream system log lines for a branch to Telegram via batched sends.""" - def __init__(self, bot_token: str, chat_id: int, branch_name: str) -> None: + def __init__( + self, + bot_token: str, + chat_id: int, + branch_name: str, + system_wide: bool = False, + level_filter: str = "all", + ) -> None: self.bot_token = bot_token self.chat_id = chat_id self.branch_name = branch_name + self._system_wide = system_wide + self._level_filter = level_filter self._running = False self._stop_event = threading.Event() @@ -68,11 +100,19 @@ def __init__(self, bot_token: str, chat_id: int, branch_name: str) -> None: # ----------------------------------------- def _get_log_files(self) -> List[Path]: - """Find all log files matching this branch's pattern.""" + """Find log files: all *.log when system_wide, else branch-specific.""" if not SYSTEM_LOGS_DIR.exists(): return [] + if self._system_wide: + return sorted(SYSTEM_LOGS_DIR.glob("*.log")) return sorted(SYSTEM_LOGS_DIR.glob(f"{self.branch_name}_*.log")) + def _filter_lines(self, lines: List[str]) -> List[str]: + """Apply level filter: default keeps WARNING/ERROR/CRITICAL, all passes everything.""" + if self._level_filter == "all": + return lines + return [ln for ln in lines if any(m in ln for m in _LEVEL_MARKERS)] + def _init_positions(self) -> None: """Set initial positions to end of file so we only tail new lines.""" for log_file in self._get_log_files(): @@ -201,6 +241,7 @@ def _run(self) -> None: while self._running: try: new_lines = self._read_new_lines() + new_lines = self._filter_lines(new_lines) if new_lines: logger.info("Found %d new log lines, sending to Telegram", len(new_lines)) self._send_batched(new_lines) diff --git a/src/aipass/skills/.aipass/skills/telegram/apps/handlers/notifier.py b/src/aipass/skills/lib/telegram/apps/handlers/notifier.py similarity index 100% rename from src/aipass/skills/.aipass/skills/telegram/apps/handlers/notifier.py rename to src/aipass/skills/lib/telegram/apps/handlers/notifier.py diff --git a/src/aipass/skills/.aipass/skills/telegram/apps/handlers/response_router.py b/src/aipass/skills/lib/telegram/apps/handlers/response_router.py similarity index 100% rename from src/aipass/skills/.aipass/skills/telegram/apps/handlers/response_router.py rename to src/aipass/skills/lib/telegram/apps/handlers/response_router.py diff --git a/src/aipass/skills/lib/telegram/apps/handlers/scheduler_bot.py b/src/aipass/skills/lib/telegram/apps/handlers/scheduler_bot.py new file mode 100644 index 00000000..2bad4a45 --- /dev/null +++ b/src/aipass/skills/lib/telegram/apps/handlers/scheduler_bot.py @@ -0,0 +1,204 @@ +# =================== AIPass ==================== +# Name: scheduler_bot.py +# Description: Dedicated Telegram bot for the daemon's job queue — announce/query only +# Version: 1.0.0 +# Created: 2026-06-25 +# Modified: 2026-06-25 +# ============================================= + +""" +SchedulerBot — a BaseBot subclass for the AIPass Scheduler. + +Pure announce/query bot: serves /queue, posts an hourly digest, receives +lifecycle pings from @daemon. Free-text messages do NOT spin up tmux/Claude. +""" + +import json +import subprocess +import threading +from typing import Optional + +from aipass.prax import logger +from aipass.skills.apps.handlers.json import json_handler + +from .base_bot import BaseBot + + +DIGEST_INTERVAL = 3600.0 +QUEUE_CMD = ["drone", "@daemon", "queue", "--json"] + + +class SchedulerBot(BaseBot): + """Dedicated scheduler bot — no tmux/Claude, just /queue and hourly digest.""" + + def __init__(self, **kwargs) -> None: + super().__init__(**kwargs) + self._digest_thread: Optional[threading.Thread] = None + self._digest_stop = threading.Event() + self._scheduler_chat_id: Optional[int] = None + + def handle_message(self, chat_id: int, text: str, message: dict) -> None: + """Reject free-text — this bot only serves commands (no tmux/Claude spawn).""" + self.send_message( + chat_id, + "I'm the scheduler bot — I only handle commands.\nTry /queue or /help", + ) + + def handle_file(self, chat_id: int, message: dict) -> None: + """Reject files — this bot only serves commands.""" + self.send_message(chat_id, "I don't process files. Try /queue or /help") + + def _dispatch_command(self, chat_id: int, parsed: tuple) -> bool: + cmd_name, cmd_args = parsed + if cmd_name == "queue": + self._handle_queue_command(chat_id) + return True + return super()._dispatch_command(chat_id, parsed) + + def get_custom_commands(self) -> dict: + """Add /queue to the slash-menu alongside the inherited commands.""" + cmds = super().get_custom_commands() + cmds["queue"] = { + "description": "Show all scheduled daemon jobs — next fire, status, type", + "menu_text": "Job queue", + } + return cmds + + def _handle_queue_command(self, chat_id: int) -> None: + """Fetch queue from daemon and send formatted message.""" + json_handler.log_operation("queue_requested", {"chat_id": chat_id}) + queue_data = self._fetch_queue() + if queue_data is None: + self.send_message(chat_id, "Failed to fetch queue from daemon.") + return + + text = self._format_queue(queue_data) + for chunk in self.chunk_text(text): + self.send_message(chat_id, chunk) + + def _fetch_queue(self) -> Optional[dict]: + """Run drone @daemon queue --json and parse the result.""" + try: + result = subprocess.run( + QUEUE_CMD, + capture_output=True, + text=True, + timeout=15, + ) + if result.returncode != 0: + logger.warning("queue --json failed (rc=%d): %s", result.returncode, result.stderr) + return None + decoder = json.JSONDecoder(strict=False) + return decoder.decode(result.stdout) + except (subprocess.TimeoutExpired, json.JSONDecodeError, OSError) as e: + logger.warning("Failed to fetch queue: %s", e) + return None + + @staticmethod + def _format_queue(data: dict) -> str: + """Format queue JSON into a readable Telegram message.""" + jobs = data.get("jobs", []) + count = data.get("count", len(jobs)) + generated = data.get("generated_at", "unknown") + + if not jobs: + return f"No scheduled jobs.\n\nGenerated: {generated}" + + lines = [f"Scheduled Jobs ({count})\n"] + for job in jobs: + owner = job.get("owner", "?") + job_id = job.get("id", "?") + enabled = job.get("enabled", False) + jtype = job.get("type", "?") + schedule = job.get("schedule_human", "?") + next_run = job.get("next_run") or "—" + last_status = job.get("last_status") or "never run" + last_error = job.get("last_error") + prompt = job.get("prompt_preview", "") + + status_icon = { + "success": "✅", + "failed": "❌", + "dispatched": "\U0001f535", + }.get(last_status, "⚪") + enabled_tag = "" if enabled else " [disabled]" + + lines.append(f"{status_icon} {owner}/{job_id}{enabled_tag}") + lines.append(f" Type: {jtype} ({schedule})") + lines.append(f" Next: {next_run}") + lines.append(f" Last: {last_status}") + if last_error: + lines.append(f" Error: {last_error}") + if prompt: + lines.append(f" Prompt: {prompt[:80]}...") + lines.append("") + + lines.append(f"Generated: {generated}") + return "\n".join(lines) + + # ============================================= + # HOURLY DIGEST + # ============================================= + + def start_digest(self, chat_id: int) -> None: + """Start the hourly digest thread.""" + if self._digest_thread is not None and self._digest_thread.is_alive(): + return + self._scheduler_chat_id = chat_id + self._digest_stop.clear() + self._digest_thread = threading.Thread( + target=self._digest_loop, + name="scheduler-digest", + daemon=True, + ) + self._digest_thread.start() + logger.info("Digest thread started (chat_id=%s)", chat_id) + + def stop_digest(self) -> None: + """Stop the hourly digest thread.""" + self._digest_stop.set() + if self._digest_thread is not None: + self._digest_thread.join(timeout=5) + self._digest_thread = None + logger.info("Digest thread stopped") + + def _digest_loop(self) -> None: + """Post a queue digest every hour.""" + while not self._digest_stop.is_set(): + self._digest_stop.wait(DIGEST_INTERVAL) + if self._digest_stop.is_set(): + break + self._post_digest() + + def _post_digest(self) -> None: + """Fetch queue and post digest to the scheduler chat.""" + if self._scheduler_chat_id is None: + return + queue_data = self._fetch_queue() + if queue_data is None: + logger.warning("Digest skipped: failed to fetch queue") + return + + text = f"Hourly Queue Digest\n{'=' * 20}\n\n{self._format_queue(queue_data)}" + for chunk in self.chunk_text(text): + self.send_message(self._scheduler_chat_id, chunk) + logger.info("Hourly digest posted") + + # ============================================= + # LIFECYCLE + # ============================================= + + def run(self) -> int: + """Run the bot: start the hourly digest, then delegate to the parent poll loop. + + BaseBot.run() owns the full lifecycle (verify, command menu, lock, signal + handlers, offset tracking, poll loop, cleanup). We only wrap it to start the + digest thread before the blocking loop and stop it on exit. + """ + chat_id = getattr(self, "_config_chat_id", None) + if chat_id: + self.start_digest(int(chat_id)) + try: + return super().run() + finally: + self.stop_digest() diff --git a/src/aipass/skills/.aipass/skills/telegram/apps/handlers/telegram_standards.py b/src/aipass/skills/lib/telegram/apps/handlers/telegram_standards.py similarity index 83% rename from src/aipass/skills/.aipass/skills/telegram/apps/handlers/telegram_standards.py rename to src/aipass/skills/lib/telegram/apps/handlers/telegram_standards.py index 76727af1..a470f27c 100644 --- a/src/aipass/skills/.aipass/skills/telegram/apps/handlers/telegram_standards.py +++ b/src/aipass/skills/lib/telegram/apps/handlers/telegram_standards.py @@ -1,6 +1,29 @@ +# =================== AIPass ==================== +# Name: telegram_standards.py +# Description: Standard command registry, response builders, and helpers for Telegram bots +# Version: 1.0.0 +# Created: 2026-02-24 +# Modified: 2026-06-29 +# ============================================= + +""" +Telegram Standards — shared command registry and response builders. + +Provides the canonical STANDARD_COMMANDS dict, text builder functions for +/start, /help, /status, and /new responses, the BotFather setMyCommands +payload builder, and the parse_command / handle_standard_command dispatcher +used by BaseBot and all subclasses. + +All functions are pure (no side effects) except _tmux_session_exists which +calls subprocess to check tmux state. +""" + import subprocess from typing import Optional +from aipass.skills.apps.handlers.json import json_handler # noqa: F401 +from aipass.prax import logger + # ============================================= # STANDARD COMMAND REGISTRY @@ -8,19 +31,19 @@ STANDARD_COMMANDS: dict[str, dict[str, str]] = { "start": { - "description": "Welcome message and command list", - "menu_text": "Start / welcome message", + "description": "Welcome — what this bot is and how to use it", + "menu_text": "What this bot does", }, "help": { - "description": "Show available commands", - "menu_text": "Show help", + "description": "Show every command and what it does", + "menu_text": "List commands", }, "new": { - "description": "Kill current session and start fresh (clean Claude context)", + "description": "Start a fresh conversation (clears Claude's current context)", "menu_text": "Fresh session", }, "status": { - "description": "Show session info (branch, uptime, session state)", + "description": "Show the branch, uptime, and whether a session is active", "menu_text": "Session status", }, } @@ -34,7 +57,7 @@ ERROR_TEMPLATE = "Something went wrong: {error}" -HELP_FOOTER = "\nSend any message to chat with Claude." +HELP_FOOTER = "\nJust send any message to talk to me — or use a command above." # Internal templates (used by builder functions) _WELCOME_HEADER = "Hello! I'm {bot_name}." @@ -91,7 +114,7 @@ def build_help_text( standard_commands = STANDARD_COMMANDS parts: list[str] = [ - "Commands:", + "Available commands:", _format_command_list(standard_commands, custom_commands), HELP_FOOTER, ] @@ -123,7 +146,7 @@ def build_welcome_text( _WELCOME_HEADER.format(bot_name=bot_name), _WELCOME_BRANCH.format(branch_name=branch_name), "", - "Commands:", + "Available commands:", _format_command_list(standard_commands, custom_commands), HELP_FOOTER, ] @@ -136,6 +159,7 @@ def build_status_text( uptime: Optional[str] = None, message_count: Optional[int] = None, chat_id: Optional[str | int] = None, + daemon_uptime: Optional[str] = None, ) -> str: """ Build the /status response. @@ -146,9 +170,10 @@ def build_status_text( Args: session_name: tmux session name (e.g., "telegram-assistant"). branch_name: Branch name (e.g., "assistant"). - uptime: Optional human-readable uptime string. - message_count: Optional count of messages processed. + uptime: Optional conversation uptime (resets on /new). + message_count: Optional count of messages in current conversation. chat_id: Optional Telegram chat ID to display. + daemon_uptime: Optional daemon process uptime (since boot). Returns: Formatted status text string. @@ -165,6 +190,8 @@ def build_status_text( lines.append(f"Uptime: {uptime}") if message_count is not None: lines.append(f"Messages: {message_count}") + if daemon_uptime: + lines.append(f"Daemon up: {daemon_uptime}") return "\n".join(lines) @@ -279,6 +306,8 @@ def handle_standard_command( - tuple[str, str]: ("new", response_text) for /new command - None: Command is not a standard command """ + json_handler.log_operation("standard_command", {"command": command, "branch": branch_name}) + if command == "start": return build_welcome_text( bot_name=bot_name, @@ -327,5 +356,5 @@ def _tmux_session_exists(session_name: str) -> bool: ) return result.returncode == 0 except FileNotFoundError: - # tmux not installed + logger.warning("tmux not found while checking session '%s'", session_name) return False diff --git a/src/aipass/skills/.aipass/skills/telegram/apps/handlers/tmux_manager.py b/src/aipass/skills/lib/telegram/apps/handlers/tmux_manager.py similarity index 100% rename from src/aipass/skills/.aipass/skills/telegram/apps/handlers/tmux_manager.py rename to src/aipass/skills/lib/telegram/apps/handlers/tmux_manager.py diff --git a/src/aipass/skills/.aipass/skills/telegram/apps/modules/__init__.py b/src/aipass/skills/lib/telegram/apps/modules/__init__.py similarity index 87% rename from src/aipass/skills/.aipass/skills/telegram/apps/modules/__init__.py rename to src/aipass/skills/lib/telegram/apps/modules/__init__.py index 8c07b893..ab029ba0 100644 --- a/src/aipass/skills/.aipass/skills/telegram/apps/modules/__init__.py +++ b/src/aipass/skills/lib/telegram/apps/modules/__init__.py @@ -3,7 +3,7 @@ # Name: __init__.py - telegram modules package # Date: 2026-03-07 # Version: 1.0.0 -# Category: skills/catalog/telegram/apps/modules +# Category: skills/lib/telegram/apps/modules # # CHANGELOG (Max 5 entries): # - v1.0.0 (2026-03-07): Initial scaffold diff --git a/src/aipass/skills/.aipass/skills/telegram/handler.py b/src/aipass/skills/lib/telegram/handler.py similarity index 100% rename from src/aipass/skills/.aipass/skills/telegram/handler.py rename to src/aipass/skills/lib/telegram/handler.py diff --git a/src/aipass/skills/.aipass/skills/telegram/telegram-bot@.service b/src/aipass/skills/lib/telegram/telegram-bot@.service similarity index 75% rename from src/aipass/skills/.aipass/skills/telegram/telegram-bot@.service rename to src/aipass/skills/lib/telegram/telegram-bot@.service index a81f5213..8fa27af4 100644 --- a/src/aipass/skills/.aipass/skills/telegram/telegram-bot@.service +++ b/src/aipass/skills/lib/telegram/telegram-bot@.service @@ -19,14 +19,14 @@ Wants=network-online.target [Service] Type=simple -ExecStart=%h/.venv/bin/python3 %h/Projects/AIPass/src/aipass/skills/.aipass/skills/telegram/apps/handlers/base_bot.py --bot-id %i +ExecStart=%h/Projects/AIPass/.venv/bin/python3 -m aipass.skills.lib.telegram.apps.handlers.base_bot --bot-id %i WorkingDirectory=%h/Projects/AIPass Environment=AIPASS_BOT_ID=%i Environment=AIPASS_SESSION_TYPE=telegram Restart=on-failure RestartSec=10 -StandardOutput=append:%h/system_logs/telegram-bot-%i.log -StandardError=append:%h/system_logs/telegram-bot-%i.log +StandardOutput=append:%h/Projects/AIPass/system_logs/telegram-bot-%i.log +StandardError=append:%h/Projects/AIPass/system_logs/telegram-bot-%i.log [Install] WantedBy=default.target diff --git a/src/aipass/skills/.aipass/skills/telegram/telethon_auth.py b/src/aipass/skills/lib/telegram/telethon_auth.py similarity index 100% rename from src/aipass/skills/.aipass/skills/telegram/telethon_auth.py rename to src/aipass/skills/lib/telegram/telethon_auth.py diff --git a/src/aipass/skills/lib/telegram/tests/__init__.py b/src/aipass/skills/lib/telegram/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/aipass/skills/.aipass/skills/telegram/tests/conftest.py b/src/aipass/skills/lib/telegram/tests/conftest.py similarity index 62% rename from src/aipass/skills/.aipass/skills/telegram/tests/conftest.py rename to src/aipass/skills/lib/telegram/tests/conftest.py index 7da8e307..66a1022c 100644 --- a/src/aipass/skills/.aipass/skills/telegram/tests/conftest.py +++ b/src/aipass/skills/lib/telegram/tests/conftest.py @@ -1,18 +1,19 @@ -# ===================AIPASS==================== -# META DATA HEADER -# Name: conftest.py - Telegram skill test configuration -# Date: 2026-06-15 +# =================== AIPass ==================== +# Name: conftest.py +# Description: Telegram skill test configuration — path setup and shared fixtures # Version: 1.0.0 -# Category: skills/telegram/tests -# -# CHANGELOG (Max 5 entries): -# - v1.0.0 (2026-06-15): Initial implementation — prax log redirect + path setup -# -# CODE STANDARDS: -# - Adds src/ and skill root to sys.path for test imports +# Created: 2026-06-15 +# Modified: 2026-06-29 # ============================================= -"""Telegram skill test configuration.""" +""" +Telegram skill test configuration. + +Sets up sys.path so that both aipass.* (installed package) and the local +apps.handlers.* namespace are importable from tests without a full pip install. +Also stubs the optional telethon dependency and redirects Prax logger output +to a temp dir so test runs don't pollute production log files. +""" import os import shutil @@ -27,26 +28,24 @@ import pytest -# Add src/ to path so aipass.* is importable -_src_root = Path(__file__).resolve().parents[6] # noqa: E402 +# sys.path setup is intentional test infrastructure — both entries are needed: +# _src_root → resolves aipass.* installed-package imports +# _skill_root → resolves the local apps.handlers.* namespace used by all tests +_src_root = Path(__file__).resolve().parents[5] if str(_src_root) not in sys.path: sys.path.insert(0, str(_src_root)) -# Add telegram skill root so apps.handlers.* is importable -_skill_root = Path(__file__).resolve().parents[1] # noqa: E402 +_skill_root = Path(__file__).resolve().parents[1] if str(_skill_root) not in sys.path: sys.path.insert(0, str(_skill_root)) -# Telethon stub — telethon is an OPTIONAL runtime dependency (MTProto client), -# deliberately NOT in pyproject so the core stays lightweight (botfather_client.py -# guards it with TELETHON_AVAILABLE). The botfather_client tests mock all Telethon -# classes (patch("telethon.TelegramClient"), etc.), but unittest.mock.patch must -# IMPORT the target's parent module to set the attribute — which raises -# ModuleNotFoundError when telethon isn't installed (e.g. in CI). Register a minimal -# stub so those patch targets resolve. The guard never clobbers a real telethon if -# one is installed. Real FloodWaitError/RPCError classes are required for the -# success/timeout tests, where _send_and_wait imports them but does not patch them. +# Telethon stub — telethon is an optional dependency (pyproject [telegram] extra). +# In CI or minimal installs it may not be present. botfather_client.py guards with +# TELETHON_AVAILABLE. The tests mock Telethon classes (patch("telethon.TelegramClient")), +# but unittest.mock.patch must IMPORT the parent module — which raises +# ModuleNotFoundError when telethon isn't installed. Register a minimal stub so those +# patch targets resolve. Never clobbers a real telethon if one is installed. if "telethon" not in sys.modules: _telethon_stub = types.ModuleType("telethon") _telethon_errors = types.ModuleType("telethon.errors") diff --git a/src/aipass/skills/lib/telegram/tests/test_attach_only.py b/src/aipass/skills/lib/telegram/tests/test_attach_only.py new file mode 100644 index 00000000..0afb9a95 --- /dev/null +++ b/src/aipass/skills/lib/telegram/tests/test_attach_only.py @@ -0,0 +1,297 @@ +# =================== AIPass ==================== +# Name: test_attach_only.py +# Description: Tests for attach-only mode (TDPLAN-0009 Stage 1) +# Version: 1.0.0 +# Created: 2026-06-29 +# Modified: 2026-06-29 +# ============================================= + +""" +Tests for attach-only mode (TDPLAN-0009 Stage 1). + +The bot attaches to a pre-existing tmux session and never spawns its own. +When attach_only=True + shared_session is set: + - Attaches to existing named tmux session (no spawn) + - Missing session → loud error, NO spawn of telegram-{bot_id} + - Persistent mapping file written with all CONTRACT fields + seeded cursor + - No lock created for the shared session + - inject_message reaches the session (unchanged, tested via existing tests) +""" + +import json +from unittest.mock import patch, MagicMock + +import pytest + +from apps.handlers.base_bot import BaseBot # type: ignore[import-not-found] + + +@pytest.fixture +def _patch_base_bot_deps(tmp_path): + patches = [ + patch("apps.handlers.base_bot.PENDING_DIR", tmp_path), + patch("apps.handlers.base_bot.signal.signal"), + patch("apps.handlers.base_bot.atexit.register"), + ] + for p in patches: + p.start() + yield + for p in patches: + p.stop() + + +def _make_bot(tmp_path, _patch_base_bot_deps, attach_only=False, shared_session=None): + workdir = tmp_path / "workdir" + workdir.mkdir(exist_ok=True) + with patch("apps.handlers.base_bot.PENDING_DIR", tmp_path): + bot = BaseBot( + bot_id="mirror_test", + bot_token="123:FAKETOKEN", + work_dir=workdir, + bot_name="Mirror Test Bot", + allowed_user_ids=[111], + branch_name="devpulse", + shared_session=shared_session, + attach_only=attach_only, + ) + bot.send_message = MagicMock(return_value={"ok": True, "message_id": 1}) + return bot + + +# ============================================= +# 1. ensure_tmux_session — attach-only behavior +# ============================================= + + +class TestAttachOnly: + """Attach-only mode: attach to existing session, never spawn.""" + + def test_attaches_to_existing_session(self, tmp_path, _patch_base_bot_deps): + """When shared session exists, bot attaches and returns True.""" + bot = _make_bot(tmp_path, _patch_base_bot_deps, attach_only=True, shared_session="devpulse") + + result_obj = MagicMock() + result_obj.returncode = 0 + with patch("subprocess.run", return_value=result_obj): + assert bot.ensure_tmux_session() is True + assert bot.session_name == "devpulse" + assert bot._using_shared_session is True + + def test_missing_session_returns_false(self, tmp_path, _patch_base_bot_deps): + """When shared session is missing, attach-only returns False (no spawn).""" + bot = _make_bot(tmp_path, _patch_base_bot_deps, attach_only=True, shared_session="devpulse") + + result_obj = MagicMock() + result_obj.returncode = 1 + with patch("subprocess.run", return_value=result_obj): + assert bot.ensure_tmux_session() is False + # Session name should NOT fall back to telegram-{bot_id} + assert bot._using_shared_session is False + + def test_missing_session_does_not_spawn(self, tmp_path, _patch_base_bot_deps): + """Attach-only never creates a telegram-{bot_id} session.""" + bot = _make_bot(tmp_path, _patch_base_bot_deps, attach_only=True, shared_session="devpulse") + + result_obj = MagicMock() + result_obj.returncode = 1 + with patch("subprocess.run", return_value=result_obj) as mock_run: + bot.ensure_tmux_session() + # Should only have checked has-session, never new-session + calls = [str(c) for c in mock_run.call_args_list] + for call in calls: + assert "new-session" not in call + + def test_no_shared_session_config_returns_false(self, tmp_path, _patch_base_bot_deps): + """Attach-only without shared_session configured returns False.""" + bot = _make_bot(tmp_path, _patch_base_bot_deps, attach_only=True, shared_session=None) + assert bot.ensure_tmux_session() is False + + def test_non_attach_mode_falls_back_on_missing(self, tmp_path, _patch_base_bot_deps): + """Without attach_only, missing shared session falls back to own session.""" + bot = _make_bot(tmp_path, _patch_base_bot_deps, attach_only=False, shared_session="devpulse") + + has_session = MagicMock(returncode=1) + with patch("subprocess.run", return_value=has_session): + # Falls back to telegram-{bot_id} and tries to spawn — we just check the name + bot.ensure_tmux_session() + assert bot.session_name == "telegram-mirror_test" + + def test_handle_message_shows_error_on_attach_fail(self, tmp_path, _patch_base_bot_deps): + """handle_message shows specific error when no live session found.""" + bot = _make_bot(tmp_path, _patch_base_bot_deps, attach_only=True, shared_session="devpulse") + + result_obj = MagicMock() + result_obj.returncode = 1 + with patch("subprocess.run", return_value=result_obj): + bot.handle_message(42, "hello", {"message_id": 1}) + + msg = bot.send_message.call_args[0][1] + assert "No live Claude session" in msg + assert "devpulse" in msg + + +# ============================================= +# 2. Mirror mapping file (THE CONTRACT) +# ============================================= + + +class TestMirrorMapping: + """Persistent mapping file written at attach with CONTRACT fields.""" + + def test_mapping_written_on_attach(self, tmp_path, _patch_base_bot_deps): + """Mapping file written when attach-only bot attaches to existing session.""" + bot = _make_bot(tmp_path, _patch_base_bot_deps, attach_only=True, shared_session="devpulse") + bot._active_chat_id = 42 + + mapping_dir = tmp_path / ".aipass" / "telegram_bots" + result_obj = MagicMock() + result_obj.returncode = 0 + with ( + patch("subprocess.run", return_value=result_obj), + patch("pathlib.Path.home", return_value=tmp_path), + ): + bot.ensure_tmux_session() + + mapping_file = mapping_dir / "bot-mirror_test.json" + assert mapping_file.exists() + data = json.loads(mapping_file.read_text()) + assert data["chat_id"] == 42 + assert data["bot_token"] == "123:FAKETOKEN" + assert data["session_name"] == "devpulse" + assert data["mirror"] is True + assert "transcript_line_after" in data + assert data["work_dir"] == str(tmp_path / "workdir") + + def test_mapping_uses_config_chat_id(self, tmp_path, _patch_base_bot_deps): + """Mapping uses _config_chat_id when _active_chat_id is not set.""" + bot = _make_bot(tmp_path, _patch_base_bot_deps, attach_only=True, shared_session="devpulse") + bot._config_chat_id = 99 + + mapping_dir = tmp_path / ".aipass" / "telegram_bots" + result_obj = MagicMock() + result_obj.returncode = 0 + with ( + patch("subprocess.run", return_value=result_obj), + patch("pathlib.Path.home", return_value=tmp_path), + ): + bot.ensure_tmux_session() + + mapping_file = mapping_dir / "bot-mirror_test.json" + assert mapping_file.exists() + data = json.loads(mapping_file.read_text()) + assert data["chat_id"] == 99 + + def test_mapping_deferred_when_no_chat_id(self, tmp_path, _patch_base_bot_deps): + """Mapping deferred if no chat_id at attach time.""" + bot = _make_bot(tmp_path, _patch_base_bot_deps, attach_only=True, shared_session="devpulse") + + mapping_dir = tmp_path / ".aipass" / "telegram_bots" + result_obj = MagicMock() + result_obj.returncode = 0 + with ( + patch("subprocess.run", return_value=result_obj), + patch("pathlib.Path.home", return_value=tmp_path), + ): + bot.ensure_tmux_session() + + mapping_file = mapping_dir / "bot-mirror_test.json" + assert not mapping_file.exists() + assert bot._mirror_mapping_written is False + + def test_mapping_written_once(self, tmp_path, _patch_base_bot_deps): + """Mapping file only written once (idempotent).""" + bot = _make_bot(tmp_path, _patch_base_bot_deps, attach_only=True, shared_session="devpulse") + bot._active_chat_id = 42 + + result_obj = MagicMock() + result_obj.returncode = 0 + with ( + patch("subprocess.run", return_value=result_obj), + patch("pathlib.Path.home", return_value=tmp_path), + ): + bot.ensure_tmux_session() + bot.ensure_tmux_session() + + assert bot._mirror_mapping_written is True + + def test_mapping_not_written_for_non_attach(self, tmp_path, _patch_base_bot_deps): + """Non-attach-only bots don't write mirror mapping.""" + bot = _make_bot(tmp_path, _patch_base_bot_deps, attach_only=False, shared_session="devpulse") + bot._active_chat_id = 42 + + mapping_dir = tmp_path / ".aipass" / "telegram_bots" + result_obj = MagicMock() + result_obj.returncode = 0 + with ( + patch("subprocess.run", return_value=result_obj), + patch("pathlib.Path.home", return_value=tmp_path), + ): + bot.ensure_tmux_session() + + mapping_file = mapping_dir / "bot-mirror_test.json" + assert not mapping_file.exists() + + +# ============================================= +# 3. Lock file skipped in attach-only +# ============================================= + + +class TestAttachOnlyLock: + """Lock file still created in attach-only (prevents duplicate pollers).""" + + def test_lock_still_created_in_attach_mode(self, tmp_path, _patch_base_bot_deps): + """run() still creates lock in attach-only mode (one poller per bot_id).""" + bot = _make_bot(tmp_path, _patch_base_bot_deps, attach_only=True, shared_session="devpulse") + + with ( + patch.object(bot, "_check_lock", return_value=False) as mock_check, + patch.object(bot, "_create_lock") as mock_create, + patch.object(bot, "verify_connection", return_value=True), + patch.object(bot, "_set_command_menu"), + patch.object(bot, "_boot_monitor"), + patch.object(bot, "clean_stale_pending"), + patch.object(bot, "_load_offset", return_value=0), + patch.object(bot, "poll_updates", side_effect=KeyboardInterrupt), + patch("apps.handlers.base_bot.PENDING_DIR", tmp_path), + ): + bot.run() + + mock_check.assert_called_once() + mock_create.assert_called_once() + + def test_lock_created_in_normal_mode(self, tmp_path, _patch_base_bot_deps): + """run() creates lock in normal (non-attach) mode.""" + bot = _make_bot(tmp_path, _patch_base_bot_deps, attach_only=False) + + with ( + patch.object(bot, "_check_lock", return_value=False), + patch.object(bot, "_create_lock") as mock_create, + patch.object(bot, "verify_connection", return_value=True), + patch.object(bot, "_set_command_menu"), + patch.object(bot, "_boot_monitor"), + patch.object(bot, "clean_stale_pending"), + patch.object(bot, "_load_offset", return_value=0), + patch.object(bot, "poll_updates", side_effect=KeyboardInterrupt), + patch("apps.handlers.base_bot.PENDING_DIR", tmp_path), + ): + bot.run() + + mock_create.assert_called_once() + + +# ============================================= +# 4. Config loading +# ============================================= + + +class TestAttachOnlyConfig: + """attach_only flag passed through config.""" + + def test_attach_only_defaults_false(self, tmp_path, _patch_base_bot_deps): + bot = _make_bot(tmp_path, _patch_base_bot_deps) + assert bot._attach_only is False + + def test_attach_only_set_true(self, tmp_path, _patch_base_bot_deps): + bot = _make_bot(tmp_path, _patch_base_bot_deps, attach_only=True) + assert bot._attach_only is True diff --git a/src/aipass/skills/.aipass/skills/telegram/tests/test_bot_registry.py b/src/aipass/skills/lib/telegram/tests/test_bot_registry.py similarity index 97% rename from src/aipass/skills/.aipass/skills/telegram/tests/test_bot_registry.py rename to src/aipass/skills/lib/telegram/tests/test_bot_registry.py index 3e4da190..1cd02a06 100644 --- a/src/aipass/skills/.aipass/skills/telegram/tests/test_bot_registry.py +++ b/src/aipass/skills/lib/telegram/tests/test_bot_registry.py @@ -6,7 +6,6 @@ """ import json -from pathlib import Path import pytest from apps.handlers import bot_registry # type: ignore[import-not-found] @@ -191,11 +190,15 @@ def test_creates_directory_if_missing(self): assert result is True assert bot_registry.REGISTRY_DIR.is_dir() - def test_returns_false_on_write_failure(self, monkeypatch): + def test_returns_false_on_write_failure(self, monkeypatch, tmp_path): """Should return False when writing fails (e.g. permission error).""" - # Point to an impossible path - monkeypatch.setattr(bot_registry, "REGISTRY_DIR", Path("/proc/nonexistent/impossible")) - monkeypatch.setattr(bot_registry, "REGISTRY_FILE", Path("/proc/nonexistent/impossible/_registry.json")) + # Put a regular file where a directory is expected. mkdir(parents=True) + # then fails on every OS (NotADirectoryError/FileExistsError), so this is + # cross-platform — unlike a hardcoded Unix-only path such as /proc/... + blocker = tmp_path / "blocker" + blocker.write_text("x", encoding="utf-8") + monkeypatch.setattr(bot_registry, "REGISTRY_DIR", blocker / "sub") + monkeypatch.setattr(bot_registry, "REGISTRY_FILE", blocker / "sub" / "_registry.json") result = bot_registry.save_registry({"bots": {}, "metadata": {}}) diff --git a/src/aipass/skills/.aipass/skills/telegram/tests/test_botfather_client.py b/src/aipass/skills/lib/telegram/tests/test_botfather_client.py similarity index 100% rename from src/aipass/skills/.aipass/skills/telegram/tests/test_botfather_client.py rename to src/aipass/skills/lib/telegram/tests/test_botfather_client.py diff --git a/src/aipass/skills/.aipass/skills/telegram/tests/test_handler_routing.py b/src/aipass/skills/lib/telegram/tests/test_handler_routing.py similarity index 100% rename from src/aipass/skills/.aipass/skills/telegram/tests/test_handler_routing.py rename to src/aipass/skills/lib/telegram/tests/test_handler_routing.py diff --git a/src/aipass/skills/lib/telegram/tests/test_heartbeat_delivered.py b/src/aipass/skills/lib/telegram/tests/test_heartbeat_delivered.py new file mode 100644 index 00000000..cf527fe3 --- /dev/null +++ b/src/aipass/skills/lib/telegram/tests/test_heartbeat_delivered.py @@ -0,0 +1,246 @@ +# =================== AIPass ==================== +# Name: test_heartbeat_delivered.py +# Description: Tests for heartbeat delivered-flag fix (DPLAN-0223) +# Version: 1.0.0 +# Created: 2026-06-29 +# Modified: 2026-06-29 +# ============================================= + +""" +Tests for heartbeat delivered-flag fix (DPLAN-0223). + +The heartbeat now stops when the pending file contains 'delivered': true +(set by @hooks _advance_pending), instead of waiting for file deletion. + +Tests cover: + - Heartbeat stops when pending file has delivered=true + - Heartbeat continues when pending file exists but not delivered + - Heartbeat stops when pending file is absent (backward compat) + - Multi-Stop keeps file alive (delivered flag, not deletion) + - Reply not clobbered: heartbeat does NOT edit after delivery +""" + +import json +import time +import pytest +from unittest.mock import patch + +from apps.handlers.base_bot import BaseBot # type: ignore[import-not-found] + + +@pytest.fixture +def _patch_base_bot_deps(tmp_path): + patches = [ + patch("apps.handlers.base_bot.PENDING_DIR", tmp_path), + patch("apps.handlers.base_bot.signal.signal"), + patch("apps.handlers.base_bot.atexit.register"), + ] + for p in patches: + p.start() + yield + for p in patches: + p.stop() + + +def _make_bot(tmp_path, _patch_base_bot_deps, pending_dir=None): + pdir = pending_dir or tmp_path + with patch("apps.handlers.base_bot.PENDING_DIR", pdir): + workdir = tmp_path / "workdir" + workdir.mkdir(exist_ok=True) + bot = BaseBot( + bot_id="heartbeat_test", + bot_token="123:FAKETOKEN", + work_dir=workdir, + bot_name="Heartbeat Test Bot", + allowed_user_ids=[111], + branch_name=None, + ) + bot.pending_file = pdir / "bot-heartbeat_test.json" + return bot + + +# ============================================= +# 1. _is_pending_delivered +# ============================================= + + +class TestIsPendingDelivered: + """Verify _is_pending_delivered reads the delivered flag correctly.""" + + def test_returns_true_when_delivered(self, tmp_path, _patch_base_bot_deps): + bot = _make_bot(tmp_path, _patch_base_bot_deps) + bot.pending_file.parent.mkdir(parents=True, exist_ok=True) + bot.pending_file.write_text(json.dumps({"chat_id": 42, "delivered": True}), encoding="utf-8") + assert bot._is_pending_delivered() is True + + def test_returns_false_when_not_delivered(self, tmp_path, _patch_base_bot_deps): + bot = _make_bot(tmp_path, _patch_base_bot_deps) + bot.pending_file.parent.mkdir(parents=True, exist_ok=True) + bot.pending_file.write_text(json.dumps({"chat_id": 42}), encoding="utf-8") + assert bot._is_pending_delivered() is False + + def test_returns_true_when_file_absent(self, tmp_path, _patch_base_bot_deps): + bot = _make_bot(tmp_path, _patch_base_bot_deps) + assert not bot.pending_file.exists() + assert bot._is_pending_delivered() is True + + def test_returns_false_on_corrupt_json(self, tmp_path, _patch_base_bot_deps): + bot = _make_bot(tmp_path, _patch_base_bot_deps) + bot.pending_file.parent.mkdir(parents=True, exist_ok=True) + bot.pending_file.write_text("not json", encoding="utf-8") + assert bot._is_pending_delivered() is False + + def test_returns_false_when_delivered_is_false(self, tmp_path, _patch_base_bot_deps): + bot = _make_bot(tmp_path, _patch_base_bot_deps) + bot.pending_file.parent.mkdir(parents=True, exist_ok=True) + bot.pending_file.write_text(json.dumps({"chat_id": 42, "delivered": False}), encoding="utf-8") + assert bot._is_pending_delivered() is False + + +# ============================================= +# 2. HEARTBEAT STOPS ON DELIVERED +# ============================================= + + +class TestHeartbeatStopsOnDelivered: + """Heartbeat thread exits when pending file has delivered=true.""" + + def test_heartbeat_stops_when_delivered_mid_loop(self, tmp_path, _patch_base_bot_deps): + bot = _make_bot(tmp_path, _patch_base_bot_deps) + bot.pending_file.parent.mkdir(parents=True, exist_ok=True) + bot.pending_file.write_text(json.dumps({"chat_id": 42}), encoding="utf-8") + + call_count = 0 + + def fake_edit(chat_id, msg_id, text): + nonlocal call_count + call_count += 1 + # Simulate delivery on first heartbeat edit + bot.pending_file.write_text(json.dumps({"chat_id": 42, "delivered": True}), encoding="utf-8") + + with ( + patch.object(bot, "edit_message", side_effect=fake_edit), + patch.object(bot, "_tmux_session_exists", return_value=True), + patch("apps.handlers.base_bot.HEARTBEAT_INTERVAL", 0.1), + ): + bot._start_heartbeat(42, 999) + time.sleep(0.5) + bot._stop_heartbeat() + + # Should have stopped after 1 edit (when delivered was set) + assert call_count <= 2 + + def test_heartbeat_continues_when_not_delivered(self, tmp_path, _patch_base_bot_deps): + bot = _make_bot(tmp_path, _patch_base_bot_deps) + bot.pending_file.parent.mkdir(parents=True, exist_ok=True) + bot.pending_file.write_text(json.dumps({"chat_id": 42}), encoding="utf-8") + + call_count = 0 + + def fake_edit(chat_id, msg_id, text): + nonlocal call_count + call_count += 1 + + with ( + patch.object(bot, "edit_message", side_effect=fake_edit), + patch.object(bot, "_tmux_session_exists", return_value=True), + patch("apps.handlers.base_bot.HEARTBEAT_INTERVAL", 0.1), + ): + bot._start_heartbeat(42, 999) + time.sleep(0.5) + bot._stop_heartbeat() + + # Should have ticked multiple times since never delivered + assert call_count >= 2 + + def test_heartbeat_stops_when_file_absent(self, tmp_path, _patch_base_bot_deps): + """Backward compat: heartbeat still stops when file is gone.""" + bot = _make_bot(tmp_path, _patch_base_bot_deps) + assert not bot.pending_file.exists() + + with ( + patch.object(bot, "edit_message") as mock_edit, + patch.object(bot, "_tmux_session_exists", return_value=True), + patch("apps.handlers.base_bot.HEARTBEAT_INTERVAL", 0.1), + ): + bot._start_heartbeat(42, 999) + time.sleep(0.4) + bot._stop_heartbeat() + + # File never existed, so heartbeat breaks immediately — no edits + mock_edit.assert_not_called() + + +# ============================================= +# 3. MULTI-STOP KEEPS FILE ALIVE +# ============================================= + + +class TestMultiStopFileAlive: + """Pending file survives delivery (advanced, not deleted).""" + + def test_pending_file_survives_with_delivered_flag(self, tmp_path, _patch_base_bot_deps): + bot = _make_bot(tmp_path, _patch_base_bot_deps) + bot.pending_file.parent.mkdir(parents=True, exist_ok=True) + + initial_data = {"chat_id": 42, "transcript_line_after": 100} + bot.pending_file.write_text(json.dumps(initial_data), encoding="utf-8") + + # Simulate _advance_pending behavior (what @hooks does) + data = json.loads(bot.pending_file.read_text(encoding="utf-8")) + data["delivered"] = True + data["transcript_line_after"] = 200 + bot.pending_file.write_text(json.dumps(data), encoding="utf-8") + + # File still exists + assert bot.pending_file.exists() + # But heartbeat sees it as delivered + assert bot._is_pending_delivered() is True + # Cursor advanced + reloaded = json.loads(bot.pending_file.read_text(encoding="utf-8")) + assert reloaded["transcript_line_after"] == 200 + + def test_new_message_overwrites_delivered(self, tmp_path, _patch_base_bot_deps): + """A new write_pending_file clears the delivered flag.""" + bot = _make_bot(tmp_path, _patch_base_bot_deps) + bot.pending_file.parent.mkdir(parents=True, exist_ok=True) + + # Simulate previous delivery + bot.pending_file.write_text(json.dumps({"chat_id": 42, "delivered": True}), encoding="utf-8") + assert bot._is_pending_delivered() is True + + # Now write a new pending (as handle_message does) + with patch.object(bot, "_get_transcript_line_count", return_value=300): + bot.write_pending_file(42, 999, 1000) + + # delivered flag should be gone + assert bot._is_pending_delivered() is False + data = json.loads(bot.pending_file.read_text(encoding="utf-8")) + assert "delivered" not in data + + +# ============================================= +# 4. REPLY NOT CLOBBERED +# ============================================= + + +class TestReplyNotClobbered: + """After delivery, heartbeat must NOT edit the message (no clobber).""" + + def test_no_edit_after_delivered(self, tmp_path, _patch_base_bot_deps): + bot = _make_bot(tmp_path, _patch_base_bot_deps) + bot.pending_file.parent.mkdir(parents=True, exist_ok=True) + # Start with delivered=true (simulates hooks already delivered before heartbeat tick) + bot.pending_file.write_text(json.dumps({"chat_id": 42, "delivered": True}), encoding="utf-8") + + with ( + patch.object(bot, "edit_message") as mock_edit, + patch.object(bot, "_tmux_session_exists", return_value=True), + patch("apps.handlers.base_bot.HEARTBEAT_INTERVAL", 0.1), + ): + bot._start_heartbeat(42, 999) + time.sleep(0.4) + bot._stop_heartbeat() + + # Heartbeat saw delivered immediately, never edited + mock_edit.assert_not_called() diff --git a/src/aipass/skills/.aipass/skills/telegram/tests/test_log_streamer.py b/src/aipass/skills/lib/telegram/tests/test_log_streamer.py similarity index 98% rename from src/aipass/skills/.aipass/skills/telegram/tests/test_log_streamer.py rename to src/aipass/skills/lib/telegram/tests/test_log_streamer.py index ce2d6ec8..dbb188be 100644 --- a/src/aipass/skills/.aipass/skills/telegram/tests/test_log_streamer.py +++ b/src/aipass/skills/lib/telegram/tests/test_log_streamer.py @@ -62,11 +62,13 @@ def streamer(logs_dir, tmp_path): @pytest.fixture def streamer_with_files(logs_dir, tmp_path): """Create a LogStreamer after pre-populating matching and non-matching log files.""" - # Create matching log files with content - (logs_dir / "api_main.log").write_text("line1\nline2\n", encoding="utf-8") - (logs_dir / "api_error.log").write_text("err1\n", encoding="utf-8") + # Create matching log files with content. newline="" disables newline + # translation so byte counts match len() on Windows too (\n stays 1 byte, + # not \r\n) — real log files are LF-terminated. + (logs_dir / "api_main.log").write_text("line1\nline2\n", encoding="utf-8", newline="") + (logs_dir / "api_error.log").write_text("err1\n", encoding="utf-8", newline="") # Create a non-matching file (should be ignored) - (logs_dir / "trigger_main.log").write_text("other\n", encoding="utf-8") + (logs_dir / "trigger_main.log").write_text("other\n", encoding="utf-8", newline="") return _make_streamer(logs_dir, tmp_path) diff --git a/src/aipass/skills/lib/telegram/tests/test_mirror_session.py b/src/aipass/skills/lib/telegram/tests/test_mirror_session.py new file mode 100644 index 00000000..c13d3f84 --- /dev/null +++ b/src/aipass/skills/lib/telegram/tests/test_mirror_session.py @@ -0,0 +1,456 @@ +# =================== AIPass ==================== +# Name: test_mirror_session.py +# Description: Tests for TDPLAN-0009 FINISH — mirror session launch, transcript resolver, create→attach +# Version: 1.0.0 +# Created: 2026-06-29 +# Modified: 2026-06-29 +# ============================================= + +""" +Tests for the mirror session system (TDPLAN-0009 FINISH). + +Covers: + - launch_mirror_session: tmux session with --dangerously-skip-permissions + - start_service: systemd user service start + - create_bot mirror params: shared_session, attach_only, chat_id in config + - _resolve_active_transcript: PID-based transcript detection + - _write_mirror_mapping: transcript-change detection and rewrite + - _config_chat_id initialization +""" + +import json +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest + +from apps.handlers.base_bot import BaseBot # type: ignore[import-not-found] +from apps.handlers.bot_factory import ( # type: ignore[import-not-found] + launch_mirror_session, + start_service, +) + + +# ============================================= +# Fixtures +# ============================================= + + +@pytest.fixture +def _patch_base_bot_deps(tmp_path): + patches = [ + patch("apps.handlers.base_bot.PENDING_DIR", tmp_path), + patch("apps.handlers.base_bot.signal.signal"), + patch("apps.handlers.base_bot.atexit.register"), + ] + for p in patches: + p.start() + yield + for p in patches: + p.stop() + + +def _make_bot(tmp_path, _patch_base_bot_deps, attach_only=False, shared_session=None): + workdir = tmp_path / "workdir" + workdir.mkdir(exist_ok=True) + with patch("apps.handlers.base_bot.PENDING_DIR", tmp_path): + bot = BaseBot( + bot_id="mirror_test", + bot_token="123:FAKETOKEN", + work_dir=workdir, + bot_name="Mirror Test Bot", + allowed_user_ids=[111], + branch_name="api", + shared_session=shared_session, + attach_only=attach_only, + ) + bot.send_message = MagicMock(return_value={"ok": True, "message_id": 1}) + return bot + + +# ============================================= +# 1. launch_mirror_session +# ============================================= + + +class TestLaunchMirrorSession: + """Tests for launch_mirror_session() in bot_factory.""" + + def test_creates_tmux_session_with_skip_perms(self): + """tmux new-session created and claude launched with skip-permissions.""" + no_session = MagicMock(returncode=1) + ok = MagicMock(returncode=0) + side = [no_session, ok, ok, ok] + + with ( + patch("apps.handlers.bot_factory.subprocess.run", side_effect=side), + patch("time.sleep"), + ): + result = launch_mirror_session( + session_name="telegram-api", + bot_id="api", + work_dir="/tmp/test", + ) + + assert result is True + + def test_claude_launched_with_correct_flags(self): + """AIPASS_SESSION_TYPE=interactive-mirror and skip-perms flag set.""" + calls_made = [] + first_call = True + + def _track(*args, **kwargs): + """Record subprocess.run calls for assertion.""" + nonlocal first_call + calls_made.append(args[0] if args else kwargs.get("args", [])) + if first_call: + first_call = False + return MagicMock(returncode=1) + result = MagicMock(returncode=0) + return result + + with ( + patch("apps.handlers.bot_factory.subprocess.run", side_effect=_track), + patch("time.sleep"), + ): + launch_mirror_session( + session_name="telegram-api", + bot_id="api", + work_dir="/tmp/test", + ) + + send_keys_calls = [c for c in calls_made if "send-keys" in c] + claude_cmd = send_keys_calls[-1][-2] + assert "interactive-mirror" in claude_cmd + + def test_idempotent_if_session_exists(self): + """Returns True without creating if session already exists.""" + has_session = MagicMock(returncode=0) + + with patch("apps.handlers.bot_factory.subprocess.run", return_value=has_session) as mock_run: + result = launch_mirror_session(session_name="telegram-api", bot_id="api", work_dir="/tmp/test") + + assert result is True + assert mock_run.call_count == 1 + + def test_returns_false_when_tmux_not_found(self): + """Returns False when tmux is not installed.""" + with patch("apps.handlers.bot_factory.subprocess.run", side_effect=FileNotFoundError): + result = launch_mirror_session(session_name="telegram-api", bot_id="api", work_dir="/tmp/test") + + assert result is False + + +# ============================================= +# 2. start_service +# ============================================= + + +class TestStartService: + """Tests for start_service() in bot_factory.""" + + def test_starts_systemd_service(self): + """Calls systemctl --user start telegram-bot@{bot_id}.""" + mock_result = MagicMock(returncode=0) + with patch("apps.handlers.bot_factory.subprocess.run", return_value=mock_result) as mock_run: + result = start_service("api") + + assert result is True + mock_run.assert_called_once() + cmd = mock_run.call_args[0][0] + assert cmd == ["systemctl", "--user", "start", "telegram-bot@api"] + + def test_returns_false_on_failure(self): + """Returns False when systemctl returns non-zero.""" + mock_result = MagicMock(returncode=1, stderr="unit not found") + with patch("apps.handlers.bot_factory.subprocess.run", return_value=mock_result): + assert start_service("api") is False + + def test_returns_false_on_timeout(self): + """Returns False on TimeoutExpired.""" + import subprocess + + err = subprocess.TimeoutExpired(cmd="", timeout=10) + with patch("apps.handlers.bot_factory.subprocess.run", side_effect=err): + assert start_service("api") is False + + +# ============================================= +# 3. create_bot mirror params +# ============================================= + + +class TestCreateBotMirror: + """Tests for create_bot() with mirror params.""" + + @pytest.fixture + def _mock_create_deps(self): + """Mock all external deps of create_bot.""" + bot_info = {"username": "test_bot", "id": 123} + branch_info = {"name": "api", "path": "/home/test/api"} + patches = [ + patch("apps.handlers.bot_factory.validate_token", return_value=bot_info), + patch("apps.handlers.bot_factory.validate_branch", return_value=branch_info), + patch("apps.handlers.bot_factory.get_bot", return_value=None), + patch("apps.handlers.bot_factory.get_bot_by_branch", return_value=None), + patch("apps.handlers.bot_factory.ensure_registry"), + patch("apps.handlers.bot_factory._api_set_secret"), + patch("apps.handlers.bot_factory.register_bot", return_value=True), + patch("apps.handlers.bot_factory.set_bot_commands"), + patch("apps.handlers.bot_factory.build_botfather_commands", return_value=[]), + patch("apps.handlers.bot_factory.enable_service", return_value=True), + patch("apps.handlers.bot_factory.start_bot_process", return_value=True), + patch("apps.handlers.bot_factory.launch_mirror_session", return_value=True), + patch("apps.handlers.bot_factory.start_service", return_value=True), + patch("apps.handlers.bot_factory._BOT_CONFIG_DIR", Path("/tmp/test_bots")), + ] + mocks = {} + started = [] + for p in patches: + m = p.start() + started.append(p) + name = p.attribute if hasattr(p, "attribute") and p.attribute else str(p).split(".")[-1].rstrip("'>)") + mocks[name] = m + yield mocks + for p in started: + p.stop() + + def test_config_includes_mirror_fields(self, _mock_create_deps, tmp_path): + """Config written with shared_session, attach_only, chat_id.""" + from apps.handlers.bot_factory import create_bot # type: ignore[import-not-found] + + with patch("apps.handlers.bot_factory._BOT_CONFIG_DIR", tmp_path): + result = create_bot( + bot_id="api", + bot_token="123:FAKE", + branch_name="api", + shared_session="telegram-api", + attach_only=True, + chat_id=42, + ) + + assert result is not None + config_file = tmp_path / "api.json" + assert config_file.exists() + config = json.loads(config_file.read_text()) + assert config["shared_session"] == "telegram-api" + assert config["attach_only"] is True + assert config["chat_id"] == 42 + + def test_launches_mirror_session_when_attach_only(self, _mock_create_deps, tmp_path): + """launch_mirror_session called when shared_session + attach_only.""" + from apps.handlers.bot_factory import create_bot # type: ignore[import-not-found] + + with patch("apps.handlers.bot_factory._BOT_CONFIG_DIR", tmp_path): + create_bot( + bot_id="api", + bot_token="123:FAKE", + branch_name="api", + shared_session="telegram-api", + attach_only=True, + ) + + _mock_create_deps["launch_mirror_session"].assert_called_once() + + def test_starts_via_systemd_when_mirror(self, _mock_create_deps, tmp_path): + """Mirror bot started via start_service, not start_bot_process.""" + from apps.handlers.bot_factory import create_bot # type: ignore[import-not-found] + + with patch("apps.handlers.bot_factory._BOT_CONFIG_DIR", tmp_path): + create_bot( + bot_id="api", + bot_token="123:FAKE", + branch_name="api", + shared_session="telegram-api", + attach_only=True, + ) + + _mock_create_deps["start_service"].assert_called_once() + _mock_create_deps["start_bot_process"].assert_not_called() + + def test_starts_via_popen_when_not_mirror(self, _mock_create_deps, tmp_path): + """Non-mirror bot still uses start_bot_process.""" + from apps.handlers.bot_factory import create_bot # type: ignore[import-not-found] + + with patch("apps.handlers.bot_factory._BOT_CONFIG_DIR", tmp_path): + create_bot( + bot_id="api", + bot_token="123:FAKE", + branch_name="api", + ) + + _mock_create_deps["start_bot_process"].assert_called_once() + _mock_create_deps["launch_mirror_session"].assert_not_called() + + +# ============================================= +# 4. Transcript resolution and mapping rewrite +# ============================================= + + +class TestMirrorMappingRewrite: + """Tests for transcript-change detection in _write_mirror_mapping.""" + + def test_mapping_rewrites_on_transcript_change(self, tmp_path, _patch_base_bot_deps): + """When transcript path changes, mapping is rewritten with new cursor.""" + bot = _make_bot(tmp_path, _patch_base_bot_deps, attach_only=True, shared_session="api") + bot._active_chat_id = 42 + bot.session_name = "api" + + mapping_dir = tmp_path / ".aipass" / "telegram_bots" + + with patch("pathlib.Path.home", return_value=tmp_path): + with patch.object(bot, "_resolve_active_transcript", return_value=("/path/a.jsonl", 10)): + bot._write_mirror_mapping() + + assert bot._mirror_mapping_written is True + assert bot._last_transcript_path == "/path/a.jsonl" + data1 = json.loads((mapping_dir / "bot-mirror_test.json").read_text()) + assert data1["transcript_line_after"] == 10 + + with patch.object(bot, "_resolve_active_transcript", return_value=("/path/b.jsonl", 5)): + bot._write_mirror_mapping() + + data2 = json.loads((mapping_dir / "bot-mirror_test.json").read_text()) + assert data2["transcript_line_after"] == 5 + assert bot._last_transcript_path == "/path/b.jsonl" + + def test_mapping_not_rewritten_same_transcript(self, tmp_path, _patch_base_bot_deps): + """When transcript path unchanged, mapping is not rewritten.""" + bot = _make_bot(tmp_path, _patch_base_bot_deps, attach_only=True, shared_session="api") + bot._active_chat_id = 42 + bot.session_name = "api" + + mapping_dir = tmp_path / ".aipass" / "telegram_bots" + + with patch("pathlib.Path.home", return_value=tmp_path): + with patch.object(bot, "_resolve_active_transcript", return_value=("/path/a.jsonl", 10)): + bot._write_mirror_mapping() + mtime1 = (mapping_dir / "bot-mirror_test.json").stat().st_mtime + + import time + + time.sleep(0.05) + + with patch.object(bot, "_resolve_active_transcript", return_value=("/path/a.jsonl", 20)): + bot._write_mirror_mapping() + mtime2 = (mapping_dir / "bot-mirror_test.json").stat().st_mtime + + assert mtime1 == mtime2 + + def test_mapping_rewrites_when_transcript_none(self, tmp_path, _patch_base_bot_deps): + """When transcript is None both times, still written only once.""" + bot = _make_bot(tmp_path, _patch_base_bot_deps, attach_only=True, shared_session="api") + bot._active_chat_id = 42 + bot.session_name = "api" + + with patch("pathlib.Path.home", return_value=tmp_path): + with patch.object(bot, "_resolve_active_transcript", return_value=(None, 0)): + bot._write_mirror_mapping() + assert bot._mirror_mapping_written is True + bot._write_mirror_mapping() + + def test_last_transcript_path_updated(self, tmp_path, _patch_base_bot_deps): + """_last_transcript_path updated after successful write.""" + bot = _make_bot(tmp_path, _patch_base_bot_deps, attach_only=True, shared_session="api") + bot._active_chat_id = 42 + bot.session_name = "api" + + assert bot._last_transcript_path is None + + with patch("pathlib.Path.home", return_value=tmp_path): + with patch.object(bot, "_resolve_active_transcript", return_value=("/path/transcript.jsonl", 15)): + bot._write_mirror_mapping() + + assert bot._last_transcript_path == "/path/transcript.jsonl" + + +# ============================================= +# 5. Transcript resolver +# ============================================= + + +class TestResolveActiveTranscript: + """Tests for _resolve_active_transcript.""" + + def test_returns_none_when_no_projects_dir(self, tmp_path, _patch_base_bot_deps): + """Returns (None, 0) when projects dir does not exist.""" + bot = _make_bot(tmp_path, _patch_base_bot_deps) + with patch("pathlib.Path.home", return_value=tmp_path): + path, count = bot._resolve_active_transcript() + assert path is None + assert count == 0 + + def test_returns_none_when_no_jsonl_files(self, tmp_path, _patch_base_bot_deps): + """Returns (None, 0) when projects dir exists but no JSONL files.""" + bot = _make_bot(tmp_path, _patch_base_bot_deps) + slug = str(bot.work_dir).replace("\\", "-").replace("/", "-") + projects_dir = tmp_path / ".claude" / "projects" / slug + projects_dir.mkdir(parents=True, exist_ok=True) + + with patch("pathlib.Path.home", return_value=tmp_path): + path, count = bot._resolve_active_transcript() + assert path is None + assert count == 0 + + def test_falls_back_to_recent_mtime(self, tmp_path, _patch_base_bot_deps): + """Uses most recent JSONL when PID check fails and file is < 5min old.""" + bot = _make_bot(tmp_path, _patch_base_bot_deps) + slug = str(bot.work_dir).replace("\\", "-").replace("/", "-") + projects_dir = tmp_path / ".claude" / "projects" / slug + projects_dir.mkdir(parents=True, exist_ok=True) + + transcript = projects_dir / "abc123.jsonl" + transcript.write_text('{"type":"message"}\n{"type":"response"}\n') + + with ( + patch("pathlib.Path.home", return_value=tmp_path), + patch.object(bot, "_get_tmux_pane_pid", return_value=None), + ): + path, count = bot._resolve_active_transcript() + + assert path == str(transcript) + assert count == 2 + + def test_ignores_old_files(self, tmp_path, _patch_base_bot_deps): + """JSONL files older than 5 minutes are not selected.""" + import os + + bot = _make_bot(tmp_path, _patch_base_bot_deps) + slug = str(bot.work_dir).replace("\\", "-").replace("/", "-") + projects_dir = tmp_path / ".claude" / "projects" / slug + projects_dir.mkdir(parents=True, exist_ok=True) + + transcript = projects_dir / "old.jsonl" + transcript.write_text('{"type":"message"}\n') + old_time = 1000000.0 + os.utime(transcript, (old_time, old_time)) + + with ( + patch("pathlib.Path.home", return_value=tmp_path), + patch.object(bot, "_get_tmux_pane_pid", return_value=None), + ): + path, count = bot._resolve_active_transcript() + + assert path is None + assert count == 0 + + +# ============================================= +# 6. _config_chat_id init +# ============================================= + + +class TestConfigChatId: + """Tests for _config_chat_id initialization.""" + + def test_config_chat_id_initialized_to_none(self, tmp_path, _patch_base_bot_deps): + """BaseBot.__init__ initializes _config_chat_id to None.""" + bot = _make_bot(tmp_path, _patch_base_bot_deps) + assert bot._config_chat_id is None + + def test_config_chat_id_settable(self, tmp_path, _patch_base_bot_deps): + """_config_chat_id can be set after construction.""" + bot = _make_bot(tmp_path, _patch_base_bot_deps) + bot._config_chat_id = 42 + assert bot._config_chat_id == 42 diff --git a/src/aipass/skills/lib/telegram/tests/test_monitor.py b/src/aipass/skills/lib/telegram/tests/test_monitor.py new file mode 100644 index 00000000..f5b3a820 --- /dev/null +++ b/src/aipass/skills/lib/telegram/tests/test_monitor.py @@ -0,0 +1,487 @@ +# =================== AIPass ==================== +# Name: test_monitor.py +# Description: Tests for /monitor command — system-wide log subscription (DPLAN-0221) +# Version: 1.0.0 +# Created: 2026-06-29 +# Modified: 2026-06-29 +# ============================================= + +""" +Tests for /monitor command — system-wide log subscription feature (DPLAN-0221). + +Tests cover: + - Subscribe persists {chat_id, mode} to local file + - _boot_monitor reads persisted subscription and starts the streamer + - LogStreamer level_filter: default keeps WARNING/ERROR/CRITICAL, drops INFO + - LogStreamer level_filter: 'all' keeps everything + - LogStreamer system_wide globs *.log (not branch-specific) + - /monitor off clears the subscription + - /monitor command routing (on, all, off, status, bare) +""" + +from pathlib import Path + +import pytest +from unittest.mock import patch, MagicMock + +from apps.handlers.log_streamer import LogStreamer # type: ignore[import-not-found] + + +# ============================================= +# HELPERS +# ============================================= + + +@pytest.fixture +def _patch_base_bot_deps(tmp_path): + """Patch heavy BaseBot dependencies to allow lightweight instantiation.""" + sub_file = tmp_path / "monitor_sub.json" + patches = [ + patch("apps.handlers.base_bot.PENDING_DIR", tmp_path), + patch("apps.handlers.base_bot.signal.signal"), + patch("apps.handlers.base_bot.atexit.register"), + ] + for p in patches: + p.start() + yield sub_file + for p in patches: + p.stop() + + +def _make_bot(tmp_path, _patch_base_bot_deps): + """Create a BaseBot with monitor subscription redirected to tmp_path.""" + from apps.handlers.base_bot import BaseBot # type: ignore[import-not-found] + + workdir = tmp_path / "workdir" + workdir.mkdir() + bot = BaseBot( + bot_id="monitor_test", + bot_token="123:FAKETOKEN", + work_dir=workdir, + bot_name="Monitor Test Bot", + allowed_user_ids=[111], + branch_name=None, + ) + # Redirect subscription file to tmp_path so tests don't touch real HOME + sub_file: Path = _patch_base_bot_deps + bot._monitor_subscription_file = lambda: sub_file # type: ignore[assignment] + return bot + + +# ============================================= +# 1. SUBSCRIBE PERSISTS VIA @API SET_SECRET +# ============================================= + + +class TestSubscribePersists: + """Verify _monitor_subscribe persists {chat_id, mode} and can be reloaded.""" + + def test_subscribe_writes_file(self, tmp_path, _patch_base_bot_deps): + bot = _make_bot(tmp_path, _patch_base_bot_deps) + sub_file: Path = _patch_base_bot_deps + with ( + patch.object(bot, "send_message"), + patch("apps.handlers.base_bot.LogStreamer") as MockStreamer, + ): + MockStreamer.return_value = MagicMock() + bot._monitor_subscribe(42, "default") + + import json + + data = json.loads(sub_file.read_text()) + assert data == {"chat_id": 42, "mode": "default"} + + def test_subscribe_roundtrip_reload(self, tmp_path, _patch_base_bot_deps): + """Written file can be read back by _load_monitor_subscription.""" + bot = _make_bot(tmp_path, _patch_base_bot_deps) + sub_file: Path = _patch_base_bot_deps + import json + + sub_file.write_text(json.dumps({"chat_id": 42, "mode": "all"})) + + result = bot._load_monitor_subscription() + + assert result == {"chat_id": 42, "mode": "all"} + assert result["chat_id"] == 42 + assert result["mode"] == "all" + + def test_subscribe_starts_streamer(self, tmp_path, _patch_base_bot_deps): + bot = _make_bot(tmp_path, _patch_base_bot_deps) + with ( + patch.object(bot, "send_message"), + patch("apps.handlers.base_bot.LogStreamer") as MockStreamer, + ): + mock_instance = MagicMock() + MockStreamer.return_value = mock_instance + + bot._monitor_subscribe(42, "default") + + MockStreamer.assert_called_once_with( + "123:FAKETOKEN", + 42, + branch_name="monitor", + system_wide=True, + level_filter="default", + ) + mock_instance.start.assert_called_once() + assert bot._monitor_streamer is mock_instance + + def test_subscribe_sends_confirmation(self, tmp_path, _patch_base_bot_deps): + bot = _make_bot(tmp_path, _patch_base_bot_deps) + with ( + patch.object(bot, "send_message") as mock_send, + patch("apps.handlers.base_bot.LogStreamer", return_value=MagicMock()), + ): + bot._monitor_subscribe(42, "default") + mock_send.assert_called_once() + msg = mock_send.call_args[0][1] + assert "errors & warnings" in msg + + def test_subscribe_stops_existing_streamer(self, tmp_path, _patch_base_bot_deps): + bot = _make_bot(tmp_path, _patch_base_bot_deps) + old_streamer = MagicMock() + bot._monitor_streamer = old_streamer + + with ( + patch.object(bot, "send_message"), + patch("apps.handlers.base_bot.LogStreamer", return_value=MagicMock()), + ): + bot._monitor_subscribe(42, "all") + old_streamer.stop.assert_called_once() + + def test_subscribe_aborts_on_save_failure(self, tmp_path, _patch_base_bot_deps): + bot = _make_bot(tmp_path, _patch_base_bot_deps) + with ( + patch.object(bot, "_save_monitor_subscription", return_value=False), + patch.object(bot, "send_message") as mock_send, + ): + bot._monitor_subscribe(42, "default") + msg = mock_send.call_args[0][1] + assert "Failed" in msg + assert bot._monitor_streamer is None + + +# ============================================= +# 2. BOOT-START READS SUBSCRIPTION +# ============================================= + + +class TestBootMonitor: + """Verify _boot_monitor reads persisted sub and starts streamer.""" + + def test_boot_starts_streamer_from_persisted(self, tmp_path, _patch_base_bot_deps): + bot = _make_bot(tmp_path, _patch_base_bot_deps) + sub_file: Path = _patch_base_bot_deps + import json + + sub_file.write_text(json.dumps({"chat_id": 42, "mode": "default"})) + + with patch("apps.handlers.base_bot.LogStreamer") as MockStreamer: + mock_instance = MagicMock() + MockStreamer.return_value = mock_instance + + bot._boot_monitor() + + MockStreamer.assert_called_once_with( + "123:FAKETOKEN", + 42, + branch_name="monitor", + system_wide=True, + level_filter="default", + ) + mock_instance.start.assert_called_once() + assert bot._monitor_streamer is mock_instance + + def test_boot_noop_when_no_subscription(self, tmp_path, _patch_base_bot_deps): + bot = _make_bot(tmp_path, _patch_base_bot_deps) + # No file written — subscription absent + with patch("apps.handlers.base_bot.LogStreamer") as MockStreamer: + bot._boot_monitor() + MockStreamer.assert_not_called() + assert bot._monitor_streamer is None + + def test_boot_noop_when_empty_subscription(self, tmp_path, _patch_base_bot_deps): + bot = _make_bot(tmp_path, _patch_base_bot_deps) + sub_file: Path = _patch_base_bot_deps + sub_file.write_text("{}") + + with patch("apps.handlers.base_bot.LogStreamer") as MockStreamer: + bot._boot_monitor() + MockStreamer.assert_not_called() + + def test_boot_respects_mode_all(self, tmp_path, _patch_base_bot_deps): + bot = _make_bot(tmp_path, _patch_base_bot_deps) + sub_file: Path = _patch_base_bot_deps + import json + + sub_file.write_text(json.dumps({"chat_id": 99, "mode": "all"})) + + with patch("apps.handlers.base_bot.LogStreamer") as MockStreamer: + MockStreamer.return_value = MagicMock() + bot._boot_monitor() + MockStreamer.assert_called_once_with( + "123:FAKETOKEN", + 99, + branch_name="monitor", + system_wide=True, + level_filter="all", + ) + + +# ============================================= +# 3. LEVEL FILTER +# ============================================= + + +class TestLevelFilter: + """Verify LogStreamer._filter_lines keeps/drops by level.""" + + @pytest.fixture + def streamer_default(self, tmp_path): + logs_dir = tmp_path / "system_logs" + logs_dir.mkdir() + with patch("apps.handlers.log_streamer.SYSTEM_LOGS_DIR", logs_dir): + return LogStreamer("tok", 1, "x", system_wide=True, level_filter="default") + + @pytest.fixture + def streamer_all(self, tmp_path): + logs_dir = tmp_path / "system_logs" + logs_dir.mkdir() + with patch("apps.handlers.log_streamer.SYSTEM_LOGS_DIR", logs_dir): + return LogStreamer("tok", 1, "x", system_wide=True, level_filter="all") + + def test_default_keeps_warning(self, streamer_default): + lines = ["2026-06-24 | WARNING | something bad"] + assert streamer_default._filter_lines(lines) == lines + + def test_default_keeps_error(self, streamer_default): + lines = ["2026-06-24 | ERROR | crash"] + assert streamer_default._filter_lines(lines) == lines + + def test_default_keeps_critical(self, streamer_default): + lines = ["2026-06-24 | CRITICAL | meltdown"] + assert streamer_default._filter_lines(lines) == lines + + def test_default_drops_info(self, streamer_default): + lines = ["2026-06-24 | INFO | all is well"] + assert streamer_default._filter_lines(lines) == [] + + def test_default_drops_debug(self, streamer_default): + lines = ["2026-06-24 | DEBUG | verbose detail"] + assert streamer_default._filter_lines(lines) == [] + + def test_default_mixed_keeps_only_high_severity(self, streamer_default): + lines = [ + "2026-06-24 | INFO | routine", + "2026-06-24 | WARNING | watch out", + "2026-06-24 | DEBUG | trace", + "2026-06-24 | ERROR | failure", + ] + result = streamer_default._filter_lines(lines) + assert len(result) == 2 + assert "WARNING" in result[0] + assert "ERROR" in result[1] + + def test_all_keeps_everything(self, streamer_all): + lines = [ + "2026-06-24 | INFO | routine", + "2026-06-24 | WARNING | watch out", + "2026-06-24 | DEBUG | trace", + ] + assert streamer_all._filter_lines(lines) == lines + + +# ============================================= +# 4. SYSTEM-WIDE GLOB +# ============================================= + + +class TestSystemWideGlob: + """Verify system_wide=True globs *.log, not just branch-specific.""" + + def test_system_wide_finds_all_logs(self, tmp_path): + logs_dir = tmp_path / "system_logs" + logs_dir.mkdir() + (logs_dir / "api_main.log").write_text("a\n") + (logs_dir / "prax_main.log").write_text("b\n") + (logs_dir / "trigger_events.log").write_text("c\n") + + with patch("apps.handlers.log_streamer.SYSTEM_LOGS_DIR", logs_dir): + s = LogStreamer("tok", 1, "monitor", system_wide=True, level_filter="all") + + assert len(s.log_positions) == 3 + + def test_non_system_wide_finds_only_branch(self, tmp_path): + logs_dir = tmp_path / "system_logs" + logs_dir.mkdir() + (logs_dir / "api_main.log").write_text("a\n") + (logs_dir / "prax_main.log").write_text("b\n") + + with patch("apps.handlers.log_streamer.SYSTEM_LOGS_DIR", logs_dir): + s = LogStreamer("tok", 1, "api", system_wide=False, level_filter="all") + + assert len(s.log_positions) == 1 + assert any("api_main" in p for p in s.log_positions) + + def test_system_wide_reads_new_lines_from_all(self, tmp_path): + logs_dir = tmp_path / "system_logs" + logs_dir.mkdir() + f1 = logs_dir / "api_main.log" + f2 = logs_dir / "prax_main.log" + f1.write_text("") + f2.write_text("") + + with patch("apps.handlers.log_streamer.SYSTEM_LOGS_DIR", logs_dir): + s = LogStreamer("tok", 1, "monitor", system_wide=True, level_filter="all") + + f1.write_text("api line\n") + f2.write_text("prax line\n") + + with patch("apps.handlers.log_streamer.SYSTEM_LOGS_DIR", logs_dir): + lines = s._read_new_lines() + + assert "api line" in lines + assert "prax line" in lines + + +# ============================================= +# 5. MONITOR OFF CLEARS SUBSCRIPTION +# ============================================= + + +class TestMonitorOff: + """Verify /monitor off stops streamer and clears persisted state.""" + + def test_off_stops_streamer(self, tmp_path, _patch_base_bot_deps): + bot = _make_bot(tmp_path, _patch_base_bot_deps) + mock_streamer = MagicMock() + bot._monitor_streamer = mock_streamer + + with patch.object(bot, "send_message"): + bot._monitor_unsubscribe(42) + + mock_streamer.stop.assert_called_once() + assert bot._monitor_streamer is None + + def test_off_clears_subscription(self, tmp_path, _patch_base_bot_deps): + bot = _make_bot(tmp_path, _patch_base_bot_deps) + sub_file: Path = _patch_base_bot_deps + import json + + sub_file.write_text(json.dumps({"chat_id": 42, "mode": "default"})) + + with patch.object(bot, "send_message"): + bot._monitor_unsubscribe(42) + + assert not sub_file.exists() + + def test_off_sends_confirmation(self, tmp_path, _patch_base_bot_deps): + bot = _make_bot(tmp_path, _patch_base_bot_deps) + with patch.object(bot, "send_message") as mock_send: + bot._monitor_unsubscribe(42) + + mock_send.assert_called_once() + assert "unsubscribed" in mock_send.call_args[0][1].lower() + + def test_off_safe_when_no_streamer(self, tmp_path, _patch_base_bot_deps): + bot = _make_bot(tmp_path, _patch_base_bot_deps) + assert bot._monitor_streamer is None + + with patch.object(bot, "send_message"): + bot._monitor_unsubscribe(42) + + assert bot._monitor_streamer is None + + +# ============================================= +# 6. COMMAND ROUTING +# ============================================= + + +class TestMonitorCommandRouting: + """Verify _handle_monitor_command routes subcommands correctly.""" + + def test_on_routes_to_subscribe_default(self, tmp_path, _patch_base_bot_deps): + bot = _make_bot(tmp_path, _patch_base_bot_deps) + with patch.object(bot, "_monitor_subscribe") as mock_sub: + bot._handle_monitor_command(42, "on") + mock_sub.assert_called_once_with(42, mode="default") + + def test_all_routes_to_subscribe_all(self, tmp_path, _patch_base_bot_deps): + bot = _make_bot(tmp_path, _patch_base_bot_deps) + with patch.object(bot, "_monitor_subscribe") as mock_sub: + bot._handle_monitor_command(42, "all") + mock_sub.assert_called_once_with(42, mode="all") + + def test_off_routes_to_unsubscribe(self, tmp_path, _patch_base_bot_deps): + bot = _make_bot(tmp_path, _patch_base_bot_deps) + with patch.object(bot, "_monitor_unsubscribe") as mock_unsub: + bot._handle_monitor_command(42, "off") + mock_unsub.assert_called_once_with(42) + + def test_status_routes_to_status(self, tmp_path, _patch_base_bot_deps): + bot = _make_bot(tmp_path, _patch_base_bot_deps) + with patch.object(bot, "_monitor_status") as mock_stat: + bot._handle_monitor_command(42, "status") + mock_stat.assert_called_once_with(42) + + def test_bare_monitor_shows_help(self, tmp_path, _patch_base_bot_deps): + bot = _make_bot(tmp_path, _patch_base_bot_deps) + with patch.object(bot, "send_message") as mock_send: + bot._handle_monitor_command(42, "") + msg = mock_send.call_args[0][1] + assert "/monitor on" in msg + assert "/monitor off" in msg + + def test_unknown_subcommand_shows_help(self, tmp_path, _patch_base_bot_deps): + bot = _make_bot(tmp_path, _patch_base_bot_deps) + with patch.object(bot, "send_message") as mock_send: + bot._handle_monitor_command(42, "banana") + msg = mock_send.call_args[0][1] + assert "/monitor on" in msg + + +# ============================================= +# 7. STATUS REPORTING +# ============================================= + + +class TestMonitorStatus: + """Verify _monitor_status shows correct state.""" + + def test_status_when_not_subscribed(self, tmp_path, _patch_base_bot_deps): + bot = _make_bot(tmp_path, _patch_base_bot_deps) + # No file written — no subscription + with patch.object(bot, "send_message") as mock_send: + bot._monitor_status(42) + msg = mock_send.call_args[0][1] + assert "not subscribed" in msg + + def test_status_when_subscribed_and_running(self, tmp_path, _patch_base_bot_deps): + bot = _make_bot(tmp_path, _patch_base_bot_deps) + sub_file: Path = _patch_base_bot_deps + import json + + sub_file.write_text(json.dumps({"chat_id": 42, "mode": "default"})) + mock_streamer = MagicMock() + mock_streamer._running = True + bot._monitor_streamer = mock_streamer + + with patch.object(bot, "send_message") as mock_send: + bot._monitor_status(42) + msg = mock_send.call_args[0][1] + assert "streaming" in msg + assert "this chat" in msg + + def test_status_shows_mode_label(self, tmp_path, _patch_base_bot_deps): + bot = _make_bot(tmp_path, _patch_base_bot_deps) + sub_file: Path = _patch_base_bot_deps + import json + + sub_file.write_text(json.dumps({"chat_id": 42, "mode": "all"})) + bot._monitor_streamer = MagicMock(_running=True) + + with patch.object(bot, "send_message") as mock_send: + bot._monitor_status(42) + msg = mock_send.call_args[0][1] + assert "firehose" in msg diff --git a/src/aipass/skills/.aipass/skills/telegram/tests/test_multi_bot.py b/src/aipass/skills/lib/telegram/tests/test_multi_bot.py similarity index 94% rename from src/aipass/skills/.aipass/skills/telegram/tests/test_multi_bot.py rename to src/aipass/skills/lib/telegram/tests/test_multi_bot.py index dc253dc2..3f6fb88e 100644 --- a/src/aipass/skills/.aipass/skills/telegram/tests/test_multi_bot.py +++ b/src/aipass/skills/lib/telegram/tests/test_multi_bot.py @@ -1,9 +1,17 @@ +# =================== AIPass ==================== +# Name: test_multi_bot.py +# Description: Comprehensive tests for BaseBot and BranchPlugin +# Version: 1.0.0 +# Created: 2026-06-15 +# Modified: 2026-06-29 +# ============================================= + """ Comprehensive pytest tests for BaseBot and BranchPlugin. Tests cover: - BaseBot initialization and attribute assignment - - Default hook methods (on_message, on_response, on_session_create) + - Default hook methods (on_message, on_response) - Security: is_user_allowed (allowlist), check_rate_limit (sliding window) - BranchPlugin hook overrides (message prefixing, response tagging) - Pending file creation and JSON content @@ -25,8 +33,8 @@ import pytest from unittest.mock import patch, MagicMock -from apps.handlers.base_bot import BaseBot -from apps.handlers.branch_plugin import BranchPlugin +from apps.handlers.base_bot import BaseBot # type: ignore[import-not-found] +from apps.handlers.branch_plugin import BranchPlugin # type: ignore[import-not-found] # ============================================= @@ -173,11 +181,6 @@ def test_on_response_returns_text_unchanged(self, base_bot): def test_on_response_empty_string(self, base_bot): assert base_bot.on_response("") == "" - def test_on_session_create_does_nothing(self, base_bot, tmp_path): - # Should return None and not raise - result = base_bot.on_session_create("test-session", tmp_path) - assert result is None - def test_get_custom_commands_returns_create_and_cancel(self, base_bot): commands = base_bot.get_custom_commands() assert "create" in commands @@ -300,13 +303,6 @@ def test_on_response_multiline(self, branch_bot): result = branch_bot.on_response(response) assert result == f"@dev_central\n{response}" - @patch.object(BranchPlugin, "inject_message") - @patch("apps.handlers.branch_plugin.time.sleep") - def test_on_session_create_injects_hi(self, mock_sleep, mock_inject, branch_bot): - branch_bot.on_session_create("telegram-dev_central", Path("/tmp/test")) - mock_sleep.assert_called_once_with(2) - mock_inject.assert_called_once_with("hi") - def test_branch_plugin_inherits_base_bot(self, branch_bot): assert isinstance(branch_bot, BaseBot) assert branch_bot.bot_id == "dev_central" @@ -587,25 +583,15 @@ def test_session_exists_returns_true(self, mock_run, mock_sleep, base_bot): @patch("apps.handlers.base_bot.time.sleep") @patch("apps.handlers.base_bot.subprocess.run") - def test_session_created_when_not_exists(self, mock_run, mock_sleep, base_bot): - """When session does not exist, create it, set env, launch Claude.""" - call_count = [0] - - def side_effect(cmd, **kwargs): - call_count[0] += 1 - mock_result = MagicMock() - if "has-session" in cmd: - mock_result.returncode = 1 # Session does not exist - return mock_result - mock_result.returncode = 0 - return mock_result - - mock_run.side_effect = side_effect + def test_no_session_returns_false(self, mock_run, mock_sleep, base_bot): + """When no session exists, bot returns False (never spawns own brain).""" + mock_run.return_value = MagicMock(returncode=1) result = base_bot.ensure_tmux_session() - assert result is True - # Should have: has-session, new-session, send-keys (env), send-keys (claude) - assert mock_run.call_count >= 4 + assert result is False + calls = [str(c) for c in mock_run.call_args_list] + for call in calls: + assert "new-session" not in call def test_session_refuses_nonexistent_work_dir(self, tmp_path): """When work_dir doesn't exist, ensure_tmux_session returns False.""" @@ -619,60 +605,10 @@ def test_session_refuses_nonexistent_work_dir(self, tmp_path): result = bot.ensure_tmux_session() assert result is False - @patch("apps.handlers.base_bot.time.sleep") - @patch("apps.handlers.base_bot.subprocess.run") - def test_session_creation_calls_on_session_create(self, mock_run, mock_sleep, base_bot): - """After creating a session, on_session_create hook should be called.""" - - def side_effect(cmd, **kwargs): - mock_result = MagicMock() - if "has-session" in cmd: - mock_result.returncode = 1 - return mock_result - mock_result.returncode = 0 - return mock_result - - mock_run.side_effect = side_effect - - with patch.object(base_bot, "on_session_create") as mock_hook: - base_bot.ensure_tmux_session() - mock_hook.assert_called_once_with(base_bot.session_name, base_bot.work_dir) - - @patch("apps.handlers.base_bot.time.sleep") - @patch("apps.handlers.base_bot.subprocess.run") - def test_session_creation_failure(self, mock_run, mock_sleep, base_bot): - """CalledProcessError during new-session returns False.""" - import subprocess as sp - - def side_effect(cmd, **kwargs): - if "has-session" in cmd: - mock_result = MagicMock() - mock_result.returncode = 1 - return mock_result - if "new-session" in cmd: - raise sp.CalledProcessError(1, cmd, stderr=b"error") - return MagicMock(returncode=0) - - mock_run.side_effect = side_effect - result = base_bot.ensure_tmux_session() - assert result is False - - @patch("apps.handlers.base_bot.time.sleep") - @patch("apps.handlers.base_bot.subprocess.run") - def test_tmux_not_found(self, mock_run, mock_sleep, base_bot): + def test_tmux_not_found_returns_false(self, base_bot): """FileNotFoundError (tmux not installed) returns False.""" - - def side_effect(cmd, **kwargs): - if "has-session" in cmd: - mock_result = MagicMock() - mock_result.returncode = 1 - return mock_result - if "new-session" in cmd: - raise FileNotFoundError("tmux not found") - return MagicMock(returncode=0) - - mock_run.side_effect = side_effect - result = base_bot.ensure_tmux_session() + with patch("apps.handlers.base_bot.subprocess.run", side_effect=FileNotFoundError("tmux")): + result = base_bot.ensure_tmux_session() assert result is False @@ -1553,6 +1489,20 @@ def test_create_command_uses_manual_when_telethon_not_ready(self, mock_check, mo state = self.bot._create_state[self.chat_id] assert state["branch_name"] == "flow" + @patch("apps.handlers.base_bot.get_bot_by_branch", return_value=None) + @patch("apps.handlers.base_bot.validate_branch") + @patch("apps.handlers.base_bot.check_telethon_setup") + def test_manual_fallback_message_shows_reason(self, mock_check, mock_validate, mock_get_bot): + """Manual fallback message includes the reason automation is unavailable.""" + mock_check.return_value = (False, "Telethon library not installed. Run: pip install telethon") + mock_validate.return_value = {"name": "flow", "path": "/home/aipass/flow"} + + self.bot._handle_create_command(self.chat_id, "chat flow") + + msg = self.bot.send_message.call_args[0][1] + assert "Telethon library not installed" in msg + assert "Falling back to manual token flow" in msg + @patch("apps.handlers.base_bot.create_bot") @patch("apps.handlers.base_bot.create_bot_via_botfather") def test_automated_success_message_contains_service_info(self, mock_bf_create, mock_create_bot): @@ -1636,24 +1586,15 @@ def test_ensure_attaches_to_shared_session(self, mock_run, mock_sleep): @patch("apps.handlers.base_bot.time.sleep") @patch("apps.handlers.base_bot.subprocess.run") - def test_ensure_falls_back_when_shared_missing(self, mock_run, mock_sleep): - """When shared session doesn't exist, falls back to own session.""" - - def side_effect(cmd, **kwargs): - mock = MagicMock() - if "has-session" in cmd and cmd[-1] == "pc": - mock.returncode = 1 # shared session not found - elif "has-session" in cmd: - mock.returncode = 1 # own session not found either - else: - mock.returncode = 0 # creation succeeds - return mock - - mock_run.side_effect = side_effect + def test_ensure_returns_false_when_shared_missing(self, mock_run, mock_sleep): + """When shared session doesn't exist and no presence, returns False (no spawn).""" + mock_run.return_value = MagicMock(returncode=1) result = self.bot.ensure_tmux_session() - assert result is True - assert self.bot.session_name == "telegram-dev_central" + assert result is False assert self.bot._using_shared_session is False + calls = [str(c) for c in mock_run.call_args_list] + for call in calls: + assert "new-session" not in call @patch("apps.handlers.base_bot.subprocess.run") def test_inject_uses_shared_session_name(self, mock_run): diff --git a/src/aipass/skills/.aipass/skills/telegram/tests/test_multibot_config.py b/src/aipass/skills/lib/telegram/tests/test_multibot_config.py similarity index 78% rename from src/aipass/skills/.aipass/skills/telegram/tests/test_multibot_config.py rename to src/aipass/skills/lib/telegram/tests/test_multibot_config.py index a2197524..46ddfe74 100644 --- a/src/aipass/skills/.aipass/skills/telegram/tests/test_multibot_config.py +++ b/src/aipass/skills/lib/telegram/tests/test_multibot_config.py @@ -336,7 +336,7 @@ def test_help_returns_help_text(self) -> None: bot_name="AIPass Assistant Bot", ) assert isinstance(result, str) - assert "Commands:" in result + assert "Available commands:" in result def test_new_returns_tuple(self) -> None: """The 'new' command returns tuple of ('new', response_text).""" @@ -411,7 +411,7 @@ class TestBuildHelpText: def test_includes_all_standard_commands(self) -> None: """Help text includes all standard commands.""" result = build_help_text() - assert "Commands:" in result + assert "Available commands:" in result for cmd in STANDARD_COMMANDS: assert f"/{cmd}" in result @@ -430,7 +430,7 @@ def test_includes_custom_commands(self) -> None: def test_includes_footer(self) -> None: """Help text includes the help footer.""" result = build_help_text() - assert "Send any message to chat with Claude" in result + assert "Just send any message to talk to me" in result # ============================================= @@ -463,7 +463,7 @@ def test_includes_commands(self) -> None: bot_name="TestBot", branch_name="test", ) - assert "Commands:" in result + assert "Available commands:" in result for cmd in STANDARD_COMMANDS: assert f"/{cmd}" in result @@ -792,3 +792,202 @@ def test_get_all_bots(self, mock_list_bots: MagicMock) -> None: result = bot_operations.get_all_bots() assert result == expected mock_list_bots.assert_called_once() + + +# ============================================= +# CREATE_BOT -> LOAD_BOT_CONFIG ROUND-TRIP +# ============================================= + + +class TestCreateBotRoundTrip: + """Prove GAP1 is closed: create_bot persists config that load_bot_config reads.""" + + @patch("apps.handlers.bot_factory.start_bot_process", return_value=True) + @patch("apps.handlers.bot_factory.enable_service", return_value=True) + @patch("apps.handlers.bot_factory.set_bot_commands", return_value=True) + @patch("apps.handlers.bot_factory.validate_token") + @patch("apps.handlers.bot_factory.ensure_registry") + def test_create_then_load_roundtrip( + self, + mock_ensure_registry, + mock_validate_token, + mock_set_commands, + mock_enable, + mock_start, + tmp_path, + monkeypatch, + ): + """After create_bot, load_bot_config returns the persisted config.""" + from apps.handlers import bot_factory, config as tg_config + + mock_validate_token.return_value = {"username": "test_bot", "id": 123} + + monkeypatch.setattr(bot_factory, "_BOT_CONFIG_DIR", tmp_path) + + secrets_store = {} + + def fake_set_secret(provider, slug, value, *, as_json=False): + secrets_store[f"{provider}/{slug}"] = value + return tmp_path / f"{slug}.json" + + def fake_get_secret(provider, slug, *, as_json=False): + return secrets_store.get(f"{provider}/{slug}") + + monkeypatch.setattr(bot_factory, "_api_set_secret", fake_set_secret) + monkeypatch.setattr(tg_config, "_api_get_secret", fake_get_secret) + + monkeypatch.setattr("apps.handlers.bot_registry.REGISTRY_DIR", tmp_path / "state") + monkeypatch.setattr( + "apps.handlers.bot_registry.REGISTRY_FILE", + tmp_path / "state" / "_registry.json", + ) + + result = bot_factory.create_bot( + bot_id="roundtrip_bot", + bot_token="111:AAA-test-token", + branch_name=None, + allowed_user_ids=[42], + ) + assert result is not None + assert result["bot_id"] == "roundtrip_bot" + + loaded = tg_config.load_bot_config("roundtrip_bot") + assert loaded is not None + assert loaded["bot_id"] == "roundtrip_bot" + assert loaded["bot_token"] == "111:AAA-test-token" + assert loaded["allowed_user_ids"] == [42] + + @patch("apps.handlers.bot_factory.validate_token") + @patch("apps.handlers.bot_factory.ensure_registry") + def test_create_fails_loud_on_set_secret_error( + self, + mock_ensure_registry, + mock_validate_token, + tmp_path, + monkeypatch, + ): + """create_bot returns None and logs error if set_secret raises.""" + from apps.handlers import bot_factory + + mock_validate_token.return_value = {"username": "test_bot", "id": 123} + monkeypatch.setattr(bot_factory, "_BOT_CONFIG_DIR", tmp_path) + + def failing_set_secret(*args, **kwargs): + raise OSError("permission denied") + + monkeypatch.setattr(bot_factory, "_api_set_secret", failing_set_secret) + + result = bot_factory.create_bot( + bot_id="fail_bot", + bot_token="222:BBB-test-token", + ) + assert result is None + + +# ============================================= +# COMMAND MENU SYNC + POPULATE TESTS +# ============================================= + + +class TestCommandMenuSync: + """Verify /help text and Telegram menu use the same single-source command list.""" + + def test_menu_and_help_have_same_commands(self): + """The command names in build_botfather_commands match those in build_help_text.""" + from apps.handlers.telegram_standards import ( + build_botfather_commands, + build_help_text, + ) + from apps.handlers.base_bot import BaseBot + + custom = BaseBot.get_custom_commands(None) + menu_commands = build_botfather_commands(custom_commands=custom) + menu_names = {c["command"] for c in menu_commands} + + help_text = build_help_text(custom_commands=custom) + help_names = set() + for line in help_text.split("\n"): + if line.startswith("/"): + cmd = line.split(" ")[0].lstrip("/").split("-")[0].strip() + help_names.add(cmd) + + assert menu_names == help_names + + def test_help_contains_enriched_descriptions(self): + """The /help text includes the enriched descriptions.""" + from apps.handlers.telegram_standards import build_help_text + from apps.handlers.base_bot import BaseBot + + custom = BaseBot.get_custom_commands(None) + help_text = build_help_text(custom_commands=custom) + + assert "what this bot is and how to use it" in help_text.lower() + assert "show every command" in help_text.lower() + assert "fresh conversation" in help_text.lower() + assert "branch, uptime" in help_text.lower() + assert "create a telegram bot" in help_text.lower() + assert "cancel an in-progress" in help_text.lower() + + def test_help_footer_updated(self): + """The /help footer uses the enriched text.""" + from apps.handlers.telegram_standards import build_help_text + + help_text = build_help_text() + assert "Just send any message to talk to me" in help_text + + def test_create_bot_uses_single_source(self): + """create_bot calls set_bot_commands with build_botfather_commands output.""" + from apps.handlers import bot_factory + from apps.handlers.telegram_standards import build_botfather_commands + + expected = build_botfather_commands() + with patch.object(bot_factory, "set_bot_commands") as mock_set: + with patch.object(bot_factory, "validate_token", return_value={"username": "t", "id": 1}): + with patch.object(bot_factory, "ensure_registry"): + with patch.object(bot_factory, "register_bot", return_value=True): + with patch.object(bot_factory, "_api_set_secret", return_value=None): + with patch.object(bot_factory, "enable_service"): + with patch.object(bot_factory, "start_bot_process", return_value=True): + bot_factory._BOT_CONFIG_DIR.mkdir(parents=True, exist_ok=True) + bot_factory.create_bot("sync_test", "999:ZZZ-token") + + mock_set.assert_called_once_with("999:ZZZ-token", expected) + + +class TestBaseBotStartupMenu: + """Verify base_bot sets command menu on startup.""" + + @patch("apps.handlers.base_bot.set_bot_commands", return_value=True) + def test_set_command_menu_called_on_startup(self, mock_set_commands): + """_set_command_menu calls set_bot_commands with merged commands.""" + from apps.handlers.base_bot import BaseBot + from apps.handlers.telegram_standards import build_botfather_commands + + bot = BaseBot.__new__(BaseBot) + bot.bot_token = "123:ABC" + bot.custom_commands = {} + + bot._set_command_menu() + + mock_set_commands.assert_called_once() + actual_commands = mock_set_commands.call_args[0][1] + expected = build_botfather_commands(custom_commands=bot.get_custom_commands()) + assert actual_commands == expected + + @patch("apps.handlers.base_bot.set_bot_commands", return_value=True) + def test_menu_includes_custom_commands(self, mock_set_commands): + """Menu includes /create and /cancel from get_custom_commands.""" + from apps.handlers.base_bot import BaseBot + + bot = BaseBot.__new__(BaseBot) + bot.bot_token = "123:ABC" + bot.custom_commands = {} + + bot._set_command_menu() + + actual_commands = mock_set_commands.call_args[0][1] + cmd_names = {c["command"] for c in actual_commands} + assert "create" in cmd_names + assert "cancel" in cmd_names + assert "start" in cmd_names + assert "help" in cmd_names diff --git a/src/aipass/skills/.aipass/skills/telegram/tests/test_multibot_integration.py b/src/aipass/skills/lib/telegram/tests/test_multibot_integration.py similarity index 100% rename from src/aipass/skills/.aipass/skills/telegram/tests/test_multibot_integration.py rename to src/aipass/skills/lib/telegram/tests/test_multibot_integration.py diff --git a/src/aipass/skills/lib/telegram/tests/test_presence_pointer.py b/src/aipass/skills/lib/telegram/tests/test_presence_pointer.py new file mode 100644 index 00000000..82d308ac --- /dev/null +++ b/src/aipass/skills/lib/telegram/tests/test_presence_pointer.py @@ -0,0 +1,458 @@ +# =================== AIPass ==================== +# Name: test_presence_pointer.py +# Description: Tests for FPLAN-0289 P2 — bot follows central presence pointer, never spawns +# Version: 1.0.0 +# Created: 2026-06-29 +# Modified: 2026-06-29 +# ============================================= + +""" +Tests for the presence pointer integration (FPLAN-0289 P2). + +Covers: + - _find_presence_file: walks up from work_dir to locate PRESENCE.central.json + - _read_presence_pointer: reads pointer, validates PID liveness, returns entry + - _find_tmux_for_presence: attach_handle preference, tmux CWD scan fallback + - ensure_tmux_session: presence-first resolution, legacy spawn retired + - handle_message: no-session error message +""" + +import json +import os +from unittest.mock import MagicMock, patch + +import pytest + +from apps.handlers.base_bot import BaseBot # type: ignore[import-not-found] + + +# ============================================= +# Fixtures +# ============================================= + + +@pytest.fixture +def _patch_base_bot_deps(tmp_path): + """Patch signal and atexit for safe BaseBot construction.""" + patches = [ + patch("apps.handlers.base_bot.PENDING_DIR", tmp_path), + patch("apps.handlers.base_bot.signal.signal"), + patch("apps.handlers.base_bot.atexit.register"), + ] + for p in patches: + p.start() + yield + for p in patches: + p.stop() + + +def _make_bot(tmp_path, _patch_base_bot_deps, branch_name="devpulse"): + """Create a BaseBot with test defaults.""" + workdir = tmp_path / "workdir" + workdir.mkdir(exist_ok=True) + with patch("apps.handlers.base_bot.PENDING_DIR", tmp_path): + bot = BaseBot( + bot_id="presence_test", + bot_token="123:FAKETOKEN", + work_dir=workdir, + bot_name="Presence Test Bot", + allowed_user_ids=[111], + branch_name=branch_name, + ) + bot.send_message = MagicMock(return_value={"ok": True, "message_id": 1}) + return bot + + +def _write_presence(tmp_path, data): + """Write a PRESENCE.central.json reachable from bot's work_dir.""" + ai_central = tmp_path / ".ai_central" + ai_central.mkdir(exist_ok=True) + presence_file = ai_central / "PRESENCE.central.json" + presence_file.write_text(json.dumps(data), encoding="utf-8") + return presence_file + + +# ============================================= +# 1. _find_presence_file +# ============================================= + + +class TestFindPresenceFile: + """Locate PRESENCE.central.json by walking up from work_dir.""" + + def test_finds_file_in_parent(self, tmp_path, _patch_base_bot_deps): + """Finds PRESENCE.central.json in parent of work_dir.""" + bot = _make_bot(tmp_path, _patch_base_bot_deps) + _write_presence(tmp_path, {}) + result = bot._find_presence_file() + assert result is not None + assert result.name == "PRESENCE.central.json" + + def test_returns_none_when_absent(self, tmp_path, _patch_base_bot_deps): + """Returns None when no .ai_central/ exists.""" + bot = _make_bot(tmp_path, _patch_base_bot_deps) + result = bot._find_presence_file() + assert result is None + + def test_walks_up_multiple_levels(self, tmp_path, _patch_base_bot_deps): + """Walks up multiple parent directories to find .ai_central/.""" + deep_dir = tmp_path / "a" / "b" / "c" / "workdir" + deep_dir.mkdir(parents=True) + _write_presence(tmp_path, {}) + with patch("apps.handlers.base_bot.PENDING_DIR", tmp_path): + bot = BaseBot( + bot_id="deep_test", + bot_token="t", + work_dir=deep_dir, + branch_name="test", + ) + result = bot._find_presence_file() + assert result is not None + + +# ============================================= +# 2. _read_presence_pointer +# ============================================= + + +class TestReadPresencePointer: + """Read pointer, validate PID liveness, return entry or None.""" + + def test_returns_entry_for_live_pid(self, tmp_path, _patch_base_bot_deps): + """Returns entry dict when PID is alive.""" + bot = _make_bot(tmp_path, _patch_base_bot_deps) + my_pid = os.getpid() + _write_presence( + tmp_path, + { + "devpulse": { + "pid": my_pid, + "session_id": "sess1", + "work_dir": str(tmp_path), + "session_type": "interactive", + "attach_handle": "", + "started": "2026-06-29T10:00:00", + "last_seen": "2026-06-29T10:01:00", + } + }, + ) + result = bot._read_presence_pointer() + assert result is not None + assert result["pid"] == my_pid + assert result["session_type"] == "interactive" + + def test_returns_none_for_dead_pid(self, tmp_path, _patch_base_bot_deps): + """Returns None when the recorded PID is not running.""" + bot = _make_bot(tmp_path, _patch_base_bot_deps) + _write_presence( + tmp_path, + { + "devpulse": { + "pid": 99999999, + "work_dir": str(tmp_path), + } + }, + ) + result = bot._read_presence_pointer() + assert result is None + + def test_returns_none_when_branch_missing(self, tmp_path, _patch_base_bot_deps): + """Returns None when the bot's branch has no entry.""" + bot = _make_bot(tmp_path, _patch_base_bot_deps, branch_name="devpulse") + _write_presence(tmp_path, {"other_branch": {"pid": os.getpid(), "work_dir": str(tmp_path)}}) + result = bot._read_presence_pointer() + assert result is None + + def test_returns_none_when_file_empty(self, tmp_path, _patch_base_bot_deps): + """Returns None when presence file is empty JSON object.""" + bot = _make_bot(tmp_path, _patch_base_bot_deps) + _write_presence(tmp_path, {}) + result = bot._read_presence_pointer() + assert result is None + + def test_returns_none_when_no_presence_file(self, tmp_path, _patch_base_bot_deps): + """Returns None when no PRESENCE.central.json exists.""" + bot = _make_bot(tmp_path, _patch_base_bot_deps) + result = bot._read_presence_pointer() + assert result is None + + def test_returns_none_when_corrupt_json(self, tmp_path, _patch_base_bot_deps): + """Returns None when presence file contains invalid JSON.""" + bot = _make_bot(tmp_path, _patch_base_bot_deps) + ai_central = tmp_path / ".ai_central" + ai_central.mkdir() + (ai_central / "PRESENCE.central.json").write_text("not json!", encoding="utf-8") + result = bot._read_presence_pointer() + assert result is None + + def test_returns_none_when_no_pid(self, tmp_path, _patch_base_bot_deps): + """Returns None when the entry has no pid field.""" + bot = _make_bot(tmp_path, _patch_base_bot_deps) + _write_presence(tmp_path, {"devpulse": {"work_dir": str(tmp_path)}}) + result = bot._read_presence_pointer() + assert result is None + + def test_uses_work_dir_name_when_no_branch_name(self, tmp_path, _patch_base_bot_deps): + """Falls back to work_dir.name as branch key when branch_name is None.""" + bot = _make_bot(tmp_path, _patch_base_bot_deps, branch_name="devpulse") + bot.branch_name = None + workdir_name = bot.work_dir.name + _write_presence(tmp_path, {workdir_name: {"pid": os.getpid(), "work_dir": str(tmp_path)}}) + result = bot._read_presence_pointer() + assert result is not None + + def test_permission_error_treats_as_alive(self, tmp_path, _patch_base_bot_deps): + """PermissionError on os.kill means process exists — treat as alive.""" + bot = _make_bot(tmp_path, _patch_base_bot_deps) + _write_presence(tmp_path, {"devpulse": {"pid": 1, "work_dir": str(tmp_path)}}) + with patch("os.kill", side_effect=PermissionError("denied")): + result = bot._read_presence_pointer() + assert result is not None + + +# ============================================= +# 3. _find_tmux_for_presence +# ============================================= + + +class TestFindTmuxForPresence: + """Find tmux session via attach_handle or CWD scan.""" + + def test_uses_attach_handle_when_present(self, tmp_path, _patch_base_bot_deps): + """Returns attach_handle directly when the tmux session exists.""" + bot = _make_bot(tmp_path, _patch_base_bot_deps) + entry = {"attach_handle": "my-session", "work_dir": str(tmp_path)} + with patch("subprocess.run", return_value=MagicMock(returncode=0)): + result = bot._find_tmux_for_presence(entry) + assert result == "my-session" + + def test_attach_handle_falls_back_on_missing_session(self, tmp_path, _patch_base_bot_deps): + """Falls back to CWD scan when attach_handle session doesn't exist.""" + bot = _make_bot(tmp_path, _patch_base_bot_deps) + entry = {"attach_handle": "dead-session", "work_dir": str(tmp_path)} + + def _side_effect(cmd, **kwargs): + mock = MagicMock() + if "has-session" in cmd: + mock.returncode = 1 + elif "list-panes" in cmd: + mock.returncode = 0 + mock.stdout = f"live-session:{tmp_path}\n" + return mock + + with patch("subprocess.run", side_effect=_side_effect): + result = bot._find_tmux_for_presence(entry) + assert result == "live-session" + + def test_cwd_scan_matches_work_dir(self, tmp_path, _patch_base_bot_deps): + """CWD scan finds the session whose pane path matches work_dir.""" + bot = _make_bot(tmp_path, _patch_base_bot_deps) + entry = {"attach_handle": "", "work_dir": str(tmp_path / "workdir")} + pane_output = f"dev-session:{tmp_path / 'workdir'}\nother:{tmp_path / 'other'}\n" + with patch( + "subprocess.run", + return_value=MagicMock(returncode=0, stdout=pane_output), + ): + result = bot._find_tmux_for_presence(entry) + assert result == "dev-session" + + def test_returns_none_when_no_match(self, tmp_path, _patch_base_bot_deps): + """Returns None when no tmux pane CWD matches the work_dir.""" + bot = _make_bot(tmp_path, _patch_base_bot_deps) + entry = {"attach_handle": "", "work_dir": "/no/such/dir"} + pane_output = f"session1:{tmp_path}\n" + with patch( + "subprocess.run", + return_value=MagicMock(returncode=0, stdout=pane_output), + ): + result = bot._find_tmux_for_presence(entry) + assert result is None + + def test_returns_none_when_no_work_dir(self, tmp_path, _patch_base_bot_deps): + """Returns None immediately when work_dir is empty.""" + bot = _make_bot(tmp_path, _patch_base_bot_deps) + entry = {"attach_handle": "", "work_dir": ""} + result = bot._find_tmux_for_presence(entry) + assert result is None + + def test_returns_none_when_tmux_unavailable(self, tmp_path, _patch_base_bot_deps): + """Returns None when tmux is not installed.""" + bot = _make_bot(tmp_path, _patch_base_bot_deps) + entry = {"attach_handle": "", "work_dir": str(tmp_path)} + with patch("subprocess.run", side_effect=FileNotFoundError("tmux")): + result = bot._find_tmux_for_presence(entry) + assert result is None + + def test_empty_attach_handle_skipped(self, tmp_path, _patch_base_bot_deps): + """Empty attach_handle skips has-session check, goes straight to CWD scan.""" + bot = _make_bot(tmp_path, _patch_base_bot_deps) + entry = {"attach_handle": "", "work_dir": str(tmp_path / "workdir")} + pane_output = f"found:{tmp_path / 'workdir'}\n" + with patch( + "subprocess.run", + return_value=MagicMock(returncode=0, stdout=pane_output), + ): + result = bot._find_tmux_for_presence(entry) + assert result == "found" + + +# ============================================= +# 4. ensure_tmux_session — presence-first +# ============================================= + + +class TestEnsureWithPresence: + """ensure_tmux_session follows presence pointer first.""" + + def test_attaches_via_presence_pointer(self, tmp_path, _patch_base_bot_deps): + """Attaches to live session found via presence pointer.""" + bot = _make_bot(tmp_path, _patch_base_bot_deps) + _write_presence( + tmp_path, + { + "devpulse": { + "pid": os.getpid(), + "work_dir": str(tmp_path / "workdir"), + "session_type": "interactive", + "attach_handle": "live-session", + } + }, + ) + with patch("subprocess.run", return_value=MagicMock(returncode=0)): + result = bot.ensure_tmux_session() + assert result is True + assert bot.session_name == "live-session" + assert bot._using_shared_session is True + + def test_presence_fallback_to_shared_session(self, tmp_path, _patch_base_bot_deps): + """When presence pointer is empty, falls back to shared_session config.""" + workdir = tmp_path / "workdir" + workdir.mkdir(exist_ok=True) + _write_presence(tmp_path, {}) + with patch("apps.handlers.base_bot.PENDING_DIR", tmp_path): + bot = BaseBot( + bot_id="fb_test", + bot_token="t", + work_dir=workdir, + branch_name="devpulse", + shared_session="explicit-session", + ) + bot.send_message = MagicMock() + with patch("subprocess.run", return_value=MagicMock(returncode=0)): + result = bot.ensure_tmux_session() + assert result is True + assert bot.session_name == "explicit-session" + + def test_no_session_returns_false_never_spawns(self, tmp_path, _patch_base_bot_deps): + """When no presence and no shared session, returns False — never spawns.""" + bot = _make_bot(tmp_path, _patch_base_bot_deps) + with patch("subprocess.run", return_value=MagicMock(returncode=1)): + result = bot.ensure_tmux_session() + assert result is False + assert bot._using_shared_session is False + + def test_presence_rebinds_on_pointer_change(self, tmp_path, _patch_base_bot_deps): + """Bot re-reads presence on each call, rebinding to new session.""" + bot = _make_bot(tmp_path, _patch_base_bot_deps) + my_pid = os.getpid() + + _write_presence( + tmp_path, + { + "devpulse": { + "pid": my_pid, + "work_dir": str(tmp_path / "workdir"), + "attach_handle": "session-v1", + } + }, + ) + with patch("subprocess.run", return_value=MagicMock(returncode=0)): + bot.ensure_tmux_session() + assert bot.session_name == "session-v1" + + _write_presence( + tmp_path, + { + "devpulse": { + "pid": my_pid, + "work_dir": str(tmp_path / "workdir"), + "attach_handle": "session-v2", + } + }, + ) + with patch("subprocess.run", return_value=MagicMock(returncode=0)): + bot.ensure_tmux_session() + assert bot.session_name == "session-v2" + + def test_stale_presence_ignored(self, tmp_path, _patch_base_bot_deps): + """Dead PID in presence pointer is treated as absent.""" + bot = _make_bot(tmp_path, _patch_base_bot_deps) + _write_presence( + tmp_path, + { + "devpulse": { + "pid": 99999999, + "work_dir": str(tmp_path / "workdir"), + "attach_handle": "dead-session", + } + }, + ) + with patch("subprocess.run", return_value=MagicMock(returncode=1)): + result = bot.ensure_tmux_session() + assert result is False + + def test_presence_with_tmux_scan_fallback(self, tmp_path, _patch_base_bot_deps): + """When attach_handle is empty, bot finds tmux session by CWD scan.""" + bot = _make_bot(tmp_path, _patch_base_bot_deps) + workdir = str(bot.work_dir) + _write_presence( + tmp_path, + { + "devpulse": { + "pid": os.getpid(), + "work_dir": workdir, + "attach_handle": "", + } + }, + ) + + def _side_effect(cmd, **kwargs): + mock = MagicMock() + if "list-panes" in cmd: + mock.returncode = 0 + mock.stdout = f"found-session:{workdir}\n" + else: + mock.returncode = 1 + return mock + + with patch("subprocess.run", side_effect=_side_effect): + result = bot.ensure_tmux_session() + assert result is True + assert bot.session_name == "found-session" + + +# ============================================= +# 5. handle_message — no-session error +# ============================================= + + +class TestHandleMessageNoSession: + """Error message when no live session is available.""" + + def test_shows_branch_name_in_error(self, tmp_path, _patch_base_bot_deps): + """Error message includes the branch name.""" + bot = _make_bot(tmp_path, _patch_base_bot_deps, branch_name="api") + with patch("subprocess.run", return_value=MagicMock(returncode=1)): + bot.handle_message(42, "hello", {"message_id": 1}) + msg = bot.send_message.call_args[0][1] + assert "No live Claude session" in msg + assert "api" in msg + + def test_shows_work_dir_name_when_no_branch_name(self, tmp_path, _patch_base_bot_deps): + """Falls back to work_dir name in error when branch_name is None.""" + bot = _make_bot(tmp_path, _patch_base_bot_deps) + bot.branch_name = None + with patch("subprocess.run", return_value=MagicMock(returncode=1)): + bot.handle_message(42, "hello", {"message_id": 1}) + msg = bot.send_message.call_args[0][1] + assert "No live Claude session" in msg diff --git a/src/aipass/skills/.aipass/skills/telegram/tests/test_response_router.py b/src/aipass/skills/lib/telegram/tests/test_response_router.py similarity index 100% rename from src/aipass/skills/.aipass/skills/telegram/tests/test_response_router.py rename to src/aipass/skills/lib/telegram/tests/test_response_router.py diff --git a/src/aipass/skills/lib/telegram/tests/test_scheduler_bot.py b/src/aipass/skills/lib/telegram/tests/test_scheduler_bot.py new file mode 100644 index 00000000..8fb722d2 --- /dev/null +++ b/src/aipass/skills/lib/telegram/tests/test_scheduler_bot.py @@ -0,0 +1,395 @@ +""" +Tests for SchedulerBot — dedicated Telegram bot for daemon job queue (TDPLAN-0008 P2). + +Tests cover: + - /queue parses drone @daemon queue --json and formats readable output + - Free-text (non-command) does NOT launch tmux/Claude + - Hourly digest thread posts a digest (mockable clock/sender) + - >4096-char digest is chunked correctly via chunk_text + - Bot loads telegram/scheduler config; missing secret fails loud + - Slash-menu includes /queue + - Command routing (/queue dispatched correctly) +""" + +import json +import pytest +from unittest.mock import patch, MagicMock + +from apps.handlers.scheduler_bot import SchedulerBot, QUEUE_CMD # type: ignore[import-not-found] + + +# ============================================= +# HELPERS +# ============================================= + + +SAMPLE_QUEUE = { + "generated_at": "2026-06-25T15:00:00Z", + "count": 2, + "jobs": [ + { + "owner": "@api", + "id": "data-check", + "enabled": True, + "type": "once", + "schedule_human": "2026-07-02", + "next_run": "2026-07-02T09:00:00Z", + "last_run": None, + "last_status": "success", + "last_error": None, + "prompt_preview": "Check the live data and report...", + "wake": {"fresh": True, "model": "haiku"}, + }, + { + "owner": "@backup", + "id": "weekly-snap", + "enabled": False, + "type": "interval", + "schedule_human": "every 7d", + "next_run": "2026-07-01T00:00:00Z", + "last_run": "2026-06-24T00:00:00Z", + "last_status": "failed", + "last_error": "disk full", + "prompt_preview": "Run full backup...", + "wake": {"fresh": True}, + }, + ], +} + +EMPTY_QUEUE = {"generated_at": "2026-06-25T15:00:00Z", "count": 0, "jobs": []} + + +@pytest.fixture +def _patch_base_bot_deps(tmp_path): + """Patch heavy BaseBot dependencies to allow lightweight instantiation.""" + patches = [ + patch("apps.handlers.base_bot.PENDING_DIR", tmp_path), + patch("apps.handlers.base_bot.signal.signal"), + patch("apps.handlers.base_bot.atexit.register"), + ] + for p in patches: + p.start() + yield + for p in patches: + p.stop() + + +def _make_scheduler_bot(tmp_path, _patch_base_bot_deps): + workdir = tmp_path / "workdir" + workdir.mkdir() + bot = SchedulerBot( + bot_id="scheduler", + bot_token="123:FAKETOKEN", + work_dir=workdir, + bot_name="AIPass Scheduler Bot", + allowed_user_ids=[111], + branch_name=None, + ) + return bot + + +# ============================================= +# 1. /queue PARSES AND FORMATS +# ============================================= + + +class TestQueueCommand: + """/queue fetches queue --json, formats, and sends.""" + + def test_queue_formats_jobs(self): + text = SchedulerBot._format_queue(SAMPLE_QUEUE) + assert "@api/data-check" in text + assert "@backup/weekly-snap" in text + assert "[disabled]" in text + assert "once" in text + assert "success" in text + + def test_queue_shows_error_when_failed(self): + text = SchedulerBot._format_queue(SAMPLE_QUEUE) + assert "disk full" in text + + def test_queue_empty_shows_no_jobs(self): + text = SchedulerBot._format_queue(EMPTY_QUEUE) + assert "No scheduled jobs" in text + + def test_queue_shows_count(self): + text = SchedulerBot._format_queue(SAMPLE_QUEUE) + assert "2" in text + + def test_queue_shows_status_icons(self): + text = SchedulerBot._format_queue(SAMPLE_QUEUE) + assert "✅" in text # success + assert "❌" in text # failed + + def test_queue_command_sends_formatted(self, tmp_path, _patch_base_bot_deps): + bot = _make_scheduler_bot(tmp_path, _patch_base_bot_deps) + with ( + patch.object(bot, "_fetch_queue", return_value=SAMPLE_QUEUE), + patch.object(bot, "send_message") as mock_send, + ): + bot._handle_queue_command(42) + mock_send.assert_called_once() + msg = mock_send.call_args[0][1] + assert "@api/data-check" in msg + + def test_queue_command_handles_fetch_failure(self, tmp_path, _patch_base_bot_deps): + bot = _make_scheduler_bot(tmp_path, _patch_base_bot_deps) + with ( + patch.object(bot, "_fetch_queue", return_value=None), + patch.object(bot, "send_message") as mock_send, + ): + bot._handle_queue_command(42) + msg = mock_send.call_args[0][1] + assert "Failed" in msg + + def test_fetch_queue_parses_subprocess(self, tmp_path, _patch_base_bot_deps): + bot = _make_scheduler_bot(tmp_path, _patch_base_bot_deps) + mock_result = MagicMock() + mock_result.returncode = 0 + mock_result.stdout = json.dumps(SAMPLE_QUEUE) + + with patch("apps.handlers.scheduler_bot.subprocess.run", return_value=mock_result): + data = bot._fetch_queue() + + assert data is not None + assert data["count"] == 2 + assert len(data["jobs"]) == 2 + + def test_fetch_queue_returns_none_on_failure(self, tmp_path, _patch_base_bot_deps): + bot = _make_scheduler_bot(tmp_path, _patch_base_bot_deps) + mock_result = MagicMock() + mock_result.returncode = 1 + mock_result.stderr = "error" + + with patch("apps.handlers.scheduler_bot.subprocess.run", return_value=mock_result): + data = bot._fetch_queue() + + assert data is None + + def test_fetch_queue_returns_none_on_timeout(self, tmp_path, _patch_base_bot_deps): + bot = _make_scheduler_bot(tmp_path, _patch_base_bot_deps) + import subprocess as sp + + with patch("apps.handlers.scheduler_bot.subprocess.run", side_effect=sp.TimeoutExpired(QUEUE_CMD, 15)): + data = bot._fetch_queue() + + assert data is None + + +# ============================================= +# 2. FREE-TEXT DOES NOT LAUNCH TMUX +# ============================================= + + +class TestNoTmux: + """Free-text and file messages do NOT spin up tmux/Claude.""" + + def test_free_text_rejected(self, tmp_path, _patch_base_bot_deps): + bot = _make_scheduler_bot(tmp_path, _patch_base_bot_deps) + with ( + patch.object(bot, "send_message") as mock_send, + patch.object(bot, "ensure_tmux_session") as mock_tmux, + ): + bot.handle_message(42, "Hello there", {"message_id": 1}) + mock_tmux.assert_not_called() + msg = mock_send.call_args[0][1] + assert "commands" in msg.lower() or "/queue" in msg + + def test_file_rejected(self, tmp_path, _patch_base_bot_deps): + bot = _make_scheduler_bot(tmp_path, _patch_base_bot_deps) + with ( + patch.object(bot, "send_message") as mock_send, + patch("apps.handlers.base_bot.BaseBot.handle_file") as mock_parent_file, + ): + bot.handle_file(42, {"message_id": 1, "document": {"file_id": "abc"}}) + mock_parent_file.assert_not_called() + msg = mock_send.call_args[0][1] + assert "file" in msg.lower() or "/queue" in msg + + +# ============================================= +# 3. HOURLY DIGEST +# ============================================= + + +class TestDigest: + """Hourly digest thread posts queue digest.""" + + def test_digest_posts_message(self, tmp_path, _patch_base_bot_deps): + bot = _make_scheduler_bot(tmp_path, _patch_base_bot_deps) + bot._scheduler_chat_id = 42 + + with ( + patch.object(bot, "_fetch_queue", return_value=SAMPLE_QUEUE), + patch.object(bot, "send_message") as mock_send, + ): + bot._post_digest() + mock_send.assert_called_once() + msg = mock_send.call_args[0][1] + assert "Hourly Queue Digest" in msg + assert "@api/data-check" in msg + + def test_digest_skips_on_fetch_failure(self, tmp_path, _patch_base_bot_deps): + bot = _make_scheduler_bot(tmp_path, _patch_base_bot_deps) + bot._scheduler_chat_id = 42 + + with ( + patch.object(bot, "_fetch_queue", return_value=None), + patch.object(bot, "send_message") as mock_send, + ): + bot._post_digest() + mock_send.assert_not_called() + + def test_digest_skips_when_no_chat_id(self, tmp_path, _patch_base_bot_deps): + bot = _make_scheduler_bot(tmp_path, _patch_base_bot_deps) + assert bot._scheduler_chat_id is None + + with patch.object(bot, "send_message") as mock_send: + bot._post_digest() + mock_send.assert_not_called() + + def test_start_digest_creates_thread(self, tmp_path, _patch_base_bot_deps): + bot = _make_scheduler_bot(tmp_path, _patch_base_bot_deps) + bot.start_digest(42) + try: + assert bot._digest_thread is not None + assert bot._digest_thread.daemon is True + assert bot._digest_thread.name == "scheduler-digest" + assert bot._scheduler_chat_id == 42 + finally: + bot.stop_digest() + + def test_stop_digest_cleans_up(self, tmp_path, _patch_base_bot_deps): + bot = _make_scheduler_bot(tmp_path, _patch_base_bot_deps) + bot.start_digest(42) + bot.stop_digest() + assert bot._digest_thread is None + assert bot._digest_stop.is_set() + + +# ============================================= +# 4. CHUNKING LONG MESSAGES +# ============================================= + + +class TestChunking: + """>4096-char messages are split via chunk_text.""" + + def test_long_queue_is_chunked(self, tmp_path, _patch_base_bot_deps): + bot = _make_scheduler_bot(tmp_path, _patch_base_bot_deps) + many_jobs = { + "generated_at": "now", + "count": 100, + "jobs": [ + { + "owner": f"@branch{i}", + "id": f"job-{i}", + "enabled": True, + "type": "daily", + "schedule_human": "every day", + "next_run": "2026-07-01T00:00:00Z", + "last_run": None, + "last_status": None, + "last_error": None, + "prompt_preview": "A" * 80, + "wake": {}, + } + for i in range(100) + ], + } + + with ( + patch.object(bot, "_fetch_queue", return_value=many_jobs), + patch.object(bot, "send_message") as mock_send, + ): + bot._handle_queue_command(42) + assert mock_send.call_count >= 2 + + def test_short_queue_not_chunked(self, tmp_path, _patch_base_bot_deps): + bot = _make_scheduler_bot(tmp_path, _patch_base_bot_deps) + with ( + patch.object(bot, "_fetch_queue", return_value=SAMPLE_QUEUE), + patch.object(bot, "send_message") as mock_send, + ): + bot._handle_queue_command(42) + assert mock_send.call_count == 1 + + +# ============================================= +# 5. SECRET LOADING +# ============================================= + + +class TestSecretLoading: + """Bot loads telegram/scheduler config; missing fails loud.""" + + def test_missing_secret_returns_none(self): + """When get_secret returns None, load_bot_config returns None — bot won't start.""" + from apps.handlers.config import load_bot_config # type: ignore[import-not-found] + + with patch("apps.handlers.config._get_secret", return_value=None): + config = load_bot_config("scheduler") + assert config is None + + def test_secret_provides_chat_id(self): + """The scheduler config includes chat_id for digest delivery.""" + config = { + "bot_id": "scheduler", + "bot_token": "123:FAKE", + "bot_name": "AIPass Scheduler Bot", + "branch_name": "daemon", + "work_dir": "/tmp", + "allowed_user_ids": [111], + "chat_id": "7235222625", + } + assert "chat_id" in config + assert config["chat_id"] == "7235222625" + + +# ============================================= +# 6. SLASH-MENU INCLUDES /queue +# ============================================= + + +class TestSlashMenu: + """/queue appears in get_custom_commands and the command menu.""" + + def test_queue_in_custom_commands(self, tmp_path, _patch_base_bot_deps): + bot = _make_scheduler_bot(tmp_path, _patch_base_bot_deps) + cmds = bot.get_custom_commands() + assert "queue" in cmds + assert "description" in cmds["queue"] + + def test_inherited_commands_preserved(self, tmp_path, _patch_base_bot_deps): + bot = _make_scheduler_bot(tmp_path, _patch_base_bot_deps) + cmds = bot.get_custom_commands() + assert "monitor" in cmds + assert "create" in cmds + + +# ============================================= +# 7. COMMAND ROUTING +# ============================================= + + +class TestCommandRouting: + """/queue is dispatched correctly through _dispatch_command.""" + + def test_queue_routed(self, tmp_path, _patch_base_bot_deps): + bot = _make_scheduler_bot(tmp_path, _patch_base_bot_deps) + with patch.object(bot, "_handle_queue_command") as mock_q: + result = bot._dispatch_command(42, ("queue", "")) + assert result is True + mock_q.assert_called_once_with(42) + + def test_other_commands_fall_through_to_parent(self, tmp_path, _patch_base_bot_deps): + bot = _make_scheduler_bot(tmp_path, _patch_base_bot_deps) + with patch.object(bot, "send_message"): + result = bot._dispatch_command(42, ("status", "")) + assert result is True + + def test_unknown_command_falls_through(self, tmp_path, _patch_base_bot_deps): + bot = _make_scheduler_bot(tmp_path, _patch_base_bot_deps) + with patch.object(bot, "send_message"): + result = bot._dispatch_command(42, ("nonexistent", "")) + assert result is False diff --git a/src/aipass/skills/lib/telegram/tests/test_status_reset.py b/src/aipass/skills/lib/telegram/tests/test_status_reset.py new file mode 100644 index 00000000..879fba69 --- /dev/null +++ b/src/aipass/skills/lib/telegram/tests/test_status_reset.py @@ -0,0 +1,234 @@ +# =================== AIPass ==================== +# Name: test_status_reset.py +# Description: Tests for /status conversation vs daemon uptime + /new counter reset +# Version: 1.0.0 +# Created: 2026-06-29 +# Modified: 2026-06-29 +# ============================================= + +""" +Tests for /status conversation vs daemon uptime + /new counter reset. + +Tests cover: + - /new resets message_count to 0 + - /new resets conversation_start + - /status shows conversation uptime (not daemon uptime) as primary + - /status shows daemon uptime separately + - build_status_text includes daemon_uptime line when provided + - build_status_text omits daemon_uptime line when not provided +""" + +import time +import pytest +from unittest.mock import patch + +from apps.handlers.base_bot import BaseBot # type: ignore[import-not-found] +from apps.handlers.telegram_standards import build_status_text # type: ignore[import-not-found] + + +@pytest.fixture +def _patch_base_bot_deps(tmp_path): + patches = [ + patch("apps.handlers.base_bot.PENDING_DIR", tmp_path), + patch("apps.handlers.base_bot.signal.signal"), + patch("apps.handlers.base_bot.atexit.register"), + ] + for p in patches: + p.start() + yield + for p in patches: + p.stop() + + +def _make_bot(tmp_path, _patch_base_bot_deps): + workdir = tmp_path / "workdir" + workdir.mkdir() + return BaseBot( + bot_id="status_test", + bot_token="123:FAKETOKEN", + work_dir=workdir, + bot_name="Status Test Bot", + allowed_user_ids=[111], + branch_name=None, + ) + + +# ============================================= +# 1. /new RESETS COUNTERS +# ============================================= + + +class TestNewResetsCounters: + """/new resets message_count and conversation_start.""" + + def test_new_resets_message_count(self, tmp_path, _patch_base_bot_deps): + bot = _make_bot(tmp_path, _patch_base_bot_deps) + bot.state["message_count"] = 15 + + with ( + patch.object(bot, "send_message"), + patch.object(bot, "_kill_tmux_session"), + ): + bot._dispatch_command(42, ("new", "")) + + assert bot.state["message_count"] == 0 + + def test_new_resets_conversation_start(self, tmp_path, _patch_base_bot_deps): + bot = _make_bot(tmp_path, _patch_base_bot_deps) + bot.state["conversation_start"] = time.time() - 3600 # 1 hour ago + + with ( + patch.object(bot, "send_message"), + patch.object(bot, "_kill_tmux_session"), + ): + before = time.time() + bot._dispatch_command(42, ("new", "")) + after = time.time() + + assert before <= bot.state["conversation_start"] <= after + + def test_new_does_not_reset_daemon_start(self, tmp_path, _patch_base_bot_deps): + bot = _make_bot(tmp_path, _patch_base_bot_deps) + original_start = bot.state["start_time"] + bot.state["message_count"] = 5 + + with ( + patch.object(bot, "send_message"), + patch.object(bot, "_kill_tmux_session"), + ): + bot._dispatch_command(42, ("new", "")) + + assert bot.state["start_time"] == original_start + + +# ============================================= +# 2. /status SHOWS CORRECT UPTIMES +# ============================================= + + +class TestStatusUptimes: + """/status shows conversation uptime as primary and daemon uptime separately.""" + + def test_status_passes_daemon_uptime(self, tmp_path, _patch_base_bot_deps): + bot = _make_bot(tmp_path, _patch_base_bot_deps) + bot.state["start_time"] = time.time() - 7200 # daemon up 2h + bot.state["conversation_start"] = time.time() - 300 # conv 5m + + with ( + patch.object(bot, "send_message"), + patch("apps.handlers.base_bot.build_status_text", wraps=build_status_text) as mock_build, + patch("apps.handlers.telegram_standards._tmux_session_exists", return_value=True), + ): + bot._dispatch_command(42, ("status", "")) + + call_kwargs = mock_build.call_args + args = call_kwargs[1] if call_kwargs[1] else {} + if not args: + _, kwargs = mock_build.call_args + args = kwargs + + assert "daemon_uptime" in args + assert "2h" in args["daemon_uptime"] + + def test_status_conversation_uptime_after_new(self, tmp_path, _patch_base_bot_deps): + bot = _make_bot(tmp_path, _patch_base_bot_deps) + bot.state["start_time"] = time.time() - 7200 # daemon up 2h + + with ( + patch.object(bot, "send_message"), + patch.object(bot, "_kill_tmux_session"), + ): + bot._dispatch_command(42, ("new", "")) + + # Now check /status — conversation uptime should be near 0 + with ( + patch.object(bot, "send_message") as mock_send, + patch("apps.handlers.telegram_standards._tmux_session_exists", return_value=True), + ): + bot._dispatch_command(42, ("status", "")) + + msg = mock_send.call_args[0][1] + assert "Uptime: 0h 0m" in msg + assert "Daemon up: 2h" in msg + + def test_status_messages_zero_after_new(self, tmp_path, _patch_base_bot_deps): + bot = _make_bot(tmp_path, _patch_base_bot_deps) + bot.state["message_count"] = 42 + + with ( + patch.object(bot, "send_message"), + patch.object(bot, "_kill_tmux_session"), + ): + bot._dispatch_command(42, ("new", "")) + + with ( + patch.object(bot, "send_message") as mock_send, + patch("apps.handlers.telegram_standards._tmux_session_exists", return_value=True), + ): + bot._dispatch_command(42, ("status", "")) + + msg = mock_send.call_args[0][1] + assert "Messages: 0" in msg + + +# ============================================= +# 3. build_status_text +# ============================================= + + +class TestBuildStatusText: + """build_status_text renders daemon_uptime when provided.""" + + def test_includes_daemon_uptime(self): + with patch("apps.handlers.telegram_standards._tmux_session_exists", return_value=True): + text = build_status_text( + session_name="telegram-base", + branch_name="base", + uptime="0h 5m 0s", + message_count=3, + daemon_uptime="12h 0m 0s", + ) + assert "Daemon up: 12h 0m 0s" in text + assert "Uptime: 0h 5m 0s" in text + + def test_omits_daemon_uptime_when_none(self): + with patch("apps.handlers.telegram_standards._tmux_session_exists", return_value=True): + text = build_status_text( + session_name="telegram-base", + branch_name="base", + uptime="1h 0m 0s", + message_count=5, + ) + assert "Daemon up" not in text + assert "Uptime: 1h 0m 0s" in text + + def test_uptime_before_daemon_uptime(self): + with patch("apps.handlers.telegram_standards._tmux_session_exists", return_value=True): + text = build_status_text( + session_name="telegram-base", + branch_name="base", + uptime="0h 1m 0s", + daemon_uptime="5h 0m 0s", + ) + uptime_pos = text.index("Uptime:") + daemon_pos = text.index("Daemon up:") + assert uptime_pos < daemon_pos + + +# ============================================= +# 4. CONVERSATION_START IN STATE +# ============================================= + + +class TestConversationStartState: + """conversation_start is initialized and tracked.""" + + def test_init_sets_conversation_start(self, tmp_path, _patch_base_bot_deps): + before = time.time() + bot = _make_bot(tmp_path, _patch_base_bot_deps) + after = time.time() + assert before <= bot.state["conversation_start"] <= after + + def test_conversation_start_equals_start_time_at_boot(self, tmp_path, _patch_base_bot_deps): + bot = _make_bot(tmp_path, _patch_base_bot_deps) + assert abs(bot.state["conversation_start"] - bot.state["start_time"]) < 0.1 diff --git a/src/aipass/skills/templates/full/apps/__init__.py b/src/aipass/skills/templates/full/apps/__init__.py index 39ea0f03..a46acb3a 100644 --- a/src/aipass/skills/templates/full/apps/__init__.py +++ b/src/aipass/skills/templates/full/apps/__init__.py @@ -3,5 +3,5 @@ # Name: __init__.py - {{SKILL_NAME}} apps package # Date: 2026-03-07 # Version: 1.0.0 -# Category: skills/catalog/{{SKILL_NAME}}/apps +# Category: skills/{{SKILL_NAME}}/apps # ============================================= diff --git a/src/aipass/skills/templates/full/apps/handlers/__init__.py b/src/aipass/skills/templates/full/apps/handlers/__init__.py index 00bdc3c8..4da904bc 100644 --- a/src/aipass/skills/templates/full/apps/handlers/__init__.py +++ b/src/aipass/skills/templates/full/apps/handlers/__init__.py @@ -3,7 +3,7 @@ # Name: __init__.py - {{SKILL_NAME}} handlers package # Date: 2026-03-07 # Version: 1.0.0 -# Category: skills/catalog/{{SKILL_NAME}}/apps/handlers +# Category: skills/{{SKILL_NAME}}/apps/handlers # # CHANGELOG (Max 5 entries): # - v1.0.0 (2026-03-07): Initial scaffold diff --git a/src/aipass/skills/templates/full/apps/modules/__init__.py b/src/aipass/skills/templates/full/apps/modules/__init__.py index 37ce0633..9363b004 100644 --- a/src/aipass/skills/templates/full/apps/modules/__init__.py +++ b/src/aipass/skills/templates/full/apps/modules/__init__.py @@ -3,7 +3,7 @@ # Name: __init__.py - {{SKILL_NAME}} modules package # Date: 2026-03-07 # Version: 1.0.0 -# Category: skills/catalog/{{SKILL_NAME}}/apps/modules +# Category: skills/{{SKILL_NAME}}/apps/modules # # CHANGELOG (Max 5 entries): # - v1.0.0 (2026-03-07): Initial scaffold diff --git a/src/aipass/skills/tests/test_discovery.py b/src/aipass/skills/tests/test_discovery.py index 91acf4af..1f357224 100644 --- a/src/aipass/skills/tests/test_discovery.py +++ b/src/aipass/skills/tests/test_discovery.py @@ -142,7 +142,7 @@ def test_string(self): class TestDiscoverSkillsInPath: def test_finds_catalog_skills(self): - catalog_path = Path(__file__).resolve().parent.parent / "catalog" + catalog_path = Path(__file__).resolve().parent.parent / "lib" skills = discover_skills_in_path(catalog_path, "builtin") names = {s["name"] for s in skills} assert "github" in names @@ -159,7 +159,7 @@ def test_empty_dir(self): assert skills == [] def test_skill_dict_structure(self): - catalog_path = Path(__file__).resolve().parent.parent / "catalog" + catalog_path = Path(__file__).resolve().parent.parent / "lib" skills = discover_skills_in_path(catalog_path, "builtin") for skill in skills: assert "name" in skill @@ -170,7 +170,7 @@ def test_skill_dict_structure(self): assert "tags" in skill def test_has_handler_flag(self): - catalog_path = Path(__file__).resolve().parent.parent / "catalog" + catalog_path = Path(__file__).resolve().parent.parent / "lib" skills = discover_skills_in_path(catalog_path, "builtin") skill_map = {s["name"]: s for s in skills} assert skill_map["github"]["has_handler"] is False diff --git a/src/aipass/skills/tests/test_lifecycle.py b/src/aipass/skills/tests/test_lifecycle.py index 7c85cfe9..423ed212 100644 --- a/src/aipass/skills/tests/test_lifecycle.py +++ b/src/aipass/skills/tests/test_lifecycle.py @@ -106,7 +106,7 @@ class TestCatalogSkillsLifecycle: def test_github_skill_full_cycle(self): """GitHub (Tier 1): discover -> load -> run returns instructions.""" - catalog = Path(__file__).resolve().parent.parent / "catalog" + catalog = Path(__file__).resolve().parent.parent / "lib" skills = discover_skills_in_path(catalog, "builtin") github = [s for s in skills if s["name"] == "github"] assert len(github) == 1 diff --git a/src/aipass/spawn/.seedgo/bypass.json b/src/aipass/spawn/.seedgo/bypass.json index 8858505f..1447acb8 100644 --- a/src/aipass/spawn/.seedgo/bypass.json +++ b/src/aipass/spawn/.seedgo/bypass.json @@ -231,6 +231,11 @@ "file": "apps/handlers/meta_ops.py", "standard": "unused_function", "reason": "load_branch_meta() no longer called by P1 engine but has tests and may be reused by sync-registry or P2 shared library." + }, + { + "file": "apps/modules/core.py", + "standard": "encapsulation", + "reason": "Local import of render_all_meta_tabs from memory.apps.handlers.tracking.tab_renderer — @memory has no module entry point for this API. Cross-branch contract per FPLAN-0286." } ], "notes": { diff --git a/src/aipass/spawn/apps/handlers/placeholders.py b/src/aipass/spawn/apps/handlers/placeholders.py index 5afa4ecf..7c726d9e 100644 --- a/src/aipass/spawn/apps/handlers/placeholders.py +++ b/src/aipass/spawn/apps/handlers/placeholders.py @@ -71,6 +71,10 @@ def build_replacements_dict(target_dir, branch_name, **overrides): "PROVIDES_TO": "", } + meta_tabs = overrides.get("meta_tabs") + if meta_tabs: + replacements.update(meta_tabs) + return replacements diff --git a/src/aipass/spawn/apps/modules/core.py b/src/aipass/spawn/apps/modules/core.py index 07e456a3..9ead8266 100644 --- a/src/aipass/spawn/apps/modules/core.py +++ b/src/aipass/spawn/apps/modules/core.py @@ -59,6 +59,19 @@ # Default template location (relative to spawn package root) DEFAULT_TEMPLATE = Path(__file__).parents[2] / "templates" / "builder" +_META_TAB_KEYS = {"TODOS_META", "KEY_LEARNINGS_META", "SESSIONS_META", "OBSERVATIONS_META"} + + +def _load_meta_tabs(): + """Load memory meta-tab values from @memory's renderer. Raises on failure.""" + from aipass.memory.apps.handlers.tracking.tab_renderer import render_all_meta_tabs + + tabs = render_all_meta_tabs() + missing = _META_TAB_KEYS - set(tabs or {}) + if missing: + raise RuntimeError(f"render_all_meta_tabs() missing keys: {sorted(missing)}") + return tabs + def print_introspection(): """Display module introspection info.""" @@ -219,6 +232,7 @@ def _spawn_agent( citizen_number = get_next_citizen_number(reg_path) # Build placeholder replacements + meta_tabs = _load_meta_tabs() replacements = build_replacements_dict( target, folder_name, @@ -227,6 +241,7 @@ def _spawn_agent( purpose=purpose or "New agent - purpose TBD", profile=detected_profile, citizen_number=citizen_number, + meta_tabs=meta_tabs, ) # Step 1: Copy template with placeholder replacement in content @@ -312,8 +327,6 @@ def _adopt_existing(target, purpose, profile, registry_path): Returns: Result dict matching _spawn_agent return format. """ - import json as _json - folder_name = get_branch_name(target) branch_upper = normalize_branch_name(folder_name, "upper") branch_lower = normalize_branch_name(folder_name, "lower") @@ -324,12 +337,9 @@ def _adopt_existing(target, purpose, profile, registry_path): # Read purpose from passport if not provided if not purpose: passport_path = target / ".trinity" / "passport.json" - try: - passport = _json.loads(passport_path.read_text(encoding="utf-8")) - purpose = passport.get("identity", {}).get("purpose", "Adopted agent") - except (ValueError, OSError) as e: - logger.warning("Failed to read passport for purpose: %s", e) - purpose = "Adopted agent" + # read_json returns None on failure (and logs) — same pattern as line ~250. + passport = json_handler.read_json(passport_path) + purpose = (passport or {}).get("identity", {}).get("purpose", "Adopted agent") # Fix registry_id in passport if it doesn't match the current registry fix_passport_registry_id(target, reg_path) diff --git a/src/aipass/spawn/templates/birthright/.trinity/local.json b/src/aipass/spawn/templates/birthright/.trinity/local.json index 2613c10a..9fa49282 100644 --- a/src/aipass/spawn/templates/birthright/.trinity/local.json +++ b/src/aipass/spawn/templates/birthright/.trinity/local.json @@ -12,14 +12,17 @@ "work_log", "{{BRANCHNAME}}" ], - "_usage": "Automated file — add entries within your sections; rollover trims automatically. Limits live in @memory's memory.config.json.", + "_usage": "Automated file — add entries within your sections, newest on top. Rollover auto-archives sessions/key_learnings (+ observations.json) to @memory; todos[] are OPERATIONAL and NEVER rolled — prune done ones by hand at /prep. Limits live in @memory's memory.config.json.", "status": { "health": "healthy", "last_health_check": "{{DATE}}" } }, - "key_learnings": [], + "todos_meta": "{{TODOS_META}}", "todos": [], + "key_learnings_meta": "{{KEY_LEARNINGS_META}}", + "key_learnings": [], + "sessions_meta": "{{SESSIONS_META}}", "sessions": [ { "number": 1, diff --git a/src/aipass/spawn/templates/birthright/.trinity/observations.json b/src/aipass/spawn/templates/birthright/.trinity/observations.json index 8184b599..1f3938e0 100644 --- a/src/aipass/spawn/templates/birthright/.trinity/observations.json +++ b/src/aipass/spawn/templates/birthright/.trinity/observations.json @@ -12,7 +12,7 @@ "patterns", "{{BRANCHNAME}}" ], - "_usage": "Automated file — add entries within your sections; rollover trims automatically. Limits live in @memory's memory.config.json.", + "_usage": "Automated file — add entries within your sections, newest on top. Rollover auto-archives sessions/key_learnings (+ observations.json) to @memory; todos[] are OPERATIONAL and NEVER rolled — prune done ones by hand at /prep. Limits live in @memory's memory.config.json.", "status": { "health": "healthy", "last_health_check": "{{DATE}}" @@ -22,6 +22,7 @@ "purpose": "Capture collaboration patterns and experiential insights over time", "chronological_order": "Newest entries at TOP, oldest at BOTTOM - NEVER reorder" }, + "observations_meta": "{{OBSERVATIONS_META}}", "observations": [ { "number": 1, diff --git a/src/aipass/spawn/templates/builder/.spawn/.template_registry.json b/src/aipass/spawn/templates/builder/.spawn/.template_registry.json index 7b52f18e..b7943034 100644 --- a/src/aipass/spawn/templates/builder/.spawn/.template_registry.json +++ b/src/aipass/spawn/templates/builder/.spawn/.template_registry.json @@ -184,13 +184,13 @@ "path": ".trinity/README.md" }, "f012": { - "content_hash": "8f8a98e42d92", + "content_hash": "a68a905541ee", "has_branch_placeholder": false, "name": "local.json", "path": ".trinity/local.json" }, "f013": { - "content_hash": "62160dfa243c", + "content_hash": "e51a865855a6", "has_branch_placeholder": false, "name": "observations.json", "path": ".trinity/observations.json" @@ -384,7 +384,7 @@ }, "metadata": { "description": "Template file tracking registry for ID-based updates", - "last_updated": "2026-06-11", + "last_updated": "2026-06-25", "version": "1.0.0" } } diff --git a/src/aipass/spawn/templates/builder/.trinity/local.json b/src/aipass/spawn/templates/builder/.trinity/local.json index 2613c10a..9fa49282 100644 --- a/src/aipass/spawn/templates/builder/.trinity/local.json +++ b/src/aipass/spawn/templates/builder/.trinity/local.json @@ -12,14 +12,17 @@ "work_log", "{{BRANCHNAME}}" ], - "_usage": "Automated file — add entries within your sections; rollover trims automatically. Limits live in @memory's memory.config.json.", + "_usage": "Automated file — add entries within your sections, newest on top. Rollover auto-archives sessions/key_learnings (+ observations.json) to @memory; todos[] are OPERATIONAL and NEVER rolled — prune done ones by hand at /prep. Limits live in @memory's memory.config.json.", "status": { "health": "healthy", "last_health_check": "{{DATE}}" } }, - "key_learnings": [], + "todos_meta": "{{TODOS_META}}", "todos": [], + "key_learnings_meta": "{{KEY_LEARNINGS_META}}", + "key_learnings": [], + "sessions_meta": "{{SESSIONS_META}}", "sessions": [ { "number": 1, diff --git a/src/aipass/spawn/templates/builder/.trinity/observations.json b/src/aipass/spawn/templates/builder/.trinity/observations.json index 8184b599..1f3938e0 100644 --- a/src/aipass/spawn/templates/builder/.trinity/observations.json +++ b/src/aipass/spawn/templates/builder/.trinity/observations.json @@ -12,7 +12,7 @@ "patterns", "{{BRANCHNAME}}" ], - "_usage": "Automated file — add entries within your sections; rollover trims automatically. Limits live in @memory's memory.config.json.", + "_usage": "Automated file — add entries within your sections, newest on top. Rollover auto-archives sessions/key_learnings (+ observations.json) to @memory; todos[] are OPERATIONAL and NEVER rolled — prune done ones by hand at /prep. Limits live in @memory's memory.config.json.", "status": { "health": "healthy", "last_health_check": "{{DATE}}" @@ -22,6 +22,7 @@ "purpose": "Capture collaboration patterns and experiential insights over time", "chronological_order": "Newest entries at TOP, oldest at BOTTOM - NEVER reorder" }, + "observations_meta": "{{OBSERVATIONS_META}}", "observations": [ { "number": 1, diff --git a/src/aipass/trigger/apps/handlers/events/bulletin_created.py b/src/aipass/trigger/apps/handlers/events/bulletin_created.py deleted file mode 100644 index e700fbb8..00000000 --- a/src/aipass/trigger/apps/handlers/events/bulletin_created.py +++ /dev/null @@ -1,246 +0,0 @@ -# =================== AIPass ==================== -# Name: bulletin_created.py -# Description: Bulletin created event handler for dashboard propagation -# Version: 1.0.0 -# Created: 2026-01-31 -# Modified: 2026-01-31 -# ============================================= - -""" -Bulletin Created Event Handler - -Handles bulletin_created events fired when a new bulletin is created. -Propagates the bulletin to all branch dashboards. - -Event data expected: - - bulletin_id: ID of the created bulletin - - title: Bulletin title - - message: Bulletin content - - priority: Bulletin priority level - - created_by: Who created it - - timestamp: When created -""" - -import json -from datetime import datetime, timezone -from pathlib import Path -from typing import Any, Dict, List -from aipass.trigger.apps.config import TRIGGER_ROOT, atomic_write_json -from aipass.trigger.apps.handlers.json import json_handler - - -def _find_repo_root() -> Path: - """Walk up from this file to find the repo root (contains AIPASS_REGISTRY.json).""" - current = Path(__file__).resolve().parent - for parent in [current] + list(current.parents): - if (parent / "AIPASS_REGISTRY.json").exists(): - return parent - return Path.cwd() - - -_REPO_ROOT = _find_repo_root() - -# Paths -BRANCH_REGISTRY = _REPO_ROOT / "AIPASS_REGISTRY.json" -BULLETINS_PATH = _REPO_ROOT / "BULLETINS.central.json" - -_HANDLER_LOG = TRIGGER_ROOT / "logs" / "bulletin_handler.log" - - -def _log_warning(message: str) -> None: - """Log warning to file (event handlers cannot import Prax logger - causes recursion).""" - try: - _HANDLER_LOG.parent.mkdir(parents=True, exist_ok=True) - ts = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S") - with open(_HANDLER_LOG, "a", encoding="utf-8") as f: - f.write(f"{ts} | WARNING | {message}\n") - except Exception: - pass - - -def _load_branch_registry() -> List[Dict]: - """ - Load branch registry silently. - - Returns: - List of branch dicts with name, path, status. - Empty list on any error. - """ - try: - if not BRANCH_REGISTRY.exists(): - return [] - data = json.loads(BRANCH_REGISTRY.read_text(encoding="utf-8")) - return data.get("branches", []) - except Exception as exc: - _log_warning(f"load branch registry failed: {exc}") - return [] - - -def _load_bulletins() -> List[Dict]: - """ - Load bulletins from central storage. - - Returns: - List of bulletin dicts. Empty list on error. - """ - try: - if not BULLETINS_PATH.exists(): - return [] - data = json.loads(BULLETINS_PATH.read_text()) - return data.get("bulletins", []) - except Exception as exc: - _log_warning(f"load bulletins failed: {exc}") - return [] - - -def _filter_active_bulletins(bulletins: List[Dict]) -> List[Dict]: - """ - Filter bulletins to only active ones. - - Args: - bulletins: List of all bulletins - - Returns: - List of active bulletins only - """ - return [b for b in bulletins if b.get("active", False)] - - -def _load_dashboard(branch_path: Path) -> Dict: - """ - Load existing dashboard or create minimal structure. - - Args: - branch_path: Path to branch root - - Returns: - Dashboard data dict (existing or minimal structure) - """ - dashboard_path = branch_path / "DASHBOARD.local.json" - - if dashboard_path.exists(): - try: - data = json.loads(dashboard_path.read_text()) - if "sections" not in data: - data["sections"] = {} - if "bulletin_board" not in data["sections"]: - data["sections"]["bulletin_board"] = {"managed_by": "aipass", "active_bulletins": [], "pending_ack": []} - return data - except Exception as exc: - _log_warning(f"parse dashboard JSON for {branch_path}: {exc}") - - # Create minimal dashboard structure - return { - "last_refreshed": datetime.now().strftime("%Y-%m-%d %H:%M:%S"), - "sections": {"bulletin_board": {"managed_by": "aipass", "active_bulletins": [], "pending_ack": []}}, - } - - -def _save_dashboard(branch_path: Path, dashboard: Dict) -> bool: - """ - Save dashboard to branch. - - Args: - branch_path: Path to branch root - dashboard: Dashboard data to save - - Returns: - True if saved, False on error - """ - try: - dashboard_path = branch_path / "DASHBOARD.local.json" - dashboard["last_refreshed"] = datetime.now().strftime("%Y-%m-%d %H:%M:%S") - atomic_write_json(dashboard_path, dashboard) - return True - except Exception as exc: - _log_warning(f"save dashboard to {branch_path}: {exc}") - return False - - -def _propagate_bulletins_to_branches() -> None: - """ - Propagate active bulletins to all branch dashboards. - - Loads active bulletins and updates each branch's dashboard - with the bulletin_board section. - - Silent failure - catches all exceptions. - """ - try: - # Load branch registry - branches = _load_branch_registry() - if not branches: - return - - # Load and filter active bulletins - all_bulletins = _load_bulletins() - active_bulletins = _filter_active_bulletins(all_bulletins) - - # Update each branch dashboard - for branch in branches: - branch_path_str = branch.get("path") - if not branch_path_str: - continue - - branch_path = Path(branch_path_str) - if not branch_path.exists(): - continue - - try: - # Load dashboard - dashboard = _load_dashboard(branch_path) - - # Update bulletin_board section ONLY - if "sections" not in dashboard: - dashboard["sections"] = {} - - dashboard["sections"]["bulletin_board"] = { - "managed_by": "aipass", - "active_bulletins": active_bulletins, - "pending_ack": [], - } - - # Save dashboard - _save_dashboard(branch_path, dashboard) - except Exception as exc: - _log_warning(f"propagate to branch {branch_path_str}: {exc}") - continue - - except Exception as exc: - _log_warning(f"bulletin propagation failed: {exc}") - - -def handle_bulletin_created( - _bulletin_id: str | None = None, - _title: str | None = None, - _message: str | None = None, - _priority: str | None = None, - _created_by: str | None = None, - _timestamp: str | None = None, - **_kwargs: Any, -) -> None: - """ - Handle bulletin_created event - propagate bulletin to all branch dashboards. - - Event parameters are received but not used directly - we reload from - central storage to ensure consistency with any concurrent updates. - - Args: - _bulletin_id: ID of the created bulletin (unused - reload from storage) - _title: Bulletin title (unused - reload from storage) - _message: Bulletin content (unused - reload from storage) - _priority: Bulletin priority level (unused - reload from storage) - _created_by: Who created it (unused - reload from storage) - _timestamp: When created (unused - reload from storage) - **_kwargs: Additional event data (ignored) - """ - try: - # Propagate bulletins to all branches - # We reload from central storage to ensure consistency - # (the newly created bulletin should already be saved there) - _propagate_bulletins_to_branches() - - json_handler.log_operation("bulletin_event", {"success": True}) - - except Exception as exc: - _log_warning(f"handle_bulletin_created failed: {exc}") diff --git a/src/aipass/trigger/apps/handlers/events/registry.py b/src/aipass/trigger/apps/handlers/events/registry.py index d035b06b..81aa4373 100644 --- a/src/aipass/trigger/apps/handlers/events/registry.py +++ b/src/aipass/trigger/apps/handlers/events/registry.py @@ -61,7 +61,6 @@ def _send_email_adapter( except ImportError: _log_warning("ai_mail not available — error notifications won't send") from .warning_logged import handle_warning_logged - from .bulletin_created import handle_bulletin_created from .memory_template_updated import handle_memory_template_updated # from .pr_status_sync import handle_pr_created, handle_pr_merged # TDPLAN-0007: status-sync decommissioned @@ -74,7 +73,6 @@ def _send_email_adapter( trigger.on("plan_file_moved", handle_plan_file_moved) trigger.on("error_detected", handle_error_detected) trigger.on("warning_logged", handle_warning_logged) - trigger.on("bulletin_created", handle_bulletin_created) trigger.on("memory_template_updated", handle_memory_template_updated) # trigger.on("pr_created", handle_pr_created) # TDPLAN-0007: status-sync decommissioned # trigger.on("pr_merged", handle_pr_merged) # TDPLAN-0007: status-sync decommissioned diff --git a/src/aipass/trigger/tests/test_event_handlers.py b/src/aipass/trigger/tests/test_event_handlers.py index 40e92bfd..e9f28c01 100644 --- a/src/aipass/trigger/tests/test_event_handlers.py +++ b/src/aipass/trigger/tests/test_event_handlers.py @@ -6,7 +6,7 @@ # Modified: 2026-04-25 # ============================================= -"""Tests for cli, memory_template_updated, warning_logged, and bulletin_created event handlers.""" +"""Tests for cli, memory_template_updated, and warning_logged event handlers.""" import sys from pathlib import Path @@ -41,7 +41,6 @@ def _mock_infrastructure(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> Non "aipass.trigger.apps.handlers.events.cli", "aipass.trigger.apps.handlers.events.memory_template_updated", "aipass.trigger.apps.handlers.events.warning_logged", - "aipass.trigger.apps.handlers.events.bulletin_created", ): monkeypatch.delitem(sys.modules, mod_name, raising=False) @@ -67,13 +66,6 @@ def _import_warning_logged(): return m -def _import_bulletin(): - """Import bulletin_created handler module fresh after mocking.""" - import aipass.trigger.apps.handlers.events.bulletin_created as m - - return m - - # --------------------------------------------------------------------------- # cli.py -- handle_cli_header_displayed # --------------------------------------------------------------------------- @@ -197,65 +189,3 @@ def test_returns_none(self) -> None: mod = _import_warning_logged() result = mod.handle_warning_logged() assert result is None - - -# --------------------------------------------------------------------------- -# bulletin_created.py -- handle_bulletin_created -# --------------------------------------------------------------------------- - - -class TestHandleBulletinCreated: - """Tests for handle_bulletin_created from bulletin_created.py.""" - - def test_does_not_raise_when_files_missing(self) -> None: - """Silently handles missing registry and bulletins files.""" - mod = _import_bulletin() - mod.handle_bulletin_created() - - def test_logs_operation_on_success(self) -> None: - """Logs bulletin_event after successful propagation.""" - mod = _import_bulletin() - mod._propagate_bulletins_to_branches = MagicMock() - from aipass.trigger.apps.handlers.json import json_handler - - json_handler.log_operation.reset_mock() # type: ignore[union-attr] - - mod.handle_bulletin_created() - - mod._propagate_bulletins_to_branches.assert_called_once() - json_handler.log_operation.assert_called_once_with( # type: ignore[union-attr] - "bulletin_event", {"success": True} - ) - - def test_catches_propagation_exception(self) -> None: - """Does not log operation when propagation raises.""" - mod = _import_bulletin() - mod._propagate_bulletins_to_branches = MagicMock(side_effect=RuntimeError("propagation failed")) - from aipass.trigger.apps.handlers.json import json_handler - - json_handler.log_operation.reset_mock() # type: ignore[union-attr] - - mod.handle_bulletin_created() - - json_handler.log_operation.assert_not_called() # type: ignore[union-attr] - - def test_accepts_all_params(self) -> None: - """Accepts all documented event parameters without error.""" - mod = _import_bulletin() - mod._propagate_bulletins_to_branches = MagicMock() - - mod.handle_bulletin_created( - _bulletin_id="b1", - _title="System update", - _message="Scheduled maintenance", - _priority="high", - _created_by="devpulse", - _timestamp="2026-04-25T10:00:00", - ) - - def test_returns_none(self) -> None: - """Handler returns None.""" - mod = _import_bulletin() - mod._propagate_bulletins_to_branches = MagicMock() - result = mod.handle_bulletin_created() - assert result is None diff --git a/tests/e2e/test_wiring.py b/tests/e2e/test_wiring.py index b470656a..f0a0e794 100644 --- a/tests/e2e/test_wiring.py +++ b/tests/e2e/test_wiring.py @@ -241,15 +241,20 @@ def hook_workspace(tmp_path_factory: pytest.TempPathFactory) -> Path: def test_t2a_rm_gate_blocks(clean_venv: CleanVenv, hook_workspace: Path) -> None: - """rm -rf is blocked via {"decision":"block"} on STDOUT with exit code 0. - - Corrected contract (NOT exit 2): the bridge writes the engine's block JSON - to stdout and exits 0. + """rm -rf is blocked via {"decision":"block"} on STDOUT with exit code 2. + + Block contract: every security gate (rm/git/edit/subagent/presence) returns + exit_code 2 plus a block-JSON decision on stdout, and the bridge propagates + that exit code. The non-zero exit is the cross-event block signal — it is + what lets a UserPromptSubmit gate (presence_gate) actually cancel a prompt, + not just PreToolUse. Claude Code surfaces the stdout decision reason either + way. (Pre-FPLAN-0289 the bridge swallowed the exit code, so this test once + pinned exit 0; beb048d corrected the bridge to propagate it.) """ sentinel = f"e2e-block-{uuid.uuid4()}" proc = _fire_hook(clean_venv, hook_workspace, "rm -rf /tmp/x", sentinel) - assert proc.returncode == 0, f"expected exit 0, got {proc.returncode}. stderr:\n{proc.stderr}" + assert proc.returncode == 2, f"expected exit 2 (block), got {proc.returncode}. stderr:\n{proc.stderr}" decision = json.loads(proc.stdout) assert decision.get("decision") == "block", f"stdout was: {proc.stdout!r}"