Merge pull request #659 from AIOSAI/dev
prax log watchdog covers branch logs/ dirs — .jsonl runaway growth caught (built by @prax). Root cause: rotation was .log-hardcoded and .jsonl writers are raw open('a') appenders bypassing prax; the watchdog safety net only scanned system_logs. Now scans all src/aipass/*/logs/ for .log+.jsonl (WARN 1MB / CRITICAL 10MB unrotated), enforce truncates to last 5000 lines, log-audit shows system + branch scopes. 11 new tests, 947 suite green. Offender writers (hooks 63MB engine.jsonl, backup 31MB operations.jsonl, trigger 7MB medic log) routed to owners separately.
This commit is contained in:
@@ -6,5 +6,6 @@
|
||||
!README.md
|
||||
!PROMPT_STYLE.md
|
||||
!project_CLAUDE.md
|
||||
!project_AGENTS.md
|
||||
!project_hooks.json
|
||||
#Do not add other exceptions here without careful consideration. Developer permissions0ns needed.
|
||||
+15
-1
@@ -6,7 +6,8 @@
|
||||
"presence_gate": {
|
||||
"enabled": true,
|
||||
"handler": "aipass.hooks.apps.handlers.security.presence_gate.handle",
|
||||
"matcher": ""
|
||||
"matcher": "",
|
||||
"provider_wired": false
|
||||
},
|
||||
"identity_injector": {
|
||||
"enabled": true,
|
||||
@@ -62,6 +63,11 @@
|
||||
"handler": "aipass.hooks.apps.handlers.security.rm_gate.handle",
|
||||
"matcher": "Bash"
|
||||
},
|
||||
"registry_gate": {
|
||||
"enabled": true,
|
||||
"handler": "aipass.hooks.apps.handlers.security.registry_gate.handle",
|
||||
"matcher": "Bash|Edit|MultiEdit|Write|NotebookEdit"
|
||||
},
|
||||
"engine_test_sound": {
|
||||
"enabled": false,
|
||||
"command": "python3 $AIPASS_HOME/.claude/hooks/engine_test_sound.py",
|
||||
@@ -138,5 +144,13 @@
|
||||
"matcher": "",
|
||||
"timeout": 120
|
||||
}
|
||||
},
|
||||
|
||||
"SessionStart": {
|
||||
"cadence_reset": {
|
||||
"enabled": true,
|
||||
"handler": "aipass.hooks.apps.handlers.lifecycle.session_start.handle",
|
||||
"matcher": ""
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
# {name}
|
||||
|
||||
Agent workspace powered by AIPass.
|
||||
|
||||
# Startup protocol
|
||||
|
||||
On any greeting, silently run this sequence — no narration, no announcing steps. Just do it and respond with the status.
|
||||
|
||||
- Read: `.trinity/passport.json`, `.trinity/local.json`, `.trinity/observations.json`, `README.md`
|
||||
|
||||
Use drone commands for all operations. Never raw git, gh, or file access when drone provides it.
|
||||
|
||||
# Memories
|
||||
|
||||
Update `.trinity/` at natural breakpoints, after milestones, and on `/memo`.
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"_comment": "TEMPLATE: base per-project hook config copied into new projects by `aipass init` (DPLAN-0190). Mirrors AIPass's own .aipass/hooks.json. All handlers run from $AIPASS_HOME — projects only flip enabled true/false. Use `drone @hooks enable/disable <hook>` or edit here.",
|
||||
"_comment": "TEMPLATE: base per-project hook config copied into new projects by `aipass init` (DPLAN-0190). Mirrors AIPass's own .aipass/hooks.json. All handlers run from $AIPASS_HOME — projects only flip enabled true/false. Use `drone @hooks enable/disable <hook>` or edit here. NOTE: git_gate is enabled by default — it enforces git via drone to prevent state conflicts. To disable for your project, set git_gate.enabled to false below (this won't break other hooks).",
|
||||
"hooks_enabled": true,
|
||||
|
||||
"UserPromptSubmit": {
|
||||
@@ -92,6 +92,14 @@
|
||||
}
|
||||
},
|
||||
|
||||
"SessionStart": {
|
||||
"cadence_reset": {
|
||||
"enabled": true,
|
||||
"handler": "aipass.hooks.apps.handlers.lifecycle.session_start.handle",
|
||||
"matcher": ""
|
||||
}
|
||||
},
|
||||
|
||||
"PreCompact": {
|
||||
"pre_compact": {
|
||||
"enabled": true,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# AIPass — Kernel
|
||||
|
||||
<!-- .aipass/tier0_kernel.md — Tier 0, injected EVERY turn (cadence period 1). The irreducible "don't get lost" core. Keep it tiny — target under 2,000 chars. The full roster/framework/conventions arrive periodically as Tier 1 (.aipass/tier1_navmap.md); deep detail is pulled on demand. Format: .aipass/PROMPT_STYLE.md -->
|
||||
<!-- .aipass/tier0_kernel.md — Tier 0, injected every 5 turns (cadence period 5) + on every fresh context (new chat / clear / after compact). The irreducible "don't get lost" core. Keep it tiny — target under 2,000 chars. The full roster/framework/conventions arrive periodically as Tier 1 (.aipass/tier1_navmap.md); deep detail is pulled on demand. Format: .aipass/PROMPT_STYLE.md -->
|
||||
|
||||
You are an AIPass agent — a citizen with identity, memory, and a mailbox. Your branch is your home and address. CWD is your identity: always know which branch you're standing in. The system runs on `drone`.
|
||||
|
||||
@@ -13,6 +13,8 @@ You are an AIPass agent — a citizen with identity, memory, and a mailbox. Your
|
||||
- `drone @agent` — bare → the agent's live self-map.
|
||||
- `drone systems` — list every agent.
|
||||
|
||||
`aipass` is the one exception — the user's own front-door CLI and concierge (onboarding, `doctor`, OS/system help). Run `aipass` / `aipass --help` directly, **never `drone @aipass`** (drone can't resolve it). Serves humans, not agents.
|
||||
|
||||
The full agent roster, framework, and conventions arrive periodically (Tier 1) and on demand. Unsure of anything? Fetch it: `drone @agent --help` / the agent's `README.md` / `drone @memory search "query"`.
|
||||
|
||||
# Don't get lost
|
||||
|
||||
@@ -41,7 +41,7 @@ src/aipass/<name>/
|
||||
|
||||
- @drone — command router. Resolves `@agent`, routes commands, enforces tier-based access. Also the only git interface (`drone @git`).
|
||||
- @devpulse — orchestration hub, the user's primary collaborator. Coordinates the other agents, dispatches work, only agent with git write.
|
||||
- @aipass — the user-facing front door and a system-ops collaborator. Onboarding (`aipass init`), `doctor` diagnostics, help chat, handoff; also partners with the user on host-level health (disk, thermal, docker, config). Concierge to other branches: reads, never writes.
|
||||
- @aipass — the user's front-door concierge and its OWN CLI, NOT drone-routed: run `aipass` / `aipass --help` directly, never `drone @aipass` (drone can't resolve it). The human's best friend — onboarding (`aipass init`/`install`), `doctor` health, help chat, and OS/system questions ("why's my wifi dropping", "why's CC hogging CPU", "what is drone", "how do I make a project"). Serves humans, not agents — reads, never writes.
|
||||
- @ai_mail — inter-agent email. `dispatch` = send + wake (default for handing work), `email` = no wake, plus inbox/view/reply/close.
|
||||
- @flow — plan lifecycle: create, list, close, templates, registry. Plan types in the Plans section — never create plan files by hand.
|
||||
- @seedgo — code standards and audits. The standard pack, `audit` and `checklist`, the quality gate before and after building.
|
||||
@@ -55,7 +55,7 @@ src/aipass/<name>/
|
||||
- @skills — capability framework. Discoverable, self-contained skill units any agent can run; consume AIPass services as opt-in imports (e.g. the Telegram skill).
|
||||
- @daemon — task scheduler. Cron-triggered firing; each branch owns its `.daemon/schedule.json`, the daemon discovers and fires.
|
||||
- @commons — the social space. Where branches post, comment, vote, and gather as a community.
|
||||
- @backup — local-first backups. 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.
|
||||
- @backup — local-first backups. Snapshots + versioning + restore for any directory; optional Google Drive sync (live, per-file mirror — slow on huge file counts, respect `.backupignore`). `.backup/` is a shared runtime namespace — @memory rollover and @flow (plan archive) also write there.
|
||||
|
||||
# Daily commands
|
||||
|
||||
|
||||
@@ -44,7 +44,7 @@ Quick checks beat assumptions: `ls`/`find` for files, `git ls-files`/`grep` for
|
||||
- Update their execution logs, status, decision logs with current state
|
||||
- If a plan was completed, note it (but don't close — the user does that)
|
||||
|
||||
## 3. Git State
|
||||
## 3. Git State (Devpulse only)
|
||||
|
||||
- Run `git status` — report uncommitted changes
|
||||
- If there's a logical commit waiting, suggest it (don't commit without asking)
|
||||
|
||||
@@ -118,6 +118,12 @@ src/aipass/*/apps/integrations/**
|
||||
!src/aipass/commons/apps/handlers/artifacts/
|
||||
!src/aipass/commons/apps/handlers/artifacts/*.py
|
||||
|
||||
# hooks boot-shim installer — must ship so fresh clones can install the claude()
|
||||
# shell function. Collides with the blanket tools/ ignore (line 51).
|
||||
!src/aipass/hooks/tools/
|
||||
src/aipass/hooks/tools/*
|
||||
!src/aipass/hooks/tools/install_boot_shim.sh
|
||||
|
||||
# CI artifacts
|
||||
windows-pytest-results/
|
||||
|
||||
|
||||
+652
@@ -9,6 +9,658 @@ PyPI version — not the changelog header.
|
||||
|
||||
---
|
||||
|
||||
## [2026-07-11]
|
||||
|
||||
### Added
|
||||
|
||||
- **Owner seating made permanent + self-healing for every project (DPLAN-0239,
|
||||
fixes #693).** The owner-capability guard was correct but the DATA was never
|
||||
seeded: every project created before 2026-07-10 had its owner only in the
|
||||
self-editable passport, never in the sealed registry (8/8 external projects
|
||||
unseated; AIPass's own registry was missing `metadata.id` with 13 entries
|
||||
sharing one stale id). Identity model settled: registry `metadata.id` =
|
||||
project credential (passports conform); branch-entry `registry_id` =
|
||||
set-once PER-CITIZEN UUID minted at entry creation; entry `owner:true` =
|
||||
the authority gate (first agent), chosen by ONE shared heuristic
|
||||
(`pick_owner_branch`: manager → passport owner → first-created).
|
||||
New: `drone @spawn sync-registry --check [--json]` (read-only, 7 health
|
||||
flags, pinned JSON schema) and `--fix [--dry-run]` (idempotent reconcile:
|
||||
seat owner, majority-consensus restore of `metadata.id`, mint citizen UIDs,
|
||||
align passports; dry-run fully read-only; never moves a seated owner).
|
||||
`aipass doctor` renders owner health per flag; `doctor --fix`, `install`,
|
||||
and `init update` delegate repair to spawn — existing/external projects
|
||||
self-heal on next update (the missing DPLAN-0231 PART-4 trigger). The adopt
|
||||
path now seats owners; `placeholders.py` resolves the registry from the
|
||||
target dir (was CWD) and fails loud. @hooks `auto_watchdog` now injects the
|
||||
real Monitor-tool watchdog command with the actual @target (was a dead
|
||||
one-liner + `run_in_background`, which cannot wake a session). Deployed
|
||||
live: AIPass + 6 external projects reconciled and verified clean — VERA is
|
||||
now seated owner of Vera Studio (`is_owner('@vera') = True`, was refused).
|
||||
Owners built (spawn 343 / aipass 673 / hooks 961 tests green); devpulse
|
||||
verified every diff, live-ran every stage, full-repo sweep 9364 passed
|
||||
(1 pre-existing skills litter fail → #694).
|
||||
|
||||
### Changed
|
||||
|
||||
- **Fleet seedgo compliance sweep — every branch to 100% (issues #686, #661).**
|
||||
Overnight campaign bringing all branches to 100% on the seedgo standard pack.
|
||||
#686 (Subcommand_Help, per the #685 contract): entry points intercept
|
||||
`<cmd> --help` before dispatch, so `--help` shows help instead of executing.
|
||||
#661 (Output_Routing): status/error console output routed through the shared
|
||||
`@cli` `success()/error()/warning()` helpers instead of raw `console.print`
|
||||
markup. Owners self-audited and self-fixed their own branches; devpulse verified
|
||||
each diff + re-ran each audit and committed per wave. Landed so far: spawn,
|
||||
drone, flow, daemon, prax, ai_mail, backup, seedgo, memory, trigger, api, cli,
|
||||
aipass, commons — all 14 offenders now at 100%. **Fleet: 17/17 branches at
|
||||
100% seedgo compliance** (hooks, skills, devpulse were already compliant).
|
||||
Owners self-audited and self-fixed; devpulse verified every diff, re-ran each
|
||||
branch's full test suite, and committed per wave. A full 17-branch test run
|
||||
(~10,349 tests) surfaced one pre-existing flaky test in drone
|
||||
(`test_pr_no_branch_dir` / `test_pr_no_args` lacked cwd isolation, so a real
|
||||
checkout's findable passport made the auth path pass unexpectedly) — given
|
||||
`monkeypatch.chdir(tmp_path)` isolation to match its sibling test, so the full
|
||||
suite is now deterministically green.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Watchdog Monitor wake no longer double-fires (#693 follow-on, reported by
|
||||
VERA via the feedback channel).** The `watchdog agent` reminder banner
|
||||
("invoke via Monitor tool, not run_in_background") printed to STDOUT at arm
|
||||
time, and the harness Monitor tool treats every stdout line as a wake event —
|
||||
so every armed watchdog fired a spurious wake the instant it armed, then the
|
||||
real wake at completion. Rerouted to stderr (`err_console`); stdout now
|
||||
carries completion/stall events only, matching the contract the agent handler
|
||||
already followed. Verified live: exactly one wake, on real exit. The devpulse
|
||||
README watchdog/feedback sections were also rewritten to document the owner
|
||||
gate, the 3-step Monitor wake mechanic, why no passive wake can exist, and
|
||||
the 600 s default timeout.
|
||||
|
||||
- **Watchdog agent tests thread-race flakes made deterministic (devpulse).**
|
||||
Four tests patched the GLOBAL `time.sleep` with stateful/side-effecting
|
||||
fakes; prax's logger spawns daemon threads on first log, which executed the
|
||||
fakes concurrently with the test (advancing a fake clock, unlinking the
|
||||
fixture lock early, or re-truncating `last_bounce.json` mid-read in
|
||||
`_classify_exit` → `exit_code=None`). All fakes are now thread-scoped via a
|
||||
caller-frame guard: only sleeps from the agent module trigger the test's
|
||||
side effect; foreign threads get a real 1 ms sleep.
|
||||
|
||||
- **seedgo-audit back to 100 % after the S300 commits (PR659).** Two 99 %
|
||||
regressions from that day's own work: `aipass` `doctor.py` `_fix_owner_seating`
|
||||
had two silent catches (now log via prax like the sibling check function),
|
||||
and the devpulse README claimed 407 tests where the readme checker counts
|
||||
test functions (corrected to 309).
|
||||
|
||||
- **Two more non-hermetic ai_mail tests made deterministic (PR659).** With the full
|
||||
suite now running on varied CI runners, `test_get_pid_cwd_darwin_failure` and
|
||||
`test_is_zombie_linux_no_proc` intermittently failed: they called the real `lsof`
|
||||
(via `subprocess.run`) and real `open("/proc/…")` for a fixed PID (999 / 99999),
|
||||
so on a runner where that PID happened to exist they returned a non-`None` result
|
||||
instead of the expected failure. Mocked `subprocess.run` and `builtins.open` so the
|
||||
tests assert the failure contract without touching real process/`/proc` state.
|
||||
Test-only; deterministic across repeated runs.
|
||||
|
||||
- **Windows CI cross-platform fixes — `windows-setup` green (PR659).** Fixing the
|
||||
telegram collection errors unmasked 14 pre-existing Windows-only failures across
|
||||
six branches. Two root causes. **(1) pid-liveness tests** (ai_mail, flow, hooks,
|
||||
skills) mocked `os.kill`, but the production `_is_pid_alive` already branches to a
|
||||
ctypes `OpenProcess` path on Windows and never reaches `os.kill`, so the mocks had
|
||||
no effect and the real path ran instead — pinned `sys.platform` to `linux` in those
|
||||
tests (or patched `_is_pid_alive` directly) so they exercise the POSIX contract
|
||||
deterministically on every platform. **(2) POSIX path assumptions** — prax's jsonl
|
||||
test hardcoded `/some/path` (backslashes under `str(Path)` on Windows) now asserts
|
||||
against `str(test_path)`; hooks' rollover test compares `repr()` (matches `%r`
|
||||
logging); ai_mail's darwin lsof-parser test uses a fixed POSIX path; and seedgo's
|
||||
`is_bypassed()` now normalizes the rule file via `Path(rule_file).as_posix()` before
|
||||
matching (the one production fix — Windows backslash rule paths never matched the
|
||||
forward-slash file path). 10 files (9 test, 1 code); owners self-fixed, devpulse
|
||||
verified every diff + Linux no-regression (525 changed-test assertions green).
|
||||
|
||||
- **Flaky `test_deletes_old_system_log` made deterministic (@prax log-sweep tests).**
|
||||
The sweep integration test reached `log_watchdog._get_system_logs_dir` through a
|
||||
`_get_sweep()` wrapper and patched it by string path; a sibling test
|
||||
(`test_logging_handlers.py`) `sys.modules.pop`s and reimports `log_watchdog`,
|
||||
creating a second module object — so the string patch could target a different
|
||||
object than the function's `__globals__`, the sweep scanned the real (empty)
|
||||
`system_logs/`, removed 0 files, and `assert files_removed == 1` failed
|
||||
intermittently (the same commit passed in one CI run and failed in another).
|
||||
Switched to a direct `import log_watchdog as lw` + `patch.object(lw, …)` (shared
|
||||
module `__dict__`) and patched `json_handler` to block real file I/O. Test-only
|
||||
(1 file); prax suite 978 green, two full-repo runs 11,019 passed each, sweep tests
|
||||
deterministic across repeated runs.
|
||||
|
||||
- **Telegram skill tests made CI-safe — full-repo collection + hermeticity (issue #691).**
|
||||
The 16 test files under `skills/lib/telegram/tests/` imported handlers via bare
|
||||
`from apps.handlers…`, which collided with other branches' `apps` packages during
|
||||
full-repo CI collection (~16 ImportError collection errors → CI red on Linux +
|
||||
Windows). Converted to fully-qualified `aipass.skills.lib.telegram.apps.handlers.*`
|
||||
imports (and matching `mock.patch` targets). Verifying that fix surfaced a second
|
||||
problem the imports had exposed: ~11 tests reached the live Telegram API
|
||||
(`base_bot.run → _set_command_menu → set_bot_commands → urlopen`) — they had never
|
||||
run in CI before because they failed at collection. Added a session-scoped autouse
|
||||
`_block_network` conftest fixture that patches `urlopen` on the four network-using
|
||||
telegram modules (both bare and fully-qualified import paths, each guarded) so any
|
||||
test attempting a live HTTP call fails loud instead of hanging. A full-repo CI run
|
||||
then exposed a third layer the isolated suites had hidden: `handler.py` and its
|
||||
routing tests still used bare `from apps.handlers.X import Y` / `mock.patch("apps.
|
||||
handlers.X…")`, which resolve to the *wrong* branch's `apps` in a whole-repo run
|
||||
(AttributeError / ModuleNotFoundError at runtime — 17 `test_handler_routing`
|
||||
failures). Fully-qualified those to `aipass.skills.lib.telegram.apps.handlers.*` in
|
||||
both `handler.py` (7 lazy imports, now house-rule compliant) and the tests; the
|
||||
skill's runtime behaviour is unchanged (verified via `drone @skills run telegram`).
|
||||
Net: full-repo collection 0 errors and the whole 11k-test suite green; telegram
|
||||
suite 663 passed / 0 failed / 0 hangs, fully hermetic; coverage intact (import and
|
||||
patch-target rewiring only — zero assertion changes).
|
||||
|
||||
- **`aipass install` from a throwaway path can no longer hijack the machine-wide
|
||||
`AIPASS_HOME` (issue #688).** A probe install run from a `/tmp` scratchpad had
|
||||
rewritten `~/.claude/settings.json` `env.AIPASS_HOME`, silently pointing every
|
||||
Claude Code session on the machine at a dead temp tree (stale python, stale
|
||||
hooks — surfaced as bogus ImportErrors in unrelated work). Three defenses:
|
||||
`bootstrap.is_throwaway_path()` gates the settings write itself (temp dirs +
|
||||
scratchpads never land in global settings); `run_install` refuses a throwaway
|
||||
home loudly with `--force-global-home` as the explicit override; and
|
||||
`aipass doctor` gains a `global AIPASS_HOME` check that flags a nonexistent or
|
||||
throwaway path with fix guidance. +11 tests. Ships with a probe-hygiene SOP
|
||||
(`aipass/docs/probe_hygiene.md`): temp installs are used, deleted, gone — nothing
|
||||
permanent may point at a temp path. Tests made location-independent so the suite
|
||||
is green from any cwd and from a `/tmp` clean-room extraction, not just the repo
|
||||
root. (built by @aipass, verified + test-hardened by devpulse against the real
|
||||
hijack path)
|
||||
|
||||
## [2026-07-10]
|
||||
|
||||
### Added
|
||||
|
||||
- **Owner-capability model — project ownership sealed in the registry, and the
|
||||
owner is woken back when a dispatched agent completes (issue #678).** The
|
||||
directed-wake round-trip grew into an access-control primitive: watchdog /
|
||||
feedback / wake-back are owner-only privileges, and the owner (first agent /
|
||||
`citizen_class: manager` — devpulse in AIPass) is resolved from the *sealed*
|
||||
`*_REGISTRY.json`, not the self-editable passport (no self-grant). Three parts
|
||||
built in parallel against a frozen `is_owner` contract: `@spawn` writes
|
||||
`owner` + `registry_id` into registry entries and exposes `get_owner()` /
|
||||
`is_owner()` (`ensure_project_has_owner` now keys off the manager signal, not
|
||||
the created-date heuristic that mislabeled `@aipass`); `@hooks` adds a
|
||||
`registry_gate` PreToolUse handler that blocks raw writes/edits/deletes of
|
||||
`*_REGISTRY.json` and redirects to `drone @spawn` (per-clause bypass defeats
|
||||
compound-command smuggling; reads stay allowed); `@ai_mail` reslopes the
|
||||
dispatch wake-back from a `SKIP_SENDERS` blocklist to an `is_owner` allowlist.
|
||||
Cross-part verified end-to-end by devpulse with the real resolver (gate 13/13
|
||||
incl. compound-smuggle, wake-back owner/non-owner/depth-cap).
|
||||
(built by @spawn + @hooks + @ai_mail, verified by devpulse)
|
||||
|
||||
- **`subcommand_help` seedgo standard — entry points must intercept `<cmd> --help`
|
||||
before dispatch (issue #685, split from #665 item 3).** `drone @X <cmd> --help`
|
||||
had no framework contract: drone (a router, not a standards enforcer) forwards
|
||||
`--help` as a positional, so behavior was per-branch — 8/16 missed, and two
|
||||
branches *executed* the subcommand instead of showing help. seedgo now owns the
|
||||
contract: a new AST checker flags entry points that don't guard `<cmd> --help`
|
||||
(explicit `remaining_args[0]` guard or argparse `parse_known_args`). 21 tests,
|
||||
cwd-portable. 7/17 branches comply; the 10 offenders are tracked as a fleet
|
||||
migration (#686). (@seedgo, verified devpulse)
|
||||
|
||||
- **`windows_compat` now detects `os.kill(pid, 0)` liveness probes, not just
|
||||
documents them (issue #682).** `os.kill(pid, 0)` resolves to `TerminateProcess`
|
||||
on Windows — it *kills* the target instead of probing it. The checker documented
|
||||
the anti-pattern but never flagged it in source. A new detector recognizes the
|
||||
valid early-return platform guard (so the reference impl `watchdog/agent.py` isn't
|
||||
false-flagged) while catching genuinely unguarded sites. 6 tests; verified across
|
||||
the fleet (guarded ref passes, 10 offenders caught → fleet migration #684).
|
||||
(@seedgo, verified devpulse)
|
||||
|
||||
- **`append_jsonl` — a sanctioned rotating JSONL writer + a 30-day stale-log sweep
|
||||
(issue #673).** Branches wrote `.jsonl` via raw `open('a')`, bypassing prax
|
||||
rotation (which was `.log`-only) — unbounded log growth. `from aipass.prax import
|
||||
append_jsonl` gives 500 KB / 1-backup atomic (`os.replace`) rotation with zero
|
||||
dependency on the prax logging pipeline (recursion-safe for @trigger's event
|
||||
handlers), and `drone @prax log-audit sweep` deletes logs older than 30 days
|
||||
across system + branch logs. The raw appenders in @backup (1), @hooks (2), and
|
||||
@trigger (11 `.log` sites → `.jsonl`, plus downstream medic readers) all adopted
|
||||
it — zero raw log appenders remain fleet-wide.
|
||||
(@prax + @backup/@hooks/@trigger, verified devpulse)
|
||||
|
||||
- **Hook engine: Codex bridge + portable test suite (issue #635, DPLAN-0184
|
||||
leftovers).** The engine now drives Codex hooks the same way it drives Claude:
|
||||
new `handlers/bridges/codex.py` mirrors the claude.py bridge (same
|
||||
`EventType:hook_name` dispatch) with Codex protocol normalization — stdin
|
||||
remaps `input`→`tool_input`, stdout wraps in the `hookSpecificOutput` envelope
|
||||
(`additionalContext` for injection, `permissionDecision` +
|
||||
`permissionDecisionReason` for blocks — fixing the known DPLAN-0205 bugs:
|
||||
missing reason, wrong field name). And `drone @hooks test` is a portable
|
||||
drop-in runner that fires every hook from `.aipass/hooks.json` with mock data
|
||||
per event type and reports fired/blocked/disabled/crashed with timing
|
||||
(`--verbose` previews output). 23 new tests (12 bridge + 11 runner), seedgo
|
||||
31/31 both. (built by @hooks, verified by devpulse)
|
||||
|
||||
### Fixed
|
||||
|
||||
- **hooks/bridge: `-p` headless invocations no longer routed through tmux
|
||||
(issue #677, DPLAN-0226 fine-tune leftover).** The boot wrapper
|
||||
(`session_boot.py`) applied its tmux/session-lookup/live-attach logic to every
|
||||
invocation — wrong for `claude -p`, a non-interactive one-shot that never
|
||||
registers in `~/.claude/sessions`. The wrapper now detects `-p` in extra_args
|
||||
and short-circuits to direct `execvp` of claude — no tmux, no session lookup.
|
||||
+5 tests (39 pass). (built by @hooks, verified by devpulse)
|
||||
|
||||
- **Owner-capability PART 4 — devpulse's `watchdog` + `feedback` now gate on the
|
||||
sealed-registry owner, and cross-project (issue #681).** Closes the
|
||||
owner-capability model (#678): the last two owner-only tools were still gated
|
||||
by a hardcoded `cwd.name == "devpulse"` check — which, it turns out, was a
|
||||
**no-op through drone**: drone runs a routed module with `cwd=<branch_path>`,
|
||||
so the module's own `Path.cwd()` is *always* the devpulse tree and can't
|
||||
identify the caller (a `@flow` caller sailed straight through). A new shared
|
||||
`handlers/owner/guard.py` resolves the *real* caller from the env drone sets
|
||||
(`AIPASS_CALLER_BRANCH` / `AIPASS_CALLER_CWD`) and checks it against the sealed
|
||||
owner via the frozen `is_owner(email, start_path)` contract — so it works in
|
||||
any project (devpulse in AIPass, whoever owns elsewhere), not a hardcoded name.
|
||||
`feedback send` stays open (it's the inbound channel any agent uses to drop
|
||||
feedback to the owner); every mailbox read/manage verb is owner-only. Fail-safe:
|
||||
if no owner is sealed yet (old/partial install) or the resolver can't import,
|
||||
it falls back to the legacy devpulse-path heuristic so existing installs never
|
||||
hard-break. Live-verified end-to-end: owner allowed, `@flow` denied on both
|
||||
tools, `send` open. 18 new tests (15 guard + 3 gate), branch audit 100%.
|
||||
(built + verified by devpulse)
|
||||
|
||||
- **seedgo `json_structure` now sanctions `custom_config/` for operator-editable
|
||||
config (issue #643).** The standard said "`{branch}_json/` root, one directory,
|
||||
no splits" and the checker ignored subdirs, so `custom_config/` (home of
|
||||
operator-tunable runtime config like `cadence_config.json`, `memory.config.json`)
|
||||
was an undocumented convention. `json_structure_check.py` gained an
|
||||
`ALLOWED_JSON_SUBDIRS` allowlist and a `check_branch_post()` that validates
|
||||
`{branch}_json/` subdirs — `custom_config/` and hidden dirs (`.archive`) pass,
|
||||
any other split is flagged. `json_structure_content.py` documents the directory
|
||||
structure and operator-config location. The subdir check honors
|
||||
`.seedgo/bypass.json` (bypass rules are threaded through
|
||||
`check_branch_post` → `_check_json_dir_structure`), so a branch can sanction a
|
||||
legitimate data subdir while unsanctioned + unbypassed splits still fail. 7 new
|
||||
tests. (The new check surfaced `devpulse_json/compass/` — the devpulse Compass
|
||||
SQLite/FTS5 decision store, which needs its own directory — now sanctioned via a
|
||||
documented devpulse bypass; audit confirms Json_Structure back to 100%.)
|
||||
|
||||
- **`git_gate` block messages now guide external users instead of dead-ending
|
||||
(issue #620).** A blocked raw `git`/`gh` command previously just errored. The
|
||||
block message now explains *why* git is enforced (prevents cross-agent state
|
||||
conflicts), lists the key `drone @git` commands (commit, smart-sync, sync, pr,
|
||||
checkout), points to `drone @git --help`, and shows how to disable the gate in
|
||||
isolation (`git_gate.enabled = false` in `.aipass/hooks.json`) — verified
|
||||
against the engine, which skips a disabled hook per-hook without affecting other
|
||||
hooks or `drone @git`. The combined `GIT_GH_REDIRECT` was split into distinct
|
||||
`GIT_REDIRECT` + `GH_REDIRECT`; `EDIT_REDIRECT` also shows the disable path. An
|
||||
init notice was added to the `project_hooks.json` template and the on/off story
|
||||
documented in the hooks README. 6 new tests (86 in `test_git_gate`).
|
||||
|
||||
- **Telegram `/create` + `/cancel` are now gated to the base @aipass bot (issue
|
||||
#644).** Every per-branch bot inherited `BaseBot`'s `/create` + `/cancel` and
|
||||
could mint new bots — but Patrick designated the base @aipass bot as the *sole*
|
||||
spawner. `base_bot.py` now guards on bot identity (branch bots carry a
|
||||
`branch_name`; the base bot's is `None`): `_dispatch_command` returns `False` for
|
||||
`create`/`cancel` on a branch bot (falls through to normal handling), and
|
||||
`get_custom_commands` advertises them only for the base bot. Rode along in the
|
||||
same @skills pass: fail-loud fixes to `botfather_client.py` (issues #669.2/#669.3,
|
||||
already closed) — `_load_telethon_config` now raises `RuntimeError` naming the
|
||||
config path and the `drone @api set-secret telegram telethon_config` command
|
||||
instead of silently returning `None` — plus poll-offset test coverage (#668).
|
||||
133 telegram tests pass, seedgo 31/31 on both source files.
|
||||
|
||||
- **seedgo no longer lints throwaway code (issue #675).** A single disposable POC
|
||||
used to fire 8 standard violations (architecture, meta, shebang…). The audit and
|
||||
checklist now skip any file resolved under a system temp dir
|
||||
(`tempfile.gettempdir()` / `/tmp`, cross-platform) or a `scratchpad` path, and a
|
||||
new `--prototype` flag (plus an in-file `# seedgo: prototype` marker in the first
|
||||
5 lines) exempts disposable code explicitly. Wired into
|
||||
`branch_audit._collect_py_files` (throwaway filter) and `checklist.run_checklist`
|
||||
(early-return skip). 6 new tests; live-verified that a `/tmp` file and a
|
||||
marker-tagged file both report "✓ (skip)".
|
||||
|
||||
- **The `claude()` boot shim now ships and installs on onboarding (issue #666).**
|
||||
Its installer (`hooks/tools/install_boot_shim.sh`) lived under a gitignored
|
||||
`tools/` dir — never version-controlled, never shipped — so the
|
||||
attach-if-live / start-in-tmux boot feature (and presence-gate-via-boot) was
|
||||
dev-local only; a macOS user could not attach/resume their session. A root
|
||||
`.gitignore` negation now tracks exactly that one file (`tools/` re-ignored,
|
||||
only the installer whitelisted — README stays out), and `setup.sh` runs it
|
||||
right after hook installation (idempotent via a marker check, non-fatal on
|
||||
error, venv Python resolved from the script's own location for POSIX/Windows).
|
||||
Fresh clones and `aipass install` now get the shim.
|
||||
|
||||
- **Interactive-occupancy detection is now cross-platform — the wake-back guard
|
||||
no longer goes blind on macOS (issue #680).** `_is_branch_occupied()` and
|
||||
`_read_session_type()` (duplicated in `dispatch/wake.py` and `dispatch/daemon.py`)
|
||||
read `/proc/{pid}/cwd` + `/proc/{pid}/environ`, which do not exist on macOS —
|
||||
so occupancy always resolved `False` there and an external wake-back could spawn
|
||||
a *second* Claude session on an already-interactive branch (double-session,
|
||||
weakening the TDPLAN-0012/#678 interactive-dispatcher guard). The per-PID cwd
|
||||
and session-type probes are now extracted into platform helpers: `_get_pid_cwd`
|
||||
(Linux `/proc` readlink, macOS `lsof -a -p PID -d cwd -Fn`) and
|
||||
`_read_session_type_darwin` (`ps -p PID -wwE`), applied identically in both
|
||||
files. Fail-safe: an unreadable cwd/env logs at info and continues — never
|
||||
crashes the wake path. +11 tests (macOS cwd, macOS session type, zombie,
|
||||
unsupported platform), seedgo 31/31 on both files.
|
||||
|
||||
- **SubagentStop gate no longer runs its ~600ms seedgo check on every internal
|
||||
turn (issue #606).** Claude Code creates an internal agent per response turn
|
||||
with an empty `agent_type`, so the `subagent_gate` handler was firing its full
|
||||
`drone @git status` + seedgo modified-files check on every turn, not just when a
|
||||
real Agent-tool sub-agent completed. `handle()` now early-returns `_ALLOW` when
|
||||
`agent_type` is empty; the full check runs only for a real sub-agent
|
||||
(non-empty `agent_type`). Piper speech is a separate notification hook and is
|
||||
unaffected — the trust layer stays visible. 3 new tests (empty skip, missing-key
|
||||
skip, real-agent full check), 17/17 green.
|
||||
|
||||
- **Watchdog stall detector no longer false-fires on a long single tool call, and
|
||||
a real stall now reaches devpulse live (issue #634).** Liveness was inferred
|
||||
purely from JSONL file-size growth, so an agent doing one genuinely long
|
||||
operation (big read, long-running Bash, heavy compute) wrote no new lines for
|
||||
the span and was misread as `STALLED` while actively working. `watch_agent` now
|
||||
also treats an in-flight `tool_use` (the assistant's last transcript entry while
|
||||
a tool runs) as activity — verified live against real Claude Code transcripts:
|
||||
the `tool_use` line is written at tool *start* and persists for the whole call.
|
||||
Part 2: the stall (and a new long-running-tool advisory, plus a resumed signal)
|
||||
is emitted to **stdout** — which the Monitor-tool wrapper surfaces as a live
|
||||
event — instead of only `stderr`+logger, which Monitor captures but never
|
||||
relays. Stall logic extracted into a `StallTracker` for clarity; +9 tests
|
||||
(142 green), devpulse audit 100%. (devpulse)
|
||||
|
||||
- **`aipass install` shows progress during the slow dependency build, and a README
|
||||
quick-start command is corrected (issue #665, items 6–7).** The editable install of
|
||||
the `[memory]` extras ran with `pip --quiet`, going silent for minutes during wheel
|
||||
builds — it looked hung; dropped `--quiet` on that step and set expectation in the
|
||||
echo. And `README.md` showed `drone @seedgo audit my_project`, which fails
|
||||
(`audit` takes a registered pack name) — corrected to `audit aipass`. Remaining
|
||||
#665 items (version, --help names, subcommand --help, placeholders, hints, crash-vs-
|
||||
unknown) span multiple owners and stay open. (devpulse)
|
||||
|
||||
- **`aipass`/`drone` first-contact papercuts resolved — issue #665 fully closed
|
||||
(items 1, 2, 4, 5, 8).** `aipass --version` now reads package metadata (was
|
||||
hardcoded `0.1.0`); `aipass --help` lists real `COMMAND` names, not file stems
|
||||
(`help` not `help_chat`, `init` not `init_flow`); a crashing or unimportable
|
||||
handler surfaces its real cause instead of `Unknown command` (@aipass). `drone
|
||||
systems` placeholder descriptions now derive from each branch's passport/README,
|
||||
fixed in code so they survive registry regen — the earlier gitignored data edit
|
||||
didn't (@spawn). Bare-mode hints point to working commands — `drone @daemon
|
||||
--help` (there is no `daemon` binary) and the standard `drone @memory --help`
|
||||
(@daemon, @memory). Item 3 became the #685 standard. (multi-branch, verified devpulse)
|
||||
|
||||
- **`os.kill(pid, 0)` liveness probes across the fleet are now Windows-safe (issue
|
||||
#684).** On Windows `os.kill(pid, 0)` maps to `TerminateProcess` — the "probe"
|
||||
kills the target. Nine sites across @ai_mail (dispatch daemon/wake), @drone (git
|
||||
lock handler), @flow (runner lock), @hooks (cc_sessions/presence) and @devpulse
|
||||
(watchdog registry) now early-return to an `OpenProcess` + `GetExitCodeProcess`
|
||||
check on win32, mirroring the `watchdog/agent.py` reference. The #682 checker
|
||||
confirms 0 unguarded sites remain (down from 10); the last one,
|
||||
`tools/git_lock_tool.py`, is split to #687 (blocked by the tool's pre-existing
|
||||
gate debt). (fleet migration, verified devpulse)
|
||||
|
||||
- **Telegram poll loop no longer re-drains a rate-limited backlog; systemd
|
||||
suicide-loop + silent config fallback fixed (issues #668, #669).** #668: the poll
|
||||
loop advanced the update offset *after* processing, so a rate-limited/erroring
|
||||
update never advanced it — the same backlog re-fetched in a flood loop. The
|
||||
offset now advances *before* `process_update`, so a consumed update never pins
|
||||
it. #669: (1) systemd unit gets `KillMode=process` so a restart isn't killed by
|
||||
the old instance's cgroup teardown (suicide-loop); (2) `create_bot_via_botfather`
|
||||
now **raises** with an actionable message (naming the `set-secret` fix) instead of
|
||||
silently returning `None` when telethon config is missing (fail-honestly);
|
||||
(3) stale config-mechanism docstrings corrected. Also Windows-hardened
|
||||
`_is_pid_alive`/`_check_lock` and switched `TEMP_DIR` to `tempfile.gettempdir()`.
|
||||
653 telegram tests green. (@skills, verified devpulse)
|
||||
|
||||
- **Rollover `_find_repo_root` now fails loud, and `edit_gate` warns on over-count
|
||||
memory sections (issue #683, #664 follow-up).** The PreCompact rollover hook's
|
||||
`_find_repo_root` returned `None` silently when `AIPASS_HOME`/cwd was wrong — the
|
||||
exact silent-skip that hid #664 for months; it now logs a `logger.error` with the
|
||||
`AIPASS_HOME` value and cwd before returning. And `edit_gate` enforced per-entry
|
||||
*character* caps but not entry *counts*, so a branch could drift past its count
|
||||
cap between rollovers; a soft `_check_section_counts` now warns (never blocks),
|
||||
reading the same `memory.config.json` rollover caps @memory uses. +14 tests
|
||||
(70 green); both live-proven (bad root → error logged; over-cap → warn, no block).
|
||||
(@hooks, verified devpulse)
|
||||
|
||||
- **`is_owner()` now case-folds — `is_owner('DEVPULSE')` matches `is_owner('devpulse')`
|
||||
(issue #679).** The spawn-registry resolver (`registry.py:382`) `@`-normalized the
|
||||
email but never lowercased, so a mixed-case branch name (registry names are
|
||||
mixed-case: `DEVPULSE` vs `devpulse`) returned `False` against the seated owner.
|
||||
Harmless today (the only caller lowercases first) but the frozen TDPLAN-0012
|
||||
contract promises normalization, and PART-4 owner-gating may pass a raw name.
|
||||
Now lowercases both sides; verified live (every case variant of the owner → True,
|
||||
non-owners → False) + a case-insensitivity test (316 green). (@spawn, verified devpulse)
|
||||
|
||||
- **`aipass install` no longer hard-fails (exit 2, silently) when it can't create
|
||||
global symlinks (issue #660 follow-up).** `setup.sh` runs under
|
||||
`set -euo pipefail`; the #660 `safe_symlink` refactor returns `2` on `ln`
|
||||
failure, but the call sites captured that code on the *next* line (`rc=$?`), so
|
||||
`set -e` killed the installer at the symlink step — before the `~/.local/bin`
|
||||
fallback (built for exactly the no-sudo case) could run. Any sudo-less
|
||||
environment (containers, CI, locked-down machines) got a silent exit 2 with no
|
||||
symlinks, despite an otherwise-complete install. Fixed all three call sites to
|
||||
`rc=0; safe_symlink … || rc=$?` (set-e-safe). Proven in docker: a sudo-less
|
||||
install now falls back to `~/.local/bin` and exits 0. (devpulse)
|
||||
|
||||
- **`drone @devpulse watchdog agent` no longer reports failure on a successful
|
||||
watch (issue #661).** Its "invoke via Monitor tool" reminder was printed
|
||||
through `cli.error()`, which — after the #661 exit-code work — trips a
|
||||
process failure flag, so every successful watch exited non-zero with a red X.
|
||||
Rerouted to a dim console note; genuine argument errors still `error()` →
|
||||
exit 2. (devpulse)
|
||||
|
||||
- **The prax monitor now holds a single-instance lock, so a duplicate/orphan
|
||||
monitor can't double-send Telegram relay messages (issue #671).** A new
|
||||
`instance_lock` handler writes a pidfile (`prax_json/monitor.pid`, outside the
|
||||
tailed `system_logs/`) with a liveness check: `acquire()` runs before relay
|
||||
init and refuses to start (fail-loud, naming the holding PID) if a live monitor
|
||||
already holds the lock, reclaims a stale pidfile when the recorded PID is dead,
|
||||
and `release()` clears it on shutdown. The liveness probe is platform-branched
|
||||
— POSIX `os.kill(pid, 0)`, Windows `OpenProcess`/`GetExitCodeProcess` (a raw
|
||||
`os.kill(pid, 0)` *terminates* the target on Windows). `monitor.py` was also
|
||||
split under the 600-line limit (`pid_cache` extracted). +25 tests.
|
||||
(built by @prax, verified by devpulse)
|
||||
|
||||
- **`aipass init update` now refreshes `AGENTS.md` and prunes stale managed
|
||||
cruft (issue #676).** Two gaps: (1) `update_project` synced `AGENTS.md` from a
|
||||
`.aipass/project_AGENTS.md` template that never existed, so the branch silently
|
||||
no-op'd and `AGENTS.md` was never refreshed on update (only `CLAUDE.md`, whose
|
||||
template exists, synced) — added the template and reconciled create/update to
|
||||
one source; (2) the update was additive-only — added a whitelist-scoped cleanup
|
||||
pass (`_STALE_MANAGED_FILES`, currently the retired `aipass_global_prompt.md`)
|
||||
that removes only positively-identified managed artifacts, logs every removal,
|
||||
and never touches user-owned files. The template also had to be un-ignored in
|
||||
`.aipass/.gitignore` (allowlist) or it would never have shipped — caught in
|
||||
verify. +7 tests; live repro confirms update emits `AGENTS.md` and clears a
|
||||
planted cruft file. (built by @aipass, verified by devpulse — incl. the
|
||||
gitignore ship-gap)
|
||||
|
||||
- **External-project branches now auto-roll — rollover discovery is no longer
|
||||
cwd-scoped (issue #664).** Branch discovery only saw registries reachable by
|
||||
walking up from the caller's cwd, so branches living solely in an external
|
||||
project's `*_REGISTRY.json` were never reached by rollovers fired from the
|
||||
AIPass tree (the PreCompact hook runs with cwd = repo root) — their `.trinity`
|
||||
files grew unbounded (one hit 110 key_learnings against a 15 cap) and vector
|
||||
stores went stale. `@memory` added a persisted `known_registries.json`
|
||||
(gitignored per-install data) that records every external registry seen via the
|
||||
cwd walk, so discovery reaches them regardless of caller cwd; stale/deleted
|
||||
registry paths are filtered on load. Plus a soft entry-**count** guard at write
|
||||
time (warns, never blocks) since the write gates only enforced char caps. The
|
||||
remaining hooks-side harden (`_find_repo_root` fail-loud + the `edit_gate`
|
||||
count-guard) is filed for `@hooks`. +12 tests; live repro confirms a rollover
|
||||
fired from the AIPass root now reaches an external-registry branch.
|
||||
(built by @memory, verified by devpulse)
|
||||
|
||||
---
|
||||
|
||||
## [2026-07-09]
|
||||
|
||||
### Added
|
||||
|
||||
- **Exit-code contract foundation — failing commands can now exit non-zero
|
||||
(issue #661, in progress).** CLI error paths printed an error but returned exit
|
||||
`0`, so `$?`-checking callers (core to running `drone` as a subprocess) were
|
||||
told success on failure. The dispatch contract was a 2-state bool (`handled` /
|
||||
`not-mine`) with no way to say "handled *and* failed". `@cli` now exposes a
|
||||
process-level failure flag + `resolve_exit(handled)` (→ `0`/`1`/`2`), and
|
||||
`error()` auto-trips the flag — so any failure routed through `error()` gets a
|
||||
correct non-zero exit with zero per-site edits, and it can't regress. Inert
|
||||
until a branch's `main()` adopts it. `@seedgo` added an `output_routing`
|
||||
standard (39th checker) flagging user-facing status output that bypasses the
|
||||
cli helpers — 254 sites across 14 branches, the migration checklist. `devpulse`
|
||||
is the first adopter (`main()`→`resolve_exit`, feedback migrated to `error()`,
|
||||
exit `2`/`0`/`1` verified, 100% seedgo). Fleet migration to follow.
|
||||
(built by @cli + @seedgo)
|
||||
|
||||
### Fixed
|
||||
|
||||
- **`@trigger` no longer rewrites its 44KB `trigger_data.json` on every log event
|
||||
(issue #674).** The branch log watcher persisted dedup hashes and log positions
|
||||
with two separate full-file rewrites *per event*, so a log burst churned the
|
||||
file ~1-2×/sec (surfaced by prax monitoring). Replaced the per-event/counter
|
||||
writes with a debounced coalescing writer: events set a dirty flag and both
|
||||
keys are written in a single atomic write at most once per 5s, with a forced
|
||||
flush on watcher stop so nothing is lost on clean shutdown. Also confirmed the
|
||||
retired `bulletin_created` event handler no longer loads or warns (it lives in
|
||||
`.archive/` with no live references; scrubbed stale README/bypass mentions).
|
||||
564 trigger tests green (+6 debounce tests).
|
||||
|
||||
- **`aipass install` no longer silently repoints your global `drone`/`aipass`
|
||||
symlinks (issue #660).** `setup.sh` force-overwrote the global CLI symlinks with
|
||||
`ln -sf` on every run, no check and no opt-out — so `aipass install
|
||||
--path /tmp/scratch` "to try it" silently hijacked your real global commands to
|
||||
the scratch tree, which broke them once `/tmp` cleared, disconnected from the
|
||||
cause. A new `safe_symlink` guard refuses to repoint a symlink that points at a
|
||||
*different* install: it prints a loud from→to warning and leaves the existing
|
||||
link untouched unless you pass `--force-symlink`; `--no-symlink` opts out of
|
||||
symlinking entirely. Both flags thread through `aipass install`. Fresh installs
|
||||
and same-location reinstalls behave exactly as before. Adds a `safe_symlink`
|
||||
regression test (`tests/setup_symlink_guard_test.sh`) and 3 flag-forwarding
|
||||
tests; the touched install output was migrated to `@cli` helpers (#661).
|
||||
|
||||
- **`drone @flow close` no longer reports a false "timed out after 30s" on a
|
||||
successful close (issue #662).** A single-plan close committed early (plan
|
||||
marked closed, file archived) and then ran memory vectorization
|
||||
*synchronously* — `drone @memory process-plans` — inline. On the cold first
|
||||
close of a session that crossed drone's 30s executor timeout, so drone killed
|
||||
the flow subprocess and returned exit `1` **after** the close had fully
|
||||
committed. An autonomous agent reading that exit code would retry or abandon an
|
||||
already-closed plan. `close_plan_impl` now honors its long-existing
|
||||
`spawn_background` flag: single close fires the already-detached
|
||||
`_spawn_background_runner` (the same path `close_all` uses) and returns
|
||||
immediately after archive; vectorization runs in the background. Also removed
|
||||
the handler's cross-handler imports (archive/trigger now injected). Verified
|
||||
live: a real close returns in ~5s at exit 0 ("Vectorizing in background") vs
|
||||
the prior 30s-timeout risk. 730 flow tests green (+2 new).
|
||||
|
||||
- **`aipass doctor` no longer hangs on non-interactive stdin (issue #663).** The
|
||||
auto-wire `[y/N]` prompt called `input()` with no tty guard, so a caller with a
|
||||
blocking-but-idle stdin (a script, CI job, or subprocess whose stdin never
|
||||
sends EOF) hung `doctor` indefinitely — reading as a crash from the flagship
|
||||
"check my system" command a new user runs first. `prompt_auto_wire` now guards
|
||||
the prompt with `sys.stdin.isatty()`: a non-tty stdin declines the auto-wire
|
||||
(prints the manual-wire warning) instead of blocking. Verified against the
|
||||
exact repro — a blocking non-tty stdin that never EOFs now completes instead of
|
||||
hanging until killed. Adds 3 regression tests.
|
||||
|
||||
- **macOS session lock-out: the boot wrapper can now see tmux sessions on
|
||||
macOS.** `session_boot` decided whether a live Claude session lived inside
|
||||
tmux by walking the process tree through `/proc/<pid>/status` — Linux-only.
|
||||
On macOS (no `/proc`) that walk always failed, so the wrapper concluded every
|
||||
live session was "outside tmux" and refused to attach, locking the user out of
|
||||
their own session in an unbreakable loop. Replaced the `/proc` read with a
|
||||
portable `ps -o ppid=` ancestry walk (Linux + macOS). Also: both the boot
|
||||
warning and the presence-gate block now spell out the exact recovery command
|
||||
(`kill <pid> && claude`, `command claude --resume`) instead of a vague "kill it
|
||||
first", and the wrapper no longer doubles `--permission-mode` when the user
|
||||
passes it explicitly. New/updated tests, hooks suite 791 green. (built by @hooks)
|
||||
- **Boot-shim installer no longer bakes a hardcoded user path.**
|
||||
`install_boot_shim.sh` hardcoded `/home/patrick/Projects/AIPass/.venv/bin/python`
|
||||
into the `claude()` shell function — wrong on any other machine or user. It now
|
||||
resolves the venv interpreter from the script's own location (POSIX
|
||||
`.venv/bin/python`, Windows/git-bash `.venv/Scripts/python.exe`, else PATH
|
||||
`python3`) and bakes the correct one at install time.
|
||||
- **Silent hook-wiring break: provider settings could be left half-wired with no
|
||||
warning.** A stale `setup.sh` merge orphaned the `SessionStart` hook event to an
|
||||
empty `[]` — the key existed but nothing fired — written silently, and it went
|
||||
unnoticed for weeks because CI skips the provider-settings snapshot test (it
|
||||
needs `~/.claude/settings.json`, absent in CI). Root cause: the merge stripped
|
||||
every AIPass bridge entry per event, then re-added only events still present in
|
||||
its own hook list, orphaning any event it no longer defined. The merge now drops
|
||||
such an event entirely (and says so) instead of emitting an empty array. Also
|
||||
corrected the stale snapshot fixture (dropped the dormant `presence_gate`, which
|
||||
by design ships wired only in project config, and added
|
||||
`SessionStart:cadence_reset`) and marked `presence_gate` `provider_wired: false`
|
||||
so the wiring checker knows it is intentionally not provider-wired.
|
||||
- **`json_handler.load_json` crashed on an empty/whitespace file (#667).** Under
|
||||
concurrent audit + tests a writer could truncate a JSON file in the window
|
||||
between `ensure_json_exists` and `load_json`'s own read, raising
|
||||
`JSONDecodeError`. `load_json` now guards an empty/whitespace read and falls back
|
||||
to the type's default template; a non-empty but malformed file still raises (fail
|
||||
honestly). 3 new tests, red-green proven.
|
||||
|
||||
### Added
|
||||
|
||||
- **`drone @hooks verify` — hook-wiring integrity checker.** Cross-checks
|
||||
`~/.claude/settings.json` against `.aipass/hooks.json` and fails loud on empty
|
||||
provider hook arrays, orphaned entries, enabled handlers with no provider bridge,
|
||||
and duplicate (matcher-aware) entries — so a half-wired hook can never rot
|
||||
silently again. `aipass doctor` now runs this check under Services and re-verifies
|
||||
after `--fix`. 40+ new tests. (built by @hooks + @aipass)
|
||||
|
||||
## [2026-07-07]
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Drive sync now respects `.backupignore` on the sync path.** The ignore spec
|
||||
was applied at backup time only — anything already inside `.backup/versioned/`
|
||||
got uploaded regardless. Real case: Vera-Studio's store carried 37K legacy
|
||||
`node_modules` files (92% of the store), turning a KB-sized sync into a 7-8
|
||||
hour crawl (Drive uploads are per-file API round-trips — latency-bound, not
|
||||
bandwidth-bound; the clean store syncs in ~13 min). `drive_sync` now re-filters
|
||||
store files through the project's `.backupignore` before upload and logs the
|
||||
ignored count. Also fixed: `json_handler.log_operation` crashed on `Path`
|
||||
objects (`PosixPath is not JSON serializable`) — now serializes with
|
||||
`default=str`. 2 new tests, backup suite 247 green. (built by @backup)
|
||||
|
||||
### Added
|
||||
|
||||
- **Fresh-context grounding: cadence reset on new chat / clear / compact.**
|
||||
Both prompt loaders (tier0 kernel + navmap) now run at period 5, and a new
|
||||
`SessionStart` hook resets the cadence counter on `startup`/`clear` (skips
|
||||
`resume` — restored context already carries grounding; `compact` was already
|
||||
reset via PreCompact). Net effect: the first message of every fresh context
|
||||
gets full grounding, then every 5th turn after. Wired end-to-end: handler
|
||||
(`session_start.py`), project config (`.aipass/hooks.json` + the
|
||||
`project_hooks.json` template for external projects), and `setup.sh` seeds
|
||||
the provider `SessionStart` entry for new installs. Proven end-to-end from a
|
||||
real fresh-user clone of dev in Docker — 19/19 assertions via the new
|
||||
`tests/docker_dev_verify.sh` (bridge-era; supersedes the stale
|
||||
`docker_clone_test.sh`). (built by @hooks + @devpulse)
|
||||
|
||||
### Fixed
|
||||
|
||||
- **`aipass` ≠ drone-routed — misroutes now guide instead of crash.** `aipass`
|
||||
is the user's front-door CLI, deliberately not resolvable by drone. But
|
||||
`drone aipass` misdirected, `drone @aipass` crashed with a traceback, and
|
||||
`aipass @drone` dead-ended. All three now print clear guidance (what aipass
|
||||
is, what drone is, how to reach each). Kernel + navmap prompts updated so
|
||||
agents know the exception. (built by @drone + @aipass)
|
||||
|
||||
---
|
||||
|
||||
## [2026-07-06]
|
||||
|
||||
### Fixed
|
||||
|
||||
- **prax log watchdog now covers branch `logs/` dirs — `.jsonl` runaway growth
|
||||
caught.** Rotation was hardcoded to `.log` files, and several branches write
|
||||
`.jsonl` logs via raw `open(path, "a")` appenders that bypass prax entirely —
|
||||
`hooks/logs/engine.jsonl` had grown to 63 MB, `backup/logs/operations.jsonl`
|
||||
to 31 MB, `trigger/logs/medic_suppressed.log` to 7 MB, all unrotated. The
|
||||
log-watchdog safety net also only scanned `system_logs/*.log`. @prax extended
|
||||
it: `scan_branch_log_files()` sweeps every `src/aipass/*/logs/` for `.log` +
|
||||
`.jsonl` (WARN at 1 MB unrotated, CRITICAL at 10 MB),
|
||||
`enforce_branch_log_limits()` truncates flagged files to the last 5000 lines,
|
||||
and `drone @prax log-audit` now reports both system and branch scopes. 11 new
|
||||
tests, full prax suite 947 green. The raw-appender writers themselves still
|
||||
need per-owner caps — routed to @hooks, @backup, @trigger. (built by @prax)
|
||||
|
||||
---
|
||||
|
||||
## [2026-07-05]
|
||||
|
||||
### Fixed
|
||||
|
||||
@@ -144,7 +144,7 @@ drone @branch command [args] # Every agent, every task. Drone handles routing
|
||||
```
|
||||
|
||||
```bash
|
||||
drone @seedgo audit my_project # Run quality checks on everything
|
||||
drone @seedgo audit aipass # Run quality checks on everything
|
||||
drone @flow create . "Refactor auth module" # Create a work plan
|
||||
drone @ai_mail dispatch @agent "Archive old sessions" "Find sessions older than 30 days"
|
||||
```
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "aipass"
|
||||
version = "2.6.1"
|
||||
version = "2.7.0"
|
||||
description = "A local multi-agent framework where your AI agents keep their memory, work together, and never ask you to re-explain context"
|
||||
readme = "README.md"
|
||||
license = "MIT"
|
||||
|
||||
@@ -5,10 +5,12 @@
|
||||
# On interactive terminals it then chains into `aipass init run` to scaffold a first
|
||||
# project (DPLAN-0234: one command does setup + init).
|
||||
#
|
||||
# Usage: ./setup.sh [--no-init] [--with-init] [--project <dir>]
|
||||
# Usage: ./setup.sh [--no-init] [--with-init] [--project <dir>] [--no-symlink] [--force-symlink]
|
||||
# --no-init skip the first-project init chain
|
||||
# --with-init force the init chain even headless (init runs --non-interactive)
|
||||
# --project <dir> first-project directory (default: ~/aipass-project)
|
||||
# --no-symlink do not create/modify global drone/aipass CLI symlinks
|
||||
# --force-symlink repoint a global symlink even if it points at a different install (#660)
|
||||
#
|
||||
|
||||
set -euo pipefail
|
||||
@@ -39,6 +41,8 @@ esac
|
||||
# default (auto) chains into init on interactive terminals only — CI/headless skip.
|
||||
RUN_INIT="auto"
|
||||
INIT_PROJECT=""
|
||||
SKIP_SYMLINK="no"
|
||||
FORCE_SYMLINK="no"
|
||||
PREV_ARG=""
|
||||
for arg in "$@"; do
|
||||
if [ "$PREV_ARG" = "--project" ]; then
|
||||
@@ -47,10 +51,12 @@ for arg in "$@"; do
|
||||
continue
|
||||
fi
|
||||
case "$arg" in
|
||||
--no-init) RUN_INIT="no" ;;
|
||||
--with-init) RUN_INIT="yes" ;;
|
||||
--project=*) INIT_PROJECT="${arg#--project=}" ;;
|
||||
--project) PREV_ARG="--project" ;;
|
||||
--no-init) RUN_INIT="no" ;;
|
||||
--with-init) RUN_INIT="yes" ;;
|
||||
--no-symlink) SKIP_SYMLINK="yes" ;;
|
||||
--force-symlink) FORCE_SYMLINK="yes" ;;
|
||||
--project=*) INIT_PROJECT="${arg#--project=}" ;;
|
||||
--project) PREV_ARG="--project" ;;
|
||||
*) echo "WARN: unknown argument '$arg' (ignored)" ;;
|
||||
esac
|
||||
done
|
||||
@@ -218,8 +224,8 @@ fi
|
||||
echo "Upgrading pip ..."
|
||||
"$VENV_PYTHON" -m pip install --upgrade pip --quiet
|
||||
|
||||
echo "Installing aipass in editable mode (with dev + memory extras) ..."
|
||||
"$VENV_PYTHON" -m pip install -e ".[dev,memory]" --quiet
|
||||
echo "Installing aipass in editable mode (with dev + memory extras) — this can take a few minutes while the memory wheels build ..."
|
||||
"$VENV_PYTHON" -m pip install -e ".[dev,memory]"
|
||||
|
||||
# --- Detect shadowing drone installs (Windows) ---
|
||||
# Issues #317 + #321: system-Python pip or legacy npm aipass-drone can shadow venv drone.exe.
|
||||
@@ -662,6 +668,7 @@ else:
|
||||
# UserPromptSubmit: 5 separate entries (EventType:hook_name) to avoid output merging
|
||||
# PreToolUse, PostToolUse, SubagentStop, Stop, Notification: single aggregate entries
|
||||
# PreCompact: 3 hooks x 2 matchers (manual + auto) = 6 entries
|
||||
# SessionStart: cadence reset on startup/clear (handler skips resume itself)
|
||||
aipass_hooks = {
|
||||
"UserPromptSubmit": [
|
||||
{"hooks": [{"type": "command", "command": f"{bridge} UserPromptSubmit:tier0_kernel"}]},
|
||||
@@ -696,6 +703,9 @@ aipass_hooks = {
|
||||
{"matcher": "manual", "hooks": [{"type": "command", "command": f"{bridge} PreCompact:auto_process", "timeout": 120}]},
|
||||
{"matcher": "auto", "hooks": [{"type": "command", "command": f"{bridge} PreCompact:auto_process", "timeout": 120}]},
|
||||
],
|
||||
"SessionStart": [
|
||||
{"hooks": [{"type": "command", "command": f"{bridge} SessionStart:cadence_reset", "timeout": 30}]},
|
||||
],
|
||||
}
|
||||
|
||||
# Merge, don't replace (DPLAN-0234 Strand C): refresh every AIPass bridge entry
|
||||
@@ -708,7 +718,15 @@ for event in set(existing_hooks) | set(aipass_hooks):
|
||||
entry for entry in existing_hooks.get(event, [])
|
||||
if "bridges/claude.py" not in json.dumps(entry)
|
||||
]
|
||||
merged_hooks[event] = aipass_hooks.get(event, []) + user_entries
|
||||
merged = aipass_hooks.get(event, []) + user_entries
|
||||
# Never emit an empty hook event. If this event had only stale AIPass bridge
|
||||
# entries (no current aipass_hooks definition AND no user-wired hooks), the
|
||||
# filter above orphans it to [] — a half-wired event that fires nothing,
|
||||
# written silently. Drop the key instead and say so, so the state stays honest.
|
||||
if not merged:
|
||||
print(f" ! dropped orphaned hook event (no live entries): {event}")
|
||||
continue
|
||||
merged_hooks[event] = merged
|
||||
settings["hooks"] = merged_hooks
|
||||
|
||||
# Inject AIPASS_HOME into env block so dispatched agents find AIPass
|
||||
@@ -786,6 +804,16 @@ else
|
||||
echo "Skipping Claude hooks (bridge not found at src/aipass/hooks/apps/handlers/bridges/claude.py)"
|
||||
fi
|
||||
|
||||
# --- Install claude() boot shim (attach-if-live / start-in-tmux) ---
|
||||
# Ships via the .gitignore negation (#666). Idempotent: the installer checks for
|
||||
# its marker before appending to the shell rc, and resolves the venv Python from
|
||||
# its own location (POSIX/Windows/fallback). Composes with presence_gate seeding.
|
||||
BOOT_SHIM="$SCRIPT_DIR/src/aipass/hooks/tools/install_boot_shim.sh"
|
||||
if [ -f "$BOOT_SHIM" ]; then
|
||||
echo "Installing claude() boot shim ..."
|
||||
bash "$BOOT_SHIM" || echo " boot shim install skipped (non-fatal)"
|
||||
fi
|
||||
|
||||
# --- Install Claude Code commands (provider level) ---
|
||||
# memo.md belongs at provider level — works in all projects.
|
||||
# prep.md stays at repo root only — it's AIPass-specific.
|
||||
@@ -952,8 +980,42 @@ else
|
||||
fi
|
||||
|
||||
# --- Create global symlinks for CLI tools (Linux/macOS only) ---
|
||||
# #660: never SILENTLY hijack a global 'drone'/'aipass' that points at a
|
||||
# DIFFERENT install. safe_symlink skips a different-target link (loud warning)
|
||||
# unless --force-symlink; --no-symlink opts out of symlinking entirely.
|
||||
SYMLINK_SKIPPED=0
|
||||
safe_symlink() {
|
||||
# safe_symlink <src> <dest> [sudo] -> 0 linked · 1 skipped(diff target) · 2 ln failed
|
||||
local src="$1" dest="$2" use_sudo="${3:-}" existing=""
|
||||
if [ -L "$dest" ]; then
|
||||
existing="$(readlink "$dest" 2>/dev/null)"
|
||||
elif [ -e "$dest" ]; then
|
||||
existing="$dest (real file, not a symlink)"
|
||||
fi
|
||||
if [ -n "$existing" ] && [ "$existing" != "$src" ]; then
|
||||
if [ "$FORCE_SYMLINK" != "yes" ]; then
|
||||
echo " SKIP $dest — already points at a different install:"
|
||||
echo " $existing"
|
||||
echo " Not repointing (would hijack your existing '$(basename "$dest")'); PATH keeps the above."
|
||||
echo " Re-run 'aipass install' with --force-symlink to repoint here, or --no-symlink to skip quietly."
|
||||
SYMLINK_SKIPPED=$((SYMLINK_SKIPPED + 1))
|
||||
return 1
|
||||
fi
|
||||
echo " WARNING: repointing $dest"
|
||||
echo " from $existing"
|
||||
echo " to $src (--force-symlink)"
|
||||
fi
|
||||
if [ -n "$use_sudo" ]; then
|
||||
$use_sudo ln -sf "$src" "$dest" 2>/dev/null && return 0 || return 2
|
||||
fi
|
||||
ln -sf "$src" "$dest" 2>/dev/null && return 0 || return 2
|
||||
}
|
||||
|
||||
echo ""
|
||||
if [ "$IS_WINDOWS" -eq 1 ]; then
|
||||
if [ "$SKIP_SYMLINK" = "yes" ]; then
|
||||
echo "Skipping global CLI symlinks (--no-symlink)."
|
||||
echo " 'drone'/'aipass' resolve from $SCRIPT_DIR/.venv/bin — add it to PATH to use them."
|
||||
elif [ "$IS_WINDOWS" -eq 1 ]; then
|
||||
echo "Windows: drone available via PATH (set above)"
|
||||
elif [ "$IS_MACOS" -eq 1 ]; then
|
||||
# Mac: symlink into ~/.local/bin (user-writable, no sudo needed).
|
||||
@@ -965,9 +1027,11 @@ elif [ "$IS_MACOS" -eq 1 ]; then
|
||||
|
||||
for cmd in drone aipass; do
|
||||
if [ -f "$VENV_BIN/$cmd" ]; then
|
||||
if ln -sf "$VENV_BIN/$cmd" "$LOCAL_BIN/$cmd"; then
|
||||
rc=0
|
||||
safe_symlink "$VENV_BIN/$cmd" "$LOCAL_BIN/$cmd" || rc=$?
|
||||
if [ "$rc" -eq 0 ]; then
|
||||
echo " $LOCAL_BIN/$cmd -> $VENV_BIN/$cmd"
|
||||
else
|
||||
elif [ "$rc" -eq 2 ]; then
|
||||
echo " WARN: Could not create symlink for $cmd"
|
||||
echo " Manual fix: ln -sf $VENV_BIN/$cmd $LOCAL_BIN/$cmd"
|
||||
fi
|
||||
@@ -980,14 +1044,20 @@ else
|
||||
|
||||
for cmd in drone aipass; do
|
||||
if [ -f "$VENV_BIN/$cmd" ]; then
|
||||
if sudo ln -sf "$VENV_BIN/$cmd" "/usr/local/bin/$cmd" 2>/dev/null; then
|
||||
rc=0
|
||||
safe_symlink "$VENV_BIN/$cmd" "/usr/local/bin/$cmd" "sudo" || rc=$?
|
||||
if [ "$rc" -eq 0 ]; then
|
||||
echo " /usr/local/bin/$cmd -> $VENV_BIN/$cmd"
|
||||
LINUX_SYMLINK_DIR="/usr/local/bin"
|
||||
elif [ "$rc" -eq 1 ]; then
|
||||
: # skipped a different install — safe_symlink explained; do NOT fall back
|
||||
else
|
||||
# Fallback: user-local bin (no sudo needed)
|
||||
# sudo/ln failed (e.g. no sudo) — fall back to user-local bin
|
||||
LOCAL_BIN="$HOME/.local/bin"
|
||||
mkdir -p "$LOCAL_BIN"
|
||||
if ln -sf "$VENV_BIN/$cmd" "$LOCAL_BIN/$cmd"; then
|
||||
rc=0
|
||||
safe_symlink "$VENV_BIN/$cmd" "$LOCAL_BIN/$cmd" || rc=$?
|
||||
if [ "$rc" -eq 0 ]; then
|
||||
echo " /usr/local/bin failed (no sudo) — using $LOCAL_BIN/$cmd instead"
|
||||
LINUX_SYMLINK_DIR="$LOCAL_BIN"
|
||||
# Ensure ~/.local/bin is on PATH
|
||||
@@ -997,7 +1067,7 @@ else
|
||||
echo " ~/.local/bin added to PATH in $PROFILE"
|
||||
fi
|
||||
export PATH="$HOME/.local/bin:$PATH"
|
||||
else
|
||||
elif [ "$rc" -eq 2 ]; then
|
||||
echo " WARN: Could not create symlink for $cmd"
|
||||
echo " Manual fix: ln -sf $VENV_BIN/$cmd $LOCAL_BIN/$cmd"
|
||||
fi
|
||||
@@ -1006,6 +1076,12 @@ else
|
||||
done
|
||||
fi
|
||||
|
||||
if [ "$SYMLINK_SKIPPED" -gt 0 ]; then
|
||||
echo ""
|
||||
echo " NOTE: $SYMLINK_SKIPPED global symlink(s) left untouched (pointed at a different install)."
|
||||
echo " Your existing 'drone'/'aipass' still work. Use --force-symlink to repoint them here."
|
||||
fi
|
||||
|
||||
# --- Result ---
|
||||
echo ""
|
||||
if [ "$FAIL" -eq 0 ]; then
|
||||
|
||||
@@ -3,4 +3,4 @@
|
||||
git clone + ./setup.sh — https://github.com/AIOSAI/AIPass
|
||||
"""
|
||||
|
||||
__version__ = "2.6.1"
|
||||
__version__ = "2.7.0"
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
{
|
||||
"file": "apps/handlers/dispatch/daemon.py",
|
||||
"standard": "deep_nesting",
|
||||
"reason": "3 functions: check_inbox_for_dispatch() depth 4 (priority scanning with business logic), run_daemon() depth 4 (main daemon loop), _check_lock() depth 4 (lock validation + PID liveness + cleanup)"
|
||||
"reason": "run_daemon() depth 5 (main daemon loop with retry + signal handling)"
|
||||
},
|
||||
{
|
||||
"file": "apps/handlers/dispatch/wake.py",
|
||||
|
||||
@@ -243,6 +243,13 @@ def main():
|
||||
error("No modules found")
|
||||
return 1
|
||||
|
||||
if remaining_args and remaining_args[0] in ["--help", "-h"]:
|
||||
for module in modules:
|
||||
if module.handle_command(command, ["--help"]):
|
||||
return 0
|
||||
print_help()
|
||||
return 0
|
||||
|
||||
# Route command
|
||||
if route_command(command, remaining_args, modules):
|
||||
return 0
|
||||
|
||||
@@ -349,5 +349,7 @@ if __name__ == "__main__":
|
||||
console.print()
|
||||
|
||||
except Exception as e:
|
||||
console.print(f"[red]Error:[/red] {e}")
|
||||
from aipass.cli.apps.modules import error as cli_error
|
||||
|
||||
cli_error(f"Error: {e}")
|
||||
raise
|
||||
|
||||
@@ -86,6 +86,56 @@ def _write_json(filepath: Path, data: Dict[str, Any]) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def _pid_alive_windows(pid: int) -> bool:
|
||||
"""Windows-safe liveness check via OpenProcess + GetExitCodeProcess."""
|
||||
import ctypes
|
||||
from ctypes import wintypes
|
||||
|
||||
PROCESS_QUERY_LIMITED_INFORMATION = 0x1000
|
||||
STILL_ACTIVE = 259
|
||||
|
||||
kernel32 = ctypes.windll.kernel32 # type: ignore[attr-defined]
|
||||
kernel32.OpenProcess.argtypes = [wintypes.DWORD, wintypes.BOOL, wintypes.DWORD]
|
||||
kernel32.OpenProcess.restype = wintypes.HANDLE
|
||||
kernel32.GetExitCodeProcess.argtypes = [wintypes.HANDLE, ctypes.POINTER(wintypes.DWORD)]
|
||||
kernel32.GetExitCodeProcess.restype = wintypes.BOOL
|
||||
kernel32.CloseHandle.argtypes = [wintypes.HANDLE]
|
||||
kernel32.CloseHandle.restype = wintypes.BOOL
|
||||
|
||||
handle = kernel32.OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, False, pid)
|
||||
if not handle:
|
||||
return False
|
||||
try:
|
||||
exit_code = wintypes.DWORD()
|
||||
if not kernel32.GetExitCodeProcess(handle, ctypes.byref(exit_code)):
|
||||
return False
|
||||
return exit_code.value == STILL_ACTIVE
|
||||
finally:
|
||||
kernel32.CloseHandle(handle)
|
||||
|
||||
|
||||
def _pid_alive(pid: int) -> bool:
|
||||
"""Return True if the process is alive."""
|
||||
if sys.platform == "win32":
|
||||
try:
|
||||
return _pid_alive_windows(pid)
|
||||
except Exception as exc:
|
||||
logger.info("[daemon] PID %s Windows check failed (assuming alive): %s", pid, exc)
|
||||
return True
|
||||
try:
|
||||
os.kill(pid, 0)
|
||||
except ProcessLookupError as exc:
|
||||
logger.info("[daemon] PID %s not found: %s", pid, exc)
|
||||
return False
|
||||
except PermissionError as exc:
|
||||
logger.info("[daemon] PID %s permission denied (alive): %s", pid, exc)
|
||||
return True
|
||||
except OSError as exc:
|
||||
logger.info("[daemon] PID %s os.kill error (assuming dead): %s", pid, exc)
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _check_lock(branch_path: Path) -> Optional[Dict[str, Any]]:
|
||||
"""Check if branch has an active dispatch lock. Returns lock data or None."""
|
||||
lock_file = branch_path / ".ai_mail.local" / ".dispatch.lock"
|
||||
@@ -96,14 +146,9 @@ def _check_lock(branch_path: Path) -> Optional[Dict[str, Any]]:
|
||||
data = json.load(f)
|
||||
pid = data.get("pid")
|
||||
if pid is not None:
|
||||
try:
|
||||
os.kill(pid, 0)
|
||||
return data # Process alive, lock valid
|
||||
except ProcessLookupError:
|
||||
logger.info("Lock PID %s dead — stale lock cleanup needed", pid)
|
||||
except PermissionError as e:
|
||||
logger.warning("[daemon] Lock PID %s permission error: %s", pid, e)
|
||||
return data # Process exists, can't signal
|
||||
if _pid_alive(pid):
|
||||
return data
|
||||
logger.info("Lock PID %s dead — stale lock cleanup needed", pid)
|
||||
# Stale lock — check age (10 min timeout)
|
||||
ts = data.get("timestamp", "")
|
||||
if ts:
|
||||
@@ -214,15 +259,10 @@ def _write_pid_file() -> bool:
|
||||
# PID file exists — check if the owning process is alive
|
||||
try:
|
||||
old_pid = int(DAEMON_PID_FILE.read_text().strip())
|
||||
try:
|
||||
os.kill(old_pid, 0)
|
||||
logger.info(f"Another daemon already running (PID {old_pid}). Exiting.")
|
||||
return False
|
||||
except ProcessLookupError:
|
||||
logger.info(f"Removing stale PID file (PID {old_pid} is dead)")
|
||||
except PermissionError:
|
||||
logger.info(f"Another daemon already running (PID {old_pid}, permission denied). Exiting.")
|
||||
if _pid_alive(old_pid):
|
||||
logger.info("Another daemon already running (PID %s). Exiting.", old_pid)
|
||||
return False
|
||||
logger.info("Removing stale PID file (PID %s is dead)", old_pid)
|
||||
except (ValueError, OSError):
|
||||
logger.info("Corrupt PID file — removing")
|
||||
|
||||
@@ -474,18 +514,74 @@ def is_protected_branch(branch_email: str) -> bool:
|
||||
return branch_email == "@devpulse"
|
||||
|
||||
|
||||
def _read_session_type(pid_str: str) -> str:
|
||||
"""Read AIPASS_SESSION_TYPE from /proc/{pid}/environ. Returns 'interactive' if unset."""
|
||||
if sys.platform != "linux":
|
||||
return "interactive"
|
||||
def _get_pid_cwd(pid_str: str) -> Optional[str]:
|
||||
"""Get the cwd of a process. Cross-platform: Linux /proc, macOS lsof."""
|
||||
if sys.platform == "linux":
|
||||
try:
|
||||
return os.readlink(f"/proc/{pid_str}/cwd")
|
||||
except (OSError, PermissionError):
|
||||
logger.info("[daemon] Cannot read cwd for PID %s", pid_str)
|
||||
return None
|
||||
if sys.platform == "darwin":
|
||||
return _get_pid_cwd_darwin(pid_str)
|
||||
logger.info("[daemon] Cannot determine cwd for PID %s on %s", pid_str, sys.platform)
|
||||
return None
|
||||
|
||||
|
||||
def _get_pid_cwd_darwin(pid_str: str) -> Optional[str]:
|
||||
"""macOS: get process cwd via lsof."""
|
||||
try:
|
||||
with open(f"/proc/{pid_str}/environ", "rb") as f:
|
||||
data = f.read()
|
||||
for entry in data.split(b"\0"):
|
||||
if entry.startswith(b"AIPASS_SESSION_TYPE="):
|
||||
return entry.split(b"=", 1)[1].decode("utf-8")
|
||||
except (OSError, PermissionError):
|
||||
logger.info("Cannot read session type for PID %s", pid_str)
|
||||
result = subprocess.run(
|
||||
["lsof", "-a", "-p", pid_str, "-d", "cwd", "-Fn"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5,
|
||||
)
|
||||
except (subprocess.SubprocessError, OSError):
|
||||
logger.info("[daemon] Cannot read cwd for PID %s on macOS", pid_str)
|
||||
return None
|
||||
if result.returncode != 0:
|
||||
return None
|
||||
for line in result.stdout.strip().split("\n"):
|
||||
if line.startswith("n/"):
|
||||
return line[1:]
|
||||
return None
|
||||
|
||||
|
||||
def _read_session_type(pid_str: str) -> str:
|
||||
"""Read AIPASS_SESSION_TYPE from process environment. Returns 'interactive' if unset."""
|
||||
if sys.platform == "linux":
|
||||
try:
|
||||
with open(f"/proc/{pid_str}/environ", "rb") as f:
|
||||
data = f.read()
|
||||
for entry in data.split(b"\0"):
|
||||
if entry.startswith(b"AIPASS_SESSION_TYPE="):
|
||||
return entry.split(b"=", 1)[1].decode("utf-8")
|
||||
except (OSError, PermissionError):
|
||||
logger.info("[daemon] Cannot read session type for PID %s", pid_str)
|
||||
return "interactive"
|
||||
if sys.platform == "darwin":
|
||||
return _read_session_type_darwin(pid_str)
|
||||
return "interactive"
|
||||
|
||||
|
||||
def _read_session_type_darwin(pid_str: str) -> str:
|
||||
"""macOS: read AIPASS_SESSION_TYPE from ps environment output."""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["ps", "-p", pid_str, "-wwE", "-o", "command="],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5,
|
||||
)
|
||||
except (subprocess.SubprocessError, OSError):
|
||||
logger.info("[daemon] Cannot read session type for PID %s on macOS", pid_str)
|
||||
return "interactive"
|
||||
if result.returncode != 0:
|
||||
return "interactive"
|
||||
for token in result.stdout.split():
|
||||
if token.startswith("AIPASS_SESSION_TYPE="):
|
||||
return token.split("=", 1)[1]
|
||||
return "interactive"
|
||||
|
||||
|
||||
@@ -494,35 +590,25 @@ _NON_BLOCKING_SESSION_TYPES = {"dispatched", "daemon"}
|
||||
|
||||
|
||||
def _is_branch_occupied(branch_path: Path) -> bool:
|
||||
"""
|
||||
Check if an interactive Claude session is running in this branch.
|
||||
|
||||
Only interactive sessions block dispatch. Telegram, dispatched, and daemon
|
||||
sessions are idle/background and should not prevent new agent spawns.
|
||||
"""
|
||||
resolved = branch_path.resolve()
|
||||
"""Check if an interactive Claude session is running in this branch."""
|
||||
resolved = str(branch_path.resolve())
|
||||
try:
|
||||
result = subprocess.run(["pgrep", "-x", "claude"], capture_output=True, text=True, timeout=5)
|
||||
if result.returncode != 0:
|
||||
return False
|
||||
|
||||
for pid_str in result.stdout.strip().split("\n"):
|
||||
pid_str = pid_str.strip()
|
||||
if not pid_str:
|
||||
continue
|
||||
try:
|
||||
if sys.platform != "linux":
|
||||
continue
|
||||
cwd = os.readlink(f"/proc/{pid_str}/cwd")
|
||||
if Path(cwd).resolve() == resolved:
|
||||
session_type = _read_session_type(pid_str)
|
||||
if session_type not in _NON_BLOCKING_SESSION_TYPES:
|
||||
return True
|
||||
except (OSError, PermissionError, ValueError):
|
||||
logger.info("Cannot read cwd for PID %s", pid_str)
|
||||
cwd = _get_pid_cwd(pid_str)
|
||||
if cwd is None:
|
||||
continue
|
||||
if str(Path(cwd).resolve()) == resolved:
|
||||
session_type = _read_session_type(pid_str)
|
||||
if session_type not in _NON_BLOCKING_SESSION_TYPES:
|
||||
return True
|
||||
except Exception:
|
||||
logger.info("Failed to check branch occupancy for %s", branch_path)
|
||||
logger.info("[daemon] Failed to check branch occupancy for %s", branch_path)
|
||||
return False
|
||||
|
||||
|
||||
|
||||
@@ -80,6 +80,86 @@ def _connect_broker(repo_root: Path, branch_name: str) -> socket.socket:
|
||||
return create_identified_connection(socket_path, secret_path, branch_name)
|
||||
|
||||
|
||||
MAX_WAKE_DEPTH = 3
|
||||
|
||||
|
||||
def _wake_sender(sender: str, branch_email: str, exit_code: int, lock_file: str) -> str:
|
||||
"""Wake the dispatcher back after target completion.
|
||||
|
||||
Wake-back is owner-only: only the project owner (sealed registry)
|
||||
gets woken. Non-owners silently skipped.
|
||||
|
||||
Returns a result tag for the dispatch_wake.log:
|
||||
success, blocked_occupied, blocked_locked, blocked_depth,
|
||||
skipped_sender, skipped_not_owner, failed
|
||||
"""
|
||||
if not sender or not sender.strip():
|
||||
logger.info("[monitor] Wake-back skipped — no sender")
|
||||
return "skipped_sender"
|
||||
|
||||
normalized = f"@{sender.lstrip('@').lower()}"
|
||||
|
||||
try:
|
||||
from aipass.spawn.apps.handlers.registry import is_owner
|
||||
except ImportError:
|
||||
logger.warning("[monitor] Wake-back skipped — is_owner import failed")
|
||||
return "failed"
|
||||
|
||||
if not is_owner(normalized):
|
||||
logger.info("[monitor] Wake-back skipped — sender %s is not project owner", sender)
|
||||
return "skipped_not_owner"
|
||||
|
||||
depth = int(os.environ.get("AIPASS_WAKE_DEPTH", "0"))
|
||||
if depth >= MAX_WAKE_DEPTH:
|
||||
logger.warning("[monitor] Wake-back skipped — depth %d >= max %d", depth, MAX_WAKE_DEPTH)
|
||||
return "blocked_depth"
|
||||
|
||||
try:
|
||||
from aipass.ai_mail.apps.handlers.dispatch.wake import wake_branch
|
||||
|
||||
os.environ["AIPASS_WAKE_DEPTH"] = str(depth + 1)
|
||||
wake_status, success = wake_branch(sender, auto=True, sender="@ai_mail")
|
||||
|
||||
if success:
|
||||
logger.info("[monitor] Wake-back: %s woken after %s completed (exit %d)", sender, branch_email, exit_code)
|
||||
return "success"
|
||||
|
||||
summary = wake_status.summary
|
||||
lower = summary.lower()
|
||||
if "interactive" in lower or "occupancy" in lower or "occupied" in lower:
|
||||
logger.info("[monitor] Wake-back blocked — sender %s has interactive session: %s", sender, summary)
|
||||
return "blocked_occupied"
|
||||
if "active agent" in lower or "lock" in lower:
|
||||
logger.info("[monitor] Wake-back blocked — sender %s has active lock: %s", sender, summary)
|
||||
return "blocked_locked"
|
||||
|
||||
logger.info("[monitor] Wake-back: %s not woken — %s", sender, summary)
|
||||
return "failed"
|
||||
except Exception as e:
|
||||
logger.warning("[monitor] Wake-back failed for %s: %s", sender, e)
|
||||
return "failed"
|
||||
|
||||
|
||||
def _log_wake_result(branch_email: str, sender: str, exit_code: int, result: str, lock_file: str):
|
||||
"""Append a wake-back result line to dispatch_wake.log under target's logs/."""
|
||||
lock_path = Path(lock_file).resolve()
|
||||
logs_dir = lock_path.parent.parent / "logs"
|
||||
log_file = logs_dir / "dispatch_wake.log"
|
||||
try:
|
||||
logs_dir.mkdir(parents=True, exist_ok=True)
|
||||
line = (
|
||||
f"{time.strftime('%Y-%m-%dT%H:%M:%S')}"
|
||||
f" target={branch_email}"
|
||||
f" sender={sender}"
|
||||
f" exit_code={exit_code}"
|
||||
f" wake_result={result}\n"
|
||||
)
|
||||
with open(log_file, "a", encoding="utf-8") as f:
|
||||
f.write(line)
|
||||
except OSError as e:
|
||||
logger.info("[monitor] Failed to write dispatch_wake.log: %s", e)
|
||||
|
||||
|
||||
def _send_bounce(branch_email: str, reason: str, sender: str, lock_file: str, stderr_log: str) -> bool:
|
||||
"""Send return-to-sender bounce email via drone."""
|
||||
subject = f"BOUNCE: Dispatch to {branch_email} failed"
|
||||
@@ -585,6 +665,10 @@ def main():
|
||||
except Exception:
|
||||
logger.info("[monitor] Desktop notification unavailable")
|
||||
|
||||
# ─── Wake-back: wake the dispatcher ────────────────────
|
||||
wake_result = _wake_sender(sender, branch_email, exit_code, lock_file)
|
||||
_log_wake_result(branch_email, sender, exit_code, wake_result, lock_file)
|
||||
|
||||
sys.exit(0 if exit_code == 0 else 1)
|
||||
|
||||
|
||||
|
||||
@@ -141,6 +141,34 @@ def _read_json(filepath: Path) -> Optional[dict]:
|
||||
return None
|
||||
|
||||
|
||||
def _pid_alive_windows(pid: int) -> bool:
|
||||
"""Windows-safe liveness check via OpenProcess + GetExitCodeProcess."""
|
||||
import ctypes
|
||||
from ctypes import wintypes
|
||||
|
||||
PROCESS_QUERY_LIMITED_INFORMATION = 0x1000
|
||||
STILL_ACTIVE = 259
|
||||
|
||||
kernel32 = ctypes.windll.kernel32 # type: ignore[attr-defined]
|
||||
kernel32.OpenProcess.argtypes = [wintypes.DWORD, wintypes.BOOL, wintypes.DWORD]
|
||||
kernel32.OpenProcess.restype = wintypes.HANDLE
|
||||
kernel32.GetExitCodeProcess.argtypes = [wintypes.HANDLE, ctypes.POINTER(wintypes.DWORD)]
|
||||
kernel32.GetExitCodeProcess.restype = wintypes.BOOL
|
||||
kernel32.CloseHandle.argtypes = [wintypes.HANDLE]
|
||||
kernel32.CloseHandle.restype = wintypes.BOOL
|
||||
|
||||
handle = kernel32.OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, False, pid)
|
||||
if not handle:
|
||||
return False
|
||||
try:
|
||||
exit_code = wintypes.DWORD()
|
||||
if not kernel32.GetExitCodeProcess(handle, ctypes.byref(exit_code)):
|
||||
return False
|
||||
return exit_code.value == STILL_ACTIVE
|
||||
finally:
|
||||
kernel32.CloseHandle(handle)
|
||||
|
||||
|
||||
def _check_lock(branch_path: Path) -> Optional[dict]:
|
||||
"""Check if branch has an active dispatch lock. Returns lock data or None."""
|
||||
lock_file = branch_path / ".ai_mail.local" / ".dispatch.lock"
|
||||
@@ -151,14 +179,9 @@ def _check_lock(branch_path: Path) -> Optional[dict]:
|
||||
data = json.load(f)
|
||||
pid = data.get("pid")
|
||||
if pid is not None:
|
||||
try:
|
||||
os.kill(pid, 0)
|
||||
return data # Process alive, lock valid
|
||||
except ProcessLookupError:
|
||||
logger.info("[wake] Lock PID %s dead — cleaning stale lock", pid)
|
||||
except PermissionError as e:
|
||||
logger.warning("[wake] Lock PID %s permission error: %s", pid, e)
|
||||
return data # Process exists but can't signal — treat as active
|
||||
if _check_pid_alive(pid):
|
||||
return data
|
||||
logger.info("[wake] Lock PID %s dead — cleaning stale lock", pid)
|
||||
# Stale lock — check age (10 min timeout)
|
||||
ts = data.get("timestamp", "")
|
||||
if ts:
|
||||
@@ -210,18 +233,74 @@ def _load_config() -> dict:
|
||||
return config
|
||||
|
||||
|
||||
def _read_session_type(pid_str: str) -> str:
|
||||
"""Read AIPASS_SESSION_TYPE from /proc/{pid}/environ. Returns 'interactive' if unset."""
|
||||
if sys.platform != "linux":
|
||||
return "interactive"
|
||||
def _get_pid_cwd(pid_str: str) -> Optional[str]:
|
||||
"""Get the cwd of a process. Cross-platform: Linux /proc, macOS lsof."""
|
||||
if sys.platform == "linux":
|
||||
try:
|
||||
return os.readlink(f"/proc/{pid_str}/cwd")
|
||||
except (OSError, PermissionError):
|
||||
logger.info("[wake] Cannot read cwd for PID %s", pid_str)
|
||||
return None
|
||||
if sys.platform == "darwin":
|
||||
return _get_pid_cwd_darwin(pid_str)
|
||||
logger.info("[wake] Cannot determine cwd for PID %s on %s", pid_str, sys.platform)
|
||||
return None
|
||||
|
||||
|
||||
def _get_pid_cwd_darwin(pid_str: str) -> Optional[str]:
|
||||
"""macOS: get process cwd via lsof."""
|
||||
try:
|
||||
with open(f"/proc/{pid_str}/environ", "rb") as f:
|
||||
data = f.read()
|
||||
for entry in data.split(b"\0"):
|
||||
if entry.startswith(b"AIPASS_SESSION_TYPE="):
|
||||
return entry.split(b"=", 1)[1].decode("utf-8")
|
||||
except (OSError, PermissionError):
|
||||
logger.info("[wake] Cannot read session type for PID %s", pid_str)
|
||||
result = subprocess.run(
|
||||
["lsof", "-a", "-p", pid_str, "-d", "cwd", "-Fn"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5,
|
||||
)
|
||||
except (subprocess.SubprocessError, OSError):
|
||||
logger.info("[wake] Cannot read cwd for PID %s on macOS", pid_str)
|
||||
return None
|
||||
if result.returncode != 0:
|
||||
return None
|
||||
for line in result.stdout.strip().split("\n"):
|
||||
if line.startswith("n/"):
|
||||
return line[1:]
|
||||
return None
|
||||
|
||||
|
||||
def _read_session_type(pid_str: str) -> str:
|
||||
"""Read AIPASS_SESSION_TYPE from process environment. Returns 'interactive' if unset."""
|
||||
if sys.platform == "linux":
|
||||
try:
|
||||
with open(f"/proc/{pid_str}/environ", "rb") as f:
|
||||
data = f.read()
|
||||
for entry in data.split(b"\0"):
|
||||
if entry.startswith(b"AIPASS_SESSION_TYPE="):
|
||||
return entry.split(b"=", 1)[1].decode("utf-8")
|
||||
except (OSError, PermissionError):
|
||||
logger.info("[wake] Cannot read session type for PID %s", pid_str)
|
||||
return "interactive"
|
||||
if sys.platform == "darwin":
|
||||
return _read_session_type_darwin(pid_str)
|
||||
return "interactive"
|
||||
|
||||
|
||||
def _read_session_type_darwin(pid_str: str) -> str:
|
||||
"""macOS: read AIPASS_SESSION_TYPE from ps environment output."""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["ps", "-p", pid_str, "-wwE", "-o", "command="],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5,
|
||||
)
|
||||
except (subprocess.SubprocessError, OSError):
|
||||
logger.info("[wake] Cannot read session type for PID %s on macOS", pid_str)
|
||||
return "interactive"
|
||||
if result.returncode != 0:
|
||||
return "interactive"
|
||||
for token in result.stdout.split():
|
||||
if token.startswith("AIPASS_SESSION_TYPE="):
|
||||
return token.split("=", 1)[1]
|
||||
return "interactive"
|
||||
|
||||
|
||||
@@ -240,17 +319,13 @@ def _is_branch_occupied(branch_path: Path) -> bool:
|
||||
pid_str = pid_str.strip()
|
||||
if not pid_str:
|
||||
continue
|
||||
try:
|
||||
if sys.platform != "linux":
|
||||
continue
|
||||
cwd = os.readlink(f"/proc/{pid_str}/cwd")
|
||||
if str(Path(cwd).resolve()) == resolved:
|
||||
session_type = _read_session_type(pid_str)
|
||||
if session_type not in _NON_BLOCKING_SESSION_TYPES:
|
||||
return True
|
||||
except (OSError, PermissionError, ValueError):
|
||||
logger.info("[wake] Cannot read cwd for PID %s", pid_str)
|
||||
cwd = _get_pid_cwd(pid_str)
|
||||
if cwd is None:
|
||||
continue
|
||||
if str(Path(cwd).resolve()) == resolved:
|
||||
session_type = _read_session_type(pid_str)
|
||||
if session_type not in _NON_BLOCKING_SESSION_TYPES:
|
||||
return True
|
||||
except (subprocess.SubprocessError, OSError):
|
||||
logger.info("[wake] Failed to check branch occupancy")
|
||||
return False
|
||||
@@ -273,21 +348,38 @@ def _clean_zombies() -> int:
|
||||
|
||||
def _check_pid_alive(pid: int) -> bool:
|
||||
"""Check if a process is alive (not zombie)."""
|
||||
if sys.platform == "win32":
|
||||
try:
|
||||
return _pid_alive_windows(pid)
|
||||
except Exception as exc:
|
||||
logger.info("[wake] PID %s Windows check failed (assuming alive): %s", pid, exc)
|
||||
return True
|
||||
try:
|
||||
os.kill(pid, 0)
|
||||
# Also verify not zombie via /proc (Linux only)
|
||||
if sys.platform == "linux":
|
||||
with open(f"/proc/{pid}/status", "r") as f:
|
||||
for line in f:
|
||||
if line.startswith("State:"):
|
||||
return "Z" not in line
|
||||
return True
|
||||
except (ProcessLookupError, FileNotFoundError) as e:
|
||||
logger.warning("[wake] PID %s not found: %s", pid, e)
|
||||
except ProcessLookupError as exc:
|
||||
logger.warning("[wake] PID %s not found: %s", pid, exc)
|
||||
return False
|
||||
except PermissionError as e:
|
||||
logger.warning("[wake] PID %s permission denied: %s", pid, e)
|
||||
return True # Exists but can't check — assume alive
|
||||
except PermissionError as exc:
|
||||
logger.warning("[wake] PID %s permission denied: %s", pid, exc)
|
||||
return True
|
||||
except OSError as exc:
|
||||
logger.warning("[wake] PID %s os.kill error (assuming dead): %s", pid, exc)
|
||||
return False
|
||||
if sys.platform == "linux" and _is_zombie_linux(pid):
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _is_zombie_linux(pid: int) -> bool:
|
||||
"""Return True if PID is a zombie (Linux /proc/status check)."""
|
||||
try:
|
||||
with open(f"/proc/{pid}/status", "r") as f:
|
||||
for line in f:
|
||||
if line.startswith("State:"):
|
||||
return "Z" in line
|
||||
except FileNotFoundError as exc:
|
||||
logger.warning("[wake] PID %s /proc not found: %s", pid, exc)
|
||||
return False
|
||||
|
||||
|
||||
def _spawn_in_systemd_scope(monitor_cmd, branch_path, spawn_env, branch_email, lock_file_path, custom_message, status):
|
||||
|
||||
@@ -173,10 +173,10 @@ if __name__ == "__main__":
|
||||
console.print(" - format_email_list_item(index, email_data, show_unread) -> str")
|
||||
console.print()
|
||||
console.print("HANDLER CHARACTERISTICS:")
|
||||
console.print(" ✓ Independent - no module dependencies")
|
||||
console.print(" ✓ Can import Prax (service provider)")
|
||||
console.print(" ✓ Pure business logic")
|
||||
console.print(" ✗ CANNOT import parent modules")
|
||||
console.print(" [green]+[/green] Independent - no module dependencies")
|
||||
console.print(" [green]+[/green] Can import Prax (service provider)")
|
||||
console.print(" [green]+[/green] Pure business logic")
|
||||
console.print(" [dim]-[/dim] CANNOT import parent modules")
|
||||
console.print()
|
||||
console.print("USAGE FROM MODULES:")
|
||||
console.print(" from ai_mail.apps.handlers.email.format import format_email_preview")
|
||||
|
||||
@@ -134,10 +134,10 @@ if __name__ == "__main__":
|
||||
console.print(" - load_inbox(inbox_file) -> Dict")
|
||||
console.print()
|
||||
console.print("HANDLER CHARACTERISTICS:")
|
||||
console.print(" ✓ Independent - no module dependencies")
|
||||
console.print(" ✓ Can import Prax (service provider)")
|
||||
console.print(" ✓ Pure business logic")
|
||||
console.print(" ✗ CANNOT import parent modules")
|
||||
console.print(" [green]+[/green] Independent - no module dependencies")
|
||||
console.print(" [green]+[/green] Can import Prax (service provider)")
|
||||
console.print(" [green]+[/green] Pure business logic")
|
||||
console.print(" [dim]-[/dim] CANNOT import parent modules")
|
||||
console.print()
|
||||
console.print("USAGE FROM MODULES:")
|
||||
console.print(" from aipass.ai_mail.apps.handlers.email.inbox_ops import load_inbox")
|
||||
|
||||
@@ -305,10 +305,10 @@ if __name__ == "__main__":
|
||||
console.print(" - get_branch_info_from_registry(branch_path) -> Optional[Dict]")
|
||||
console.print()
|
||||
console.print("HANDLER CHARACTERISTICS:")
|
||||
console.print(" ✓ Independent - no module dependencies")
|
||||
console.print(" ✓ Can import Prax (service provider)")
|
||||
console.print(" ✓ Pure business logic")
|
||||
console.print(" ✗ CANNOT import parent modules")
|
||||
console.print(" [green]+[/green] Independent - no module dependencies")
|
||||
console.print(" [green]+[/green] Can import Prax (service provider)")
|
||||
console.print(" [green]+[/green] Pure business logic")
|
||||
console.print(" [dim]-[/dim] CANNOT import parent modules")
|
||||
console.print()
|
||||
console.print("DETECTION FLOW:")
|
||||
console.print(" 1. Get current working directory (PWD)")
|
||||
|
||||
@@ -14,7 +14,6 @@ Delegates all business logic to handlers.
|
||||
"""
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import List
|
||||
@@ -48,7 +47,6 @@ DISPATCH (send + wake):
|
||||
drone @ai_mail dispatch @branch "Subject" "Body" --fresh # Send + fresh wake
|
||||
drone @ai_mail dispatch @branch "Subject" "Body" --model opus # Send + wake with Opus
|
||||
drone @ai_mail dispatch @branch "Subject" "Body" --no-memory-save
|
||||
drone @ai_mail dispatch @branch "Subject" "Body" --no-watchdog # Skip auto-watchdog
|
||||
|
||||
WAKE ONLY:
|
||||
drone @ai_mail dispatch wake @branch # Wake with default inbox check
|
||||
@@ -226,7 +224,6 @@ def _orchestrate_dispatch_send(args: List[str]) -> bool:
|
||||
# Parse flags
|
||||
use_fresh = False
|
||||
no_memory_save = False
|
||||
no_watchdog = False
|
||||
from_branch = None
|
||||
use_model = None
|
||||
filtered = []
|
||||
@@ -241,7 +238,6 @@ def _orchestrate_dispatch_send(args: List[str]) -> bool:
|
||||
i += 1
|
||||
continue
|
||||
if args[i] == "--no-watchdog":
|
||||
no_watchdog = True
|
||||
i += 1
|
||||
continue
|
||||
if args[i] == "--from" and i + 1 < len(args):
|
||||
@@ -349,62 +345,12 @@ def _orchestrate_dispatch_send(args: List[str]) -> bool:
|
||||
if not wake_ok:
|
||||
logger.warning("[dispatch] Wake failed for %s — email was sent", target)
|
||||
error(f"Email sent but wake failed — retry: drone @ai_mail dispatch wake {target}")
|
||||
elif not no_watchdog:
|
||||
_spawn_watchdog(target)
|
||||
else:
|
||||
console.print(f"[dim]Wake-back enabled — sender will be woken when {target} completes (if available)[/dim]")
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def _spawn_watchdog(target: str) -> None:
|
||||
"""Auto-spawn devpulse watchdog as a detached background process."""
|
||||
from aipass.ai_mail.apps.handlers.registry.read import get_branch_by_email
|
||||
from aipass.ai_mail.apps.handlers.paths import find_repo_root
|
||||
|
||||
devpulse_info = get_branch_by_email("@devpulse")
|
||||
if not devpulse_info:
|
||||
logger.warning("[dispatch] Cannot spawn watchdog — @devpulse not in registry")
|
||||
return
|
||||
|
||||
_repo_root = find_repo_root()
|
||||
devpulse_path = devpulse_info.get("path", "")
|
||||
if not devpulse_path:
|
||||
logger.warning("[dispatch] Cannot spawn watchdog — @devpulse has no path")
|
||||
return
|
||||
|
||||
devpulse_dir = Path(devpulse_path)
|
||||
if not devpulse_dir.is_absolute():
|
||||
devpulse_dir = _repo_root / devpulse_dir
|
||||
|
||||
if not devpulse_dir.is_dir():
|
||||
logger.warning("[dispatch] Cannot spawn watchdog — devpulse dir not found: %s", devpulse_dir)
|
||||
return
|
||||
|
||||
cmd = ["drone", "@devpulse", "watchdog", "agent", target]
|
||||
|
||||
spawn_env = os.environ.copy()
|
||||
local_bin = str(Path.home() / ".local" / "bin")
|
||||
if local_bin not in spawn_env.get("PATH", ""):
|
||||
spawn_env["PATH"] = local_bin + ":" + spawn_env.get("PATH", "")
|
||||
|
||||
_detach_kwargs: dict = {}
|
||||
if sys.platform == "win32":
|
||||
_detach_kwargs["creationflags"] = subprocess.CREATE_NEW_PROCESS_GROUP
|
||||
else:
|
||||
_detach_kwargs["start_new_session"] = True
|
||||
try:
|
||||
subprocess.Popen(
|
||||
cmd,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
cwd=str(devpulse_dir),
|
||||
env=spawn_env,
|
||||
**_detach_kwargs,
|
||||
)
|
||||
console.print(f"[green]Watchdog armed for {target}[/green]")
|
||||
except Exception as e:
|
||||
logger.warning("[dispatch] Watchdog spawn failed for %s: %s", target, e)
|
||||
|
||||
|
||||
def _orchestrate_daemon() -> bool:
|
||||
"""Orchestrate daemon startup."""
|
||||
logger.info("[dispatch] Starting dispatch daemon")
|
||||
|
||||
@@ -9,10 +9,11 @@
|
||||
"""Tests for dispatch daemon handler -- config loading, state management, inbox scanning."""
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import pytest
|
||||
from datetime import datetime, date, timedelta
|
||||
from unittest.mock import patch
|
||||
from unittest.mock import MagicMock, mock_open, patch
|
||||
|
||||
import aipass.ai_mail.apps.handlers.dispatch.daemon as daemon_mod
|
||||
from aipass.ai_mail.apps.handlers.dispatch.daemon import (
|
||||
@@ -25,6 +26,17 @@ from aipass.ai_mail.apps.handlers.dispatch.daemon import (
|
||||
get_registered_branches,
|
||||
check_inbox_for_dispatch,
|
||||
is_protected_branch,
|
||||
_handle_signal,
|
||||
_check_lock,
|
||||
_acquire_lock,
|
||||
_is_registered_sender,
|
||||
poll_cycle,
|
||||
_write_pid_file,
|
||||
_remove_pid_file,
|
||||
_read_session_type,
|
||||
_is_branch_occupied,
|
||||
spawn_agent,
|
||||
run_daemon,
|
||||
)
|
||||
|
||||
|
||||
@@ -764,26 +776,6 @@ def test_poll_cycle_absolute_path_unchanged(tmp_path, monkeypatch):
|
||||
assert spawned_paths[0] == branch_dir
|
||||
|
||||
|
||||
# ---- Additional imports for new tests --------------------------------
|
||||
|
||||
import os
|
||||
from unittest.mock import MagicMock, mock_open
|
||||
|
||||
from aipass.ai_mail.apps.handlers.dispatch.daemon import (
|
||||
_handle_signal,
|
||||
_check_lock,
|
||||
_acquire_lock,
|
||||
_is_registered_sender,
|
||||
poll_cycle,
|
||||
_write_pid_file,
|
||||
_remove_pid_file,
|
||||
_read_session_type,
|
||||
_is_branch_occupied,
|
||||
spawn_agent,
|
||||
run_daemon,
|
||||
)
|
||||
|
||||
|
||||
# ---- _handle_signal tests --------------------------------------
|
||||
|
||||
|
||||
@@ -814,7 +806,7 @@ def test_check_lock_alive_pid(tmp_path, monkeypatch):
|
||||
lock_data = {"pid": 99999, "timestamp": datetime.now().isoformat()}
|
||||
lock_file.write_text(json.dumps(lock_data), encoding="utf-8")
|
||||
|
||||
monkeypatch.setattr(os, "kill", lambda pid, sig: None)
|
||||
monkeypatch.setattr(daemon_mod, "_pid_alive", lambda pid: True)
|
||||
|
||||
result = _check_lock(tmp_path)
|
||||
|
||||
@@ -823,17 +815,14 @@ def test_check_lock_alive_pid(tmp_path, monkeypatch):
|
||||
|
||||
|
||||
def test_check_lock_dead_pid(tmp_path, monkeypatch):
|
||||
"""Lock with dead PID (ProcessLookupError) is cleaned up."""
|
||||
"""Lock with dead PID is cleaned up."""
|
||||
lock_dir = tmp_path / ".ai_mail.local"
|
||||
lock_dir.mkdir(parents=True)
|
||||
lock_file = lock_dir / ".dispatch.lock"
|
||||
lock_data = {"pid": 99999, "timestamp": datetime.now().isoformat()}
|
||||
lock_file.write_text(json.dumps(lock_data), encoding="utf-8")
|
||||
|
||||
def _raise_process_lookup(pid, sig):
|
||||
raise ProcessLookupError("No such process")
|
||||
|
||||
monkeypatch.setattr(os, "kill", _raise_process_lookup)
|
||||
monkeypatch.setattr(daemon_mod, "_pid_alive", lambda pid: False)
|
||||
|
||||
result = _check_lock(tmp_path)
|
||||
|
||||
@@ -849,10 +838,7 @@ def test_check_lock_permission_error(tmp_path, monkeypatch):
|
||||
lock_data = {"pid": 99999, "timestamp": datetime.now().isoformat()}
|
||||
lock_file.write_text(json.dumps(lock_data), encoding="utf-8")
|
||||
|
||||
def _raise_permission(pid, sig):
|
||||
raise PermissionError("Operation not permitted")
|
||||
|
||||
monkeypatch.setattr(os, "kill", _raise_permission)
|
||||
monkeypatch.setattr(daemon_mod, "_pid_alive", lambda pid: True)
|
||||
|
||||
result = _check_lock(tmp_path)
|
||||
|
||||
@@ -869,10 +855,7 @@ def test_check_lock_stale_over_10min_removed(tmp_path, monkeypatch):
|
||||
lock_data = {"pid": 99999, "timestamp": old_time}
|
||||
lock_file.write_text(json.dumps(lock_data), encoding="utf-8")
|
||||
|
||||
def _raise_process_lookup(pid, sig):
|
||||
raise ProcessLookupError("No such process")
|
||||
|
||||
monkeypatch.setattr(os, "kill", _raise_process_lookup)
|
||||
monkeypatch.setattr(daemon_mod, "_pid_alive", lambda pid: False)
|
||||
|
||||
result = _check_lock(tmp_path)
|
||||
|
||||
@@ -889,10 +872,7 @@ def test_check_lock_stale_under_10min_dead_pid_removed(tmp_path, monkeypatch):
|
||||
lock_data = {"pid": 99999, "timestamp": recent_time}
|
||||
lock_file.write_text(json.dumps(lock_data), encoding="utf-8")
|
||||
|
||||
def _raise_process_lookup(pid, sig):
|
||||
raise ProcessLookupError("No such process")
|
||||
|
||||
monkeypatch.setattr(os, "kill", _raise_process_lookup)
|
||||
monkeypatch.setattr(daemon_mod, "_pid_alive", lambda pid: False)
|
||||
|
||||
result = _check_lock(tmp_path)
|
||||
|
||||
@@ -1015,6 +995,7 @@ def test_write_pid_file_existing_dead_pid(tmp_path, monkeypatch):
|
||||
|
||||
def test_write_pid_file_existing_permission_error(tmp_path, monkeypatch):
|
||||
"""Existing PID file with PermissionError on kill returns False."""
|
||||
monkeypatch.setattr("sys.platform", "linux")
|
||||
pid_file = tmp_path / "daemon.pid"
|
||||
pid_file.write_text("888888", encoding="utf-8")
|
||||
monkeypatch.setattr(daemon_mod, "DAEMON_PID_FILE", pid_file)
|
||||
|
||||
@@ -16,8 +16,6 @@ All handler dependencies are mocked -- these tests verify orchestration
|
||||
logic, not business logic.
|
||||
"""
|
||||
|
||||
import subprocess
|
||||
import sys
|
||||
from contextlib import ExitStack
|
||||
|
||||
import pytest
|
||||
@@ -971,172 +969,18 @@ class TestPrintIntrospection:
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# _spawn_watchdog
|
||||
# Wake-back messaging (TDPLAN-0012 — retired _spawn_watchdog)
|
||||
# ===========================================================================
|
||||
|
||||
|
||||
class TestSpawnWatchdog:
|
||||
"""Tests for _spawn_watchdog."""
|
||||
|
||||
def test_spawns_detached_subprocess(self, monkeypatch, tmp_path):
|
||||
"""Successful watchdog spawn calls Popen with correct args."""
|
||||
devpulse_dir = tmp_path / "src" / "aipass" / "devpulse"
|
||||
devpulse_dir.mkdir(parents=True)
|
||||
class TestWakeBackMessaging:
|
||||
"""Tests for honest wake-back messaging after watchdog retirement."""
|
||||
|
||||
def test_wake_back_message_on_successful_wake(self, monkeypatch):
|
||||
"""Successful send + wake prints wake-back enabled message."""
|
||||
printed: list[str] = []
|
||||
monkeypatch.setattr(f"{MOD}.console", _mock_console(printed))
|
||||
|
||||
popen_calls: list[dict] = []
|
||||
mock_popen = MagicMock()
|
||||
|
||||
def tracking_popen(cmd, **kwargs):
|
||||
"""Capture Popen arguments."""
|
||||
popen_calls.append({"cmd": cmd, **kwargs})
|
||||
return mock_popen
|
||||
|
||||
with (
|
||||
patch(
|
||||
f"{_H_REG}.get_branch_by_email",
|
||||
return_value={"email": "@devpulse", "path": str(devpulse_dir)},
|
||||
),
|
||||
patch(f"{MOD}.subprocess.Popen", side_effect=tracking_popen),
|
||||
):
|
||||
from aipass.ai_mail.apps.modules.dispatch import _spawn_watchdog
|
||||
|
||||
_spawn_watchdog("@flow")
|
||||
|
||||
assert len(popen_calls) == 1
|
||||
assert popen_calls[0]["cmd"] == ["drone", "@devpulse", "watchdog", "agent", "@flow"]
|
||||
if sys.platform == "win32":
|
||||
assert popen_calls[0].get("creationflags") == subprocess.CREATE_NEW_PROCESS_GROUP
|
||||
assert "start_new_session" not in popen_calls[0]
|
||||
else:
|
||||
assert popen_calls[0]["start_new_session"] is True
|
||||
assert popen_calls[0]["cwd"] == str(devpulse_dir)
|
||||
combined = " ".join(printed)
|
||||
assert "Watchdog armed for @flow" in combined
|
||||
|
||||
def test_devpulse_not_in_registry(self, monkeypatch):
|
||||
"""No spawn when @devpulse not found in registry."""
|
||||
printed: list[str] = []
|
||||
monkeypatch.setattr(f"{MOD}.console", _mock_console(printed))
|
||||
|
||||
with (
|
||||
patch(f"{_H_REG}.get_branch_by_email", return_value=None),
|
||||
patch(f"{MOD}.subprocess.Popen") as mock_popen,
|
||||
):
|
||||
from aipass.ai_mail.apps.modules.dispatch import _spawn_watchdog
|
||||
|
||||
_spawn_watchdog("@flow")
|
||||
|
||||
mock_popen.assert_not_called()
|
||||
|
||||
def test_devpulse_no_path(self, monkeypatch):
|
||||
"""No spawn when @devpulse has empty path."""
|
||||
printed: list[str] = []
|
||||
monkeypatch.setattr(f"{MOD}.console", _mock_console(printed))
|
||||
|
||||
with (
|
||||
patch(
|
||||
f"{_H_REG}.get_branch_by_email",
|
||||
return_value={"email": "@devpulse", "path": ""},
|
||||
),
|
||||
patch(f"{MOD}.subprocess.Popen") as mock_popen,
|
||||
):
|
||||
from aipass.ai_mail.apps.modules.dispatch import _spawn_watchdog
|
||||
|
||||
_spawn_watchdog("@flow")
|
||||
|
||||
mock_popen.assert_not_called()
|
||||
|
||||
def test_devpulse_dir_missing(self, monkeypatch, tmp_path):
|
||||
"""No spawn when devpulse directory doesn't exist."""
|
||||
printed: list[str] = []
|
||||
monkeypatch.setattr(f"{MOD}.console", _mock_console(printed))
|
||||
|
||||
with (
|
||||
patch(
|
||||
f"{_H_REG}.get_branch_by_email",
|
||||
return_value={"email": "@devpulse", "path": str(tmp_path / "nonexistent")},
|
||||
),
|
||||
patch(f"{MOD}.subprocess.Popen") as mock_popen,
|
||||
):
|
||||
from aipass.ai_mail.apps.modules.dispatch import _spawn_watchdog
|
||||
|
||||
_spawn_watchdog("@flow")
|
||||
|
||||
mock_popen.assert_not_called()
|
||||
|
||||
def test_popen_failure_warns_but_does_not_raise(self, monkeypatch, tmp_path):
|
||||
"""Popen failure logs warning but doesn't propagate."""
|
||||
devpulse_dir = tmp_path / "src" / "aipass" / "devpulse"
|
||||
devpulse_dir.mkdir(parents=True)
|
||||
|
||||
printed: list[str] = []
|
||||
monkeypatch.setattr(f"{MOD}.console", _mock_console(printed))
|
||||
|
||||
with (
|
||||
patch(
|
||||
f"{_H_REG}.get_branch_by_email",
|
||||
return_value={"email": "@devpulse", "path": str(devpulse_dir)},
|
||||
),
|
||||
patch(f"{MOD}.subprocess.Popen", side_effect=FileNotFoundError("drone not found")),
|
||||
):
|
||||
from aipass.ai_mail.apps.modules.dispatch import _spawn_watchdog
|
||||
|
||||
_spawn_watchdog("@flow")
|
||||
|
||||
# Should not raise — watchdog is optional
|
||||
|
||||
def test_relative_devpulse_path_resolved(self, monkeypatch, tmp_path):
|
||||
"""Relative path from registry is resolved against repo root."""
|
||||
printed: list[str] = []
|
||||
monkeypatch.setattr(f"{MOD}.console", _mock_console(printed))
|
||||
|
||||
popen_calls: list[dict] = []
|
||||
|
||||
def tracking_popen(cmd, **kwargs):
|
||||
"""Capture Popen arguments."""
|
||||
popen_calls.append({"cmd": cmd, **kwargs})
|
||||
return MagicMock()
|
||||
|
||||
from aipass.ai_mail.apps.modules import dispatch as dispatch_mod
|
||||
|
||||
real_repo_root = dispatch_mod.Path(__file__).resolve().parents[4]
|
||||
devpulse_dir = real_repo_root / "src" / "aipass" / "devpulse"
|
||||
|
||||
with (
|
||||
patch(
|
||||
f"{_H_REG}.get_branch_by_email",
|
||||
return_value={"email": "@devpulse", "path": "src/aipass/devpulse"},
|
||||
),
|
||||
patch(f"{MOD}.subprocess.Popen", side_effect=tracking_popen),
|
||||
):
|
||||
from aipass.ai_mail.apps.modules.dispatch import _spawn_watchdog
|
||||
|
||||
_spawn_watchdog("@flow")
|
||||
|
||||
if devpulse_dir.is_dir():
|
||||
assert len(popen_calls) == 1
|
||||
assert "devpulse" in popen_calls[0]["cwd"]
|
||||
else:
|
||||
assert len(popen_calls) == 0
|
||||
|
||||
|
||||
class TestDispatchSendWatchdogIntegration:
|
||||
"""Tests for watchdog integration in _orchestrate_dispatch_send."""
|
||||
|
||||
def test_watchdog_spawned_after_successful_wake(self, monkeypatch):
|
||||
"""Watchdog is spawned after successful send + wake."""
|
||||
printed: list[str] = []
|
||||
monkeypatch.setattr(f"{MOD}.console", _mock_console(printed))
|
||||
|
||||
watchdog_calls: list[str] = []
|
||||
monkeypatch.setattr(
|
||||
f"{MOD}._spawn_watchdog",
|
||||
lambda target: watchdog_calls.append(target),
|
||||
)
|
||||
|
||||
patches = _send_patches()
|
||||
with patches:
|
||||
from aipass.ai_mail.apps.modules.dispatch import _orchestrate_dispatch_send
|
||||
@@ -1144,21 +988,17 @@ class TestDispatchSendWatchdogIntegration:
|
||||
result = _orchestrate_dispatch_send(["@target", "Subject", "Body"])
|
||||
|
||||
assert result is True
|
||||
assert watchdog_calls == ["@target"]
|
||||
combined = " ".join(printed)
|
||||
assert "Wake-back enabled" in combined
|
||||
assert "Watchdog armed" not in combined
|
||||
|
||||
def test_watchdog_not_spawned_on_wake_failure(self, monkeypatch):
|
||||
"""Watchdog is NOT spawned when wake fails."""
|
||||
def test_no_wake_back_message_on_wake_failure(self, monkeypatch):
|
||||
"""No wake-back message when wake fails."""
|
||||
errors: list[str] = []
|
||||
monkeypatch.setattr(f"{MOD}.error", lambda msg: errors.append(msg))
|
||||
printed: list[str] = []
|
||||
monkeypatch.setattr(f"{MOD}.console", _mock_console(printed))
|
||||
|
||||
watchdog_calls: list[str] = []
|
||||
monkeypatch.setattr(
|
||||
f"{MOD}._spawn_watchdog",
|
||||
lambda target: watchdog_calls.append(target),
|
||||
)
|
||||
|
||||
mock_status = MagicMock()
|
||||
mock_status.format.return_value = "WAKE FAILED"
|
||||
patches = _send_patches(
|
||||
@@ -1171,19 +1011,14 @@ class TestDispatchSendWatchdogIntegration:
|
||||
|
||||
_orchestrate_dispatch_send(["@target", "Subject", "Body"])
|
||||
|
||||
assert watchdog_calls == []
|
||||
combined = " ".join(printed)
|
||||
assert "Wake-back enabled" not in combined
|
||||
|
||||
def test_no_watchdog_flag_skips_spawn(self, monkeypatch):
|
||||
"""--no-watchdog flag prevents watchdog spawn."""
|
||||
def test_no_watchdog_flag_still_accepted(self, monkeypatch):
|
||||
"""--no-watchdog flag is consumed without error (backward compat)."""
|
||||
printed: list[str] = []
|
||||
monkeypatch.setattr(f"{MOD}.console", _mock_console(printed))
|
||||
|
||||
watchdog_calls: list[str] = []
|
||||
monkeypatch.setattr(
|
||||
f"{MOD}._spawn_watchdog",
|
||||
lambda target: watchdog_calls.append(target),
|
||||
)
|
||||
|
||||
patches = _send_patches()
|
||||
with patches:
|
||||
from aipass.ai_mail.apps.modules.dispatch import _orchestrate_dispatch_send
|
||||
@@ -1191,21 +1026,14 @@ class TestDispatchSendWatchdogIntegration:
|
||||
result = _orchestrate_dispatch_send(["@target", "Subject", "Body", "--no-watchdog"])
|
||||
|
||||
assert result is True
|
||||
assert watchdog_calls == []
|
||||
|
||||
def test_watchdog_not_spawned_on_send_failure(self, monkeypatch):
|
||||
"""Watchdog is NOT spawned when send fails."""
|
||||
def test_no_watchdog_message_on_send_failure(self, monkeypatch):
|
||||
"""No wake-back message when send fails."""
|
||||
errors: list[str] = []
|
||||
monkeypatch.setattr(f"{MOD}.error", lambda msg: errors.append(msg))
|
||||
printed: list[str] = []
|
||||
monkeypatch.setattr(f"{MOD}.console", _mock_console(printed))
|
||||
|
||||
watchdog_calls: list[str] = []
|
||||
monkeypatch.setattr(
|
||||
f"{MOD}._spawn_watchdog",
|
||||
lambda target: watchdog_calls.append(target),
|
||||
)
|
||||
|
||||
patches = _send_patches(
|
||||
{
|
||||
f"{_H_SEND}.send_to_single": MagicMock(return_value=(False, "error")),
|
||||
@@ -1216,4 +1044,5 @@ class TestDispatchSendWatchdogIntegration:
|
||||
|
||||
_orchestrate_dispatch_send(["@target", "Subject", "Body"])
|
||||
|
||||
assert watchdog_calls == []
|
||||
combined = " ".join(printed)
|
||||
assert "Wake-back enabled" not in combined
|
||||
|
||||
@@ -19,15 +19,18 @@ from unittest.mock import MagicMock
|
||||
|
||||
import aipass.ai_mail.apps.handlers.dispatch.dispatch_monitor as mod
|
||||
from aipass.ai_mail.apps.handlers.dispatch.dispatch_monitor import (
|
||||
MAX_WAKE_DEPTH,
|
||||
_check_jsonl_activity,
|
||||
_check_rate_limited,
|
||||
_get_jsonl_projects_dir,
|
||||
_is_sandbox_enabled,
|
||||
_kill_process,
|
||||
_log_wake_result,
|
||||
_make_fresh_cmd,
|
||||
_run_with_startup_check,
|
||||
_send_bounce,
|
||||
_snapshot_jsonl_sizes,
|
||||
_wake_sender,
|
||||
_wrap_for_sandbox,
|
||||
main,
|
||||
)
|
||||
@@ -52,6 +55,13 @@ def _suppress_logger(monkeypatch):
|
||||
monkeypatch.setattr(mod, "logger", MagicMock())
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _suppress_wake(monkeypatch):
|
||||
"""Prevent _wake_sender from importing/calling real wake_branch in unrelated tests."""
|
||||
monkeypatch.setattr(mod, "_wake_sender", MagicMock(return_value="skipped_sender"))
|
||||
monkeypatch.setattr(mod, "_log_wake_result", MagicMock())
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def stderr_log(tmp_path):
|
||||
"""Create a stderr log file and return its path string."""
|
||||
@@ -1824,3 +1834,417 @@ finally:
|
||||
finally:
|
||||
broker.stop()
|
||||
t.join(timeout=3)
|
||||
|
||||
|
||||
# === Wake-back tests (TDPLAN-0012) ==========================================
|
||||
|
||||
|
||||
class TestWakeSender:
|
||||
"""_wake_sender guards and owner-allowlist dispatch."""
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _mock_is_owner(self, monkeypatch):
|
||||
"""Default: is_owner returns False (non-owner). Tests override as needed."""
|
||||
monkeypatch.setattr(
|
||||
"aipass.spawn.apps.handlers.registry.is_owner",
|
||||
MagicMock(return_value=False),
|
||||
)
|
||||
|
||||
def test_skips_empty_sender(self, monkeypatch):
|
||||
"""Empty sender returns skipped_sender."""
|
||||
monkeypatch.setattr(mod, "logger", MagicMock())
|
||||
result = _wake_sender("", "@target", 0, "/fake/lock")
|
||||
assert result == "skipped_sender"
|
||||
|
||||
def test_skips_whitespace_sender(self, monkeypatch):
|
||||
"""Whitespace-only sender returns skipped_sender."""
|
||||
monkeypatch.setattr(mod, "logger", MagicMock())
|
||||
result = _wake_sender(" ", "@target", 0, "/fake/lock")
|
||||
assert result == "skipped_sender"
|
||||
|
||||
def test_skips_non_owner_sender(self, monkeypatch):
|
||||
"""Non-owner sender returns skipped_not_owner."""
|
||||
monkeypatch.setattr(mod, "logger", MagicMock())
|
||||
monkeypatch.setattr(
|
||||
"aipass.spawn.apps.handlers.registry.is_owner",
|
||||
MagicMock(return_value=False),
|
||||
)
|
||||
result = _wake_sender("@someagent", "@target", 0, "/fake/lock")
|
||||
assert result == "skipped_not_owner"
|
||||
|
||||
def test_skips_ai_mail_when_not_owner(self, monkeypatch):
|
||||
"""@ai_mail is not owner — skipped."""
|
||||
monkeypatch.setattr(mod, "logger", MagicMock())
|
||||
monkeypatch.setattr(
|
||||
"aipass.spawn.apps.handlers.registry.is_owner",
|
||||
MagicMock(return_value=False),
|
||||
)
|
||||
result = _wake_sender("@ai_mail", "@target", 0, "/fake/lock")
|
||||
assert result == "skipped_not_owner"
|
||||
|
||||
def test_skips_human_when_not_owner(self, monkeypatch):
|
||||
"""@human is not owner — skipped."""
|
||||
monkeypatch.setattr(mod, "logger", MagicMock())
|
||||
monkeypatch.setattr(
|
||||
"aipass.spawn.apps.handlers.registry.is_owner",
|
||||
MagicMock(return_value=False),
|
||||
)
|
||||
result = _wake_sender("@human", "@target", 0, "/fake/lock")
|
||||
assert result == "skipped_not_owner"
|
||||
|
||||
def test_owner_passes_guard(self, monkeypatch):
|
||||
"""Owner sender passes the is_owner guard and reaches wake_branch."""
|
||||
monkeypatch.setattr(mod, "logger", MagicMock())
|
||||
monkeypatch.delenv("AIPASS_WAKE_DEPTH", raising=False)
|
||||
monkeypatch.setattr(
|
||||
"aipass.spawn.apps.handlers.registry.is_owner",
|
||||
MagicMock(return_value=True),
|
||||
)
|
||||
mock_status = MagicMock()
|
||||
mock_status.summary = "ok"
|
||||
mock_wake = MagicMock(return_value=(mock_status, True))
|
||||
monkeypatch.setattr(
|
||||
"aipass.ai_mail.apps.handlers.dispatch.wake.wake_branch",
|
||||
mock_wake,
|
||||
)
|
||||
result = _wake_sender("@devpulse", "@target", 0, "/fake/lock")
|
||||
assert result == "success"
|
||||
mock_wake.assert_called_once()
|
||||
|
||||
def test_is_owner_called_with_normalized_sender(self, monkeypatch):
|
||||
"""is_owner receives normalized @-prefixed lowercase sender."""
|
||||
monkeypatch.setattr(mod, "logger", MagicMock())
|
||||
mock_is_owner = MagicMock(return_value=False)
|
||||
monkeypatch.setattr(
|
||||
"aipass.spawn.apps.handlers.registry.is_owner",
|
||||
mock_is_owner,
|
||||
)
|
||||
_wake_sender("DevPulse", "@target", 0, "/fake/lock")
|
||||
mock_is_owner.assert_called_once_with("@devpulse")
|
||||
|
||||
def test_is_owner_import_failure(self, monkeypatch):
|
||||
"""ImportError from is_owner returns failed."""
|
||||
monkeypatch.setattr(mod, "logger", MagicMock())
|
||||
import builtins
|
||||
|
||||
real_import = builtins.__import__
|
||||
|
||||
def fail_import(name, *args, **kwargs):
|
||||
if name == "aipass.spawn.apps.handlers.registry":
|
||||
raise ImportError("no spawn")
|
||||
return real_import(name, *args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(builtins, "__import__", fail_import)
|
||||
result = _wake_sender("@devpulse", "@target", 0, "/fake/lock")
|
||||
assert result == "failed"
|
||||
|
||||
def test_depth_cap_blocks(self, monkeypatch):
|
||||
"""AIPASS_WAKE_DEPTH >= MAX_WAKE_DEPTH returns blocked_depth."""
|
||||
monkeypatch.setattr(mod, "logger", MagicMock())
|
||||
monkeypatch.setattr(
|
||||
"aipass.spawn.apps.handlers.registry.is_owner",
|
||||
MagicMock(return_value=True),
|
||||
)
|
||||
monkeypatch.setenv("AIPASS_WAKE_DEPTH", str(MAX_WAKE_DEPTH))
|
||||
result = _wake_sender("@devpulse", "@target", 0, "/fake/lock")
|
||||
assert result == "blocked_depth"
|
||||
|
||||
def test_depth_cap_over_max_blocks(self, monkeypatch):
|
||||
"""Depth above max also blocks."""
|
||||
monkeypatch.setattr(mod, "logger", MagicMock())
|
||||
monkeypatch.setattr(
|
||||
"aipass.spawn.apps.handlers.registry.is_owner",
|
||||
MagicMock(return_value=True),
|
||||
)
|
||||
monkeypatch.setenv("AIPASS_WAKE_DEPTH", str(MAX_WAKE_DEPTH + 5))
|
||||
result = _wake_sender("@devpulse", "@target", 0, "/fake/lock")
|
||||
assert result == "blocked_depth"
|
||||
|
||||
def test_success_on_wake(self, monkeypatch):
|
||||
"""Successful wake_branch call returns success."""
|
||||
monkeypatch.setattr(mod, "logger", MagicMock())
|
||||
monkeypatch.delenv("AIPASS_WAKE_DEPTH", raising=False)
|
||||
monkeypatch.setattr(
|
||||
"aipass.spawn.apps.handlers.registry.is_owner",
|
||||
MagicMock(return_value=True),
|
||||
)
|
||||
|
||||
mock_status = MagicMock()
|
||||
mock_status.summary = "ok"
|
||||
mock_wake = MagicMock(return_value=(mock_status, True))
|
||||
monkeypatch.setattr(
|
||||
"aipass.ai_mail.apps.handlers.dispatch.wake.wake_branch",
|
||||
mock_wake,
|
||||
)
|
||||
|
||||
result = _wake_sender("@devpulse", "@target", 0, "/fake/lock")
|
||||
assert result == "success"
|
||||
mock_wake.assert_called_once_with("@devpulse", auto=True, sender="@ai_mail")
|
||||
|
||||
def test_blocked_locked_on_lock_failure(self, monkeypatch):
|
||||
"""wake_branch failing with lock-related message returns blocked_locked."""
|
||||
monkeypatch.setattr(mod, "logger", MagicMock())
|
||||
monkeypatch.delenv("AIPASS_WAKE_DEPTH", raising=False)
|
||||
monkeypatch.setattr(
|
||||
"aipass.spawn.apps.handlers.registry.is_owner",
|
||||
MagicMock(return_value=True),
|
||||
)
|
||||
|
||||
mock_status = MagicMock()
|
||||
mock_status.summary = "lock: Active agent (PID 1234)"
|
||||
mock_wake = MagicMock(return_value=(mock_status, False))
|
||||
monkeypatch.setattr(
|
||||
"aipass.ai_mail.apps.handlers.dispatch.wake.wake_branch",
|
||||
mock_wake,
|
||||
)
|
||||
|
||||
result = _wake_sender("@devpulse", "@target", 0, "/fake/lock")
|
||||
assert result == "blocked_locked"
|
||||
|
||||
def test_blocked_occupied_on_interactive(self, monkeypatch):
|
||||
"""wake_branch failing with occupancy message returns blocked_occupied."""
|
||||
monkeypatch.setattr(mod, "logger", MagicMock())
|
||||
monkeypatch.delenv("AIPASS_WAKE_DEPTH", raising=False)
|
||||
monkeypatch.setattr(
|
||||
"aipass.spawn.apps.handlers.registry.is_owner",
|
||||
MagicMock(return_value=True),
|
||||
)
|
||||
|
||||
mock_status = MagicMock()
|
||||
mock_status.summary = "blocked: Cannot spawn — interactive session running"
|
||||
mock_wake = MagicMock(return_value=(mock_status, False))
|
||||
monkeypatch.setattr(
|
||||
"aipass.ai_mail.apps.handlers.dispatch.wake.wake_branch",
|
||||
mock_wake,
|
||||
)
|
||||
|
||||
result = _wake_sender("@devpulse", "@target", 0, "/fake/lock")
|
||||
assert result == "blocked_occupied"
|
||||
|
||||
def test_failed_on_exception(self, monkeypatch):
|
||||
"""Exception during wake returns failed."""
|
||||
monkeypatch.setattr(mod, "logger", MagicMock())
|
||||
monkeypatch.delenv("AIPASS_WAKE_DEPTH", raising=False)
|
||||
monkeypatch.setattr(
|
||||
"aipass.spawn.apps.handlers.registry.is_owner",
|
||||
MagicMock(return_value=True),
|
||||
)
|
||||
|
||||
monkeypatch.setattr(
|
||||
"aipass.ai_mail.apps.handlers.dispatch.wake.wake_branch",
|
||||
MagicMock(side_effect=RuntimeError("broken")),
|
||||
)
|
||||
|
||||
result = _wake_sender("@devpulse", "@target", 0, "/fake/lock")
|
||||
assert result == "failed"
|
||||
|
||||
def test_depth_incremented_before_wake(self, monkeypatch):
|
||||
"""AIPASS_WAKE_DEPTH is incremented before calling wake_branch."""
|
||||
monkeypatch.setattr(mod, "logger", MagicMock())
|
||||
monkeypatch.setenv("AIPASS_WAKE_DEPTH", "1")
|
||||
monkeypatch.setattr(
|
||||
"aipass.spawn.apps.handlers.registry.is_owner",
|
||||
MagicMock(return_value=True),
|
||||
)
|
||||
|
||||
captured_depth = []
|
||||
|
||||
def capture_wake(*args, **kwargs):
|
||||
captured_depth.append(os.environ.get("AIPASS_WAKE_DEPTH"))
|
||||
mock_status = MagicMock()
|
||||
mock_status.summary = "ok"
|
||||
return mock_status, True
|
||||
|
||||
monkeypatch.setattr(
|
||||
"aipass.ai_mail.apps.handlers.dispatch.wake.wake_branch",
|
||||
capture_wake,
|
||||
)
|
||||
|
||||
_wake_sender("@devpulse", "@target", 0, "/fake/lock")
|
||||
assert captured_depth == ["2"]
|
||||
|
||||
def test_wake_called_on_failure_exit(self, monkeypatch):
|
||||
"""Wake fires on non-zero exit code too."""
|
||||
monkeypatch.setattr(mod, "logger", MagicMock())
|
||||
monkeypatch.delenv("AIPASS_WAKE_DEPTH", raising=False)
|
||||
monkeypatch.setattr(
|
||||
"aipass.spawn.apps.handlers.registry.is_owner",
|
||||
MagicMock(return_value=True),
|
||||
)
|
||||
|
||||
mock_status = MagicMock()
|
||||
mock_status.summary = "ok"
|
||||
mock_wake = MagicMock(return_value=(mock_status, True))
|
||||
monkeypatch.setattr(
|
||||
"aipass.ai_mail.apps.handlers.dispatch.wake.wake_branch",
|
||||
mock_wake,
|
||||
)
|
||||
|
||||
result = _wake_sender("@devpulse", "@target", 1, "/fake/lock")
|
||||
assert result == "success"
|
||||
mock_wake.assert_called_once()
|
||||
|
||||
def test_sender_normalization_for_is_owner(self, monkeypatch):
|
||||
"""Sender with or without @ prefix is normalized before is_owner call."""
|
||||
monkeypatch.setattr(mod, "logger", MagicMock())
|
||||
mock_is_owner = MagicMock(return_value=False)
|
||||
monkeypatch.setattr(
|
||||
"aipass.spawn.apps.handlers.registry.is_owner",
|
||||
mock_is_owner,
|
||||
)
|
||||
_wake_sender("devpulse", "@target", 0, "/fake/lock")
|
||||
_wake_sender("@devpulse", "@target", 0, "/fake/lock")
|
||||
assert mock_is_owner.call_count == 2
|
||||
for call in mock_is_owner.call_args_list:
|
||||
assert call[0][0] == "@devpulse"
|
||||
|
||||
|
||||
class TestLogWakeResult:
|
||||
"""_log_wake_result writes to dispatch_wake.log."""
|
||||
|
||||
def test_creates_log_file(self, tmp_path):
|
||||
"""Log file created under target's logs/ directory."""
|
||||
lock = tmp_path / "branch" / ".ai_mail.local" / ".dispatch.lock"
|
||||
lock.parent.mkdir(parents=True)
|
||||
lock.write_text("{}", encoding="utf-8")
|
||||
logs_dir = tmp_path / "branch" / "logs"
|
||||
|
||||
_log_wake_result("@target", "@sender", 0, "success", str(lock))
|
||||
|
||||
log_file = logs_dir / "dispatch_wake.log"
|
||||
assert log_file.exists()
|
||||
content = log_file.read_text(encoding="utf-8")
|
||||
assert "target=@target" in content
|
||||
assert "sender=@sender" in content
|
||||
assert "exit_code=0" in content
|
||||
assert "wake_result=success" in content
|
||||
|
||||
def test_appends_to_existing(self, tmp_path):
|
||||
"""Subsequent calls append, not overwrite."""
|
||||
lock = tmp_path / "branch" / ".ai_mail.local" / ".dispatch.lock"
|
||||
lock.parent.mkdir(parents=True)
|
||||
lock.write_text("{}", encoding="utf-8")
|
||||
logs_dir = tmp_path / "branch" / "logs"
|
||||
logs_dir.mkdir(parents=True)
|
||||
log_file = logs_dir / "dispatch_wake.log"
|
||||
log_file.write_text("existing line\n", encoding="utf-8")
|
||||
|
||||
_log_wake_result("@target", "@sender", 0, "success", str(lock))
|
||||
|
||||
lines = log_file.read_text(encoding="utf-8").strip().split("\n")
|
||||
assert len(lines) == 2
|
||||
assert lines[0] == "existing line"
|
||||
assert "wake_result=success" in lines[1]
|
||||
|
||||
def test_all_result_values(self, tmp_path):
|
||||
"""All result enum values are logged correctly."""
|
||||
lock = tmp_path / "branch" / ".ai_mail.local" / ".dispatch.lock"
|
||||
lock.parent.mkdir(parents=True)
|
||||
lock.write_text("{}", encoding="utf-8")
|
||||
|
||||
for result_tag in (
|
||||
"success",
|
||||
"blocked_occupied",
|
||||
"blocked_locked",
|
||||
"blocked_depth",
|
||||
"skipped_sender",
|
||||
"failed",
|
||||
):
|
||||
_log_wake_result("@t", "@s", 0, result_tag, str(lock))
|
||||
|
||||
log_file = tmp_path / "branch" / "logs" / "dispatch_wake.log"
|
||||
lines = log_file.read_text(encoding="utf-8").strip().split("\n")
|
||||
assert len(lines) == 6
|
||||
|
||||
|
||||
class TestWakeBackIntegration:
|
||||
"""Wake-back wired into main() — fires after lock cleanup on both paths."""
|
||||
|
||||
def test_wake_called_on_success(self, monkeypatch, main_argv):
|
||||
"""_wake_sender called with correct args after successful agent run."""
|
||||
argv, lock_file, stderr_log = main_argv
|
||||
|
||||
wake_calls = []
|
||||
|
||||
def track_wake(sender, branch_email, exit_code, lf):
|
||||
wake_calls.append((sender, branch_email, exit_code))
|
||||
return "success"
|
||||
|
||||
monkeypatch.setattr("sys.argv", argv)
|
||||
monkeypatch.setattr(mod, "_run_with_startup_check", MagicMock(return_value=(0, False)))
|
||||
monkeypatch.setattr(mod, "_send_bounce", MagicMock())
|
||||
monkeypatch.setattr(mod, "_check_rate_limited", MagicMock(return_value=False))
|
||||
monkeypatch.setattr(mod, "_wake_sender", track_wake)
|
||||
monkeypatch.setattr(mod, "_log_wake_result", MagicMock())
|
||||
monkeypatch.setattr(
|
||||
"aipass.ai_mail.apps.handlers.paths.find_repo_root",
|
||||
MagicMock(return_value=Path("/fake/repo")),
|
||||
)
|
||||
|
||||
with pytest.raises(SystemExit) as exc_info:
|
||||
main()
|
||||
|
||||
assert exc_info.value.code == 0
|
||||
assert len(wake_calls) == 1
|
||||
assert wake_calls[0] == ("@sender", "@test_branch", 0)
|
||||
|
||||
def test_wake_called_on_failure(self, monkeypatch, main_argv):
|
||||
"""_wake_sender called after all attempts fail (in addition to bounce)."""
|
||||
argv, lock_file, stderr_log = main_argv
|
||||
|
||||
wake_calls = []
|
||||
mock_bounce = MagicMock()
|
||||
|
||||
def track_wake(sender, branch_email, exit_code, lf):
|
||||
wake_calls.append((sender, branch_email, exit_code))
|
||||
return "success"
|
||||
|
||||
monkeypatch.setattr("sys.argv", argv)
|
||||
monkeypatch.setattr(mod, "_run_with_startup_check", MagicMock(side_effect=[(1, False), (1, False), (1, False)]))
|
||||
monkeypatch.setattr(mod, "_send_bounce", mock_bounce)
|
||||
monkeypatch.setattr(mod, "_check_rate_limited", MagicMock(return_value=False))
|
||||
monkeypatch.setattr(mod, "_wake_sender", track_wake)
|
||||
monkeypatch.setattr(mod, "_log_wake_result", MagicMock())
|
||||
monkeypatch.setattr(
|
||||
mod,
|
||||
"time",
|
||||
MagicMock(time=time.time, strftime=time.strftime, sleep=MagicMock()),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"aipass.ai_mail.apps.handlers.paths.find_repo_root",
|
||||
MagicMock(return_value=Path("/fake/repo")),
|
||||
)
|
||||
|
||||
with pytest.raises(SystemExit):
|
||||
main()
|
||||
|
||||
mock_bounce.assert_called_once()
|
||||
assert len(wake_calls) == 1
|
||||
assert wake_calls[0][2] != 0
|
||||
|
||||
def test_log_wake_result_called(self, monkeypatch, main_argv):
|
||||
"""_log_wake_result called with wake result after main completes."""
|
||||
argv, lock_file, stderr_log = main_argv
|
||||
|
||||
log_calls = []
|
||||
|
||||
monkeypatch.setattr("sys.argv", argv)
|
||||
monkeypatch.setattr(mod, "_run_with_startup_check", MagicMock(return_value=(0, False)))
|
||||
monkeypatch.setattr(mod, "_send_bounce", MagicMock())
|
||||
monkeypatch.setattr(mod, "_check_rate_limited", MagicMock(return_value=False))
|
||||
monkeypatch.setattr(mod, "_wake_sender", MagicMock(return_value="success"))
|
||||
monkeypatch.setattr(mod, "_log_wake_result", lambda *a: log_calls.append(a))
|
||||
monkeypatch.setattr(
|
||||
"aipass.ai_mail.apps.handlers.paths.find_repo_root",
|
||||
MagicMock(return_value=Path("/fake/repo")),
|
||||
)
|
||||
|
||||
with pytest.raises(SystemExit):
|
||||
main()
|
||||
|
||||
assert len(log_calls) == 1
|
||||
branch_email, sender, exit_code, result, lf = log_calls[0]
|
||||
assert branch_email == "@test_branch"
|
||||
assert sender == "@sender"
|
||||
assert exit_code == 0
|
||||
assert result == "success"
|
||||
|
||||
@@ -20,7 +20,11 @@ from aipass.ai_mail.apps.handlers.dispatch.wake import (
|
||||
_read_json,
|
||||
_check_lock,
|
||||
_check_pid_alive,
|
||||
_get_pid_cwd,
|
||||
_get_pid_cwd_darwin,
|
||||
_read_session_type,
|
||||
_read_session_type_darwin,
|
||||
_is_zombie_linux,
|
||||
_clean_zombies,
|
||||
_find_claude_bin,
|
||||
resolve_branch,
|
||||
@@ -138,6 +142,7 @@ def test_check_pid_alive_dead(monkeypatch):
|
||||
|
||||
def test_check_pid_alive_permission_error(monkeypatch):
|
||||
"""PermissionError means process exists but cannot signal -- returns True."""
|
||||
monkeypatch.setattr("sys.platform", "linux")
|
||||
monkeypatch.setattr(os, "kill", _raise_permission)
|
||||
assert _check_pid_alive(1) is True
|
||||
|
||||
@@ -201,11 +206,126 @@ def test_read_session_type_not_set(monkeypatch, tmp_path):
|
||||
|
||||
|
||||
def test_read_session_type_non_linux(monkeypatch):
|
||||
"""Non-linux platform returns 'interactive' immediately."""
|
||||
monkeypatch.setattr("sys.platform", "darwin")
|
||||
"""Non-linux, non-darwin platform returns 'interactive' immediately."""
|
||||
monkeypatch.setattr("sys.platform", "win32")
|
||||
assert _read_session_type("999") == "interactive"
|
||||
|
||||
|
||||
def test_read_session_type_darwin_found(monkeypatch):
|
||||
"""macOS: reads AIPASS_SESSION_TYPE from ps -wwE output."""
|
||||
monkeypatch.setattr("sys.platform", "darwin")
|
||||
|
||||
class FakeResult:
|
||||
returncode = 0
|
||||
stdout = "/usr/bin/claude AIPASS_SESSION_TYPE=dispatched HOME=/Users/u"
|
||||
|
||||
monkeypatch.setattr(subprocess, "run", lambda *a, **kw: FakeResult())
|
||||
assert _read_session_type("123") == "dispatched"
|
||||
|
||||
|
||||
def test_read_session_type_darwin_not_set(monkeypatch):
|
||||
"""macOS: missing env var returns 'interactive'."""
|
||||
monkeypatch.setattr("sys.platform", "darwin")
|
||||
|
||||
class FakeResult:
|
||||
returncode = 0
|
||||
stdout = "/usr/bin/claude HOME=/Users/u"
|
||||
|
||||
monkeypatch.setattr(subprocess, "run", lambda *a, **kw: FakeResult())
|
||||
assert _read_session_type("456") == "interactive"
|
||||
|
||||
|
||||
def test_read_session_type_darwin_ps_failure(monkeypatch):
|
||||
"""macOS: ps failure returns 'interactive'."""
|
||||
assert _read_session_type_darwin("999") == "interactive"
|
||||
|
||||
|
||||
# --- _get_pid_cwd tests ------------------------------------------------
|
||||
|
||||
|
||||
def test_get_pid_cwd_linux(monkeypatch, tmp_path):
|
||||
"""Linux: reads /proc/{pid}/cwd via readlink."""
|
||||
monkeypatch.setattr("sys.platform", "linux")
|
||||
target = str(tmp_path / "project")
|
||||
monkeypatch.setattr(os, "readlink", lambda p: target)
|
||||
assert _get_pid_cwd("100") == target
|
||||
|
||||
|
||||
def test_get_pid_cwd_linux_oserror(monkeypatch):
|
||||
"""Linux: OSError returns None."""
|
||||
monkeypatch.setattr("sys.platform", "linux")
|
||||
monkeypatch.setattr(os, "readlink", lambda p: (_ for _ in ()).throw(OSError("no proc")))
|
||||
assert _get_pid_cwd("100") is None
|
||||
|
||||
|
||||
def test_get_pid_cwd_darwin(monkeypatch, tmp_path):
|
||||
"""macOS: reads cwd via lsof."""
|
||||
monkeypatch.setattr("sys.platform", "darwin")
|
||||
target = "/tmp/pytest-project"
|
||||
|
||||
class FakeResult:
|
||||
returncode = 0
|
||||
stdout = f"p100\nn{target}\n"
|
||||
|
||||
monkeypatch.setattr(subprocess, "run", lambda *a, **kw: FakeResult())
|
||||
assert _get_pid_cwd("100") == target
|
||||
|
||||
|
||||
def test_get_pid_cwd_darwin_failure(monkeypatch):
|
||||
"""macOS: lsof failure returns None."""
|
||||
|
||||
class FailedResult:
|
||||
returncode = 1
|
||||
stdout = ""
|
||||
|
||||
monkeypatch.setattr(subprocess, "run", lambda *a, **kw: FailedResult())
|
||||
assert _get_pid_cwd_darwin("999") is None
|
||||
|
||||
|
||||
def test_get_pid_cwd_unsupported_platform(monkeypatch):
|
||||
"""Unsupported platform returns None."""
|
||||
monkeypatch.setattr("sys.platform", "win32")
|
||||
assert _get_pid_cwd("100") is None
|
||||
|
||||
|
||||
# --- _is_zombie_linux tests --------------------------------------------
|
||||
|
||||
|
||||
def test_is_zombie_linux_not_zombie(monkeypatch, tmp_path):
|
||||
"""Non-zombie process returns False."""
|
||||
status_file = tmp_path / "status"
|
||||
status_file.write_text("Name:\tclaude\nState:\tS (sleeping)\nPid:\t42\n")
|
||||
monkeypatch.setattr(
|
||||
"builtins.open",
|
||||
_fake_open_factory(str(status_file), {"/proc/42/status": str(status_file)}),
|
||||
)
|
||||
assert _is_zombie_linux(42) is False
|
||||
|
||||
|
||||
def test_is_zombie_linux_zombie(monkeypatch, tmp_path):
|
||||
"""Zombie process returns True."""
|
||||
status_file = tmp_path / "status"
|
||||
status_file.write_text("Name:\tclaude\nState:\tZ (zombie)\nPid:\t42\n")
|
||||
monkeypatch.setattr(
|
||||
"builtins.open",
|
||||
_fake_open_factory(str(status_file), {"/proc/42/status": str(status_file)}),
|
||||
)
|
||||
assert _is_zombie_linux(42) is True
|
||||
|
||||
|
||||
def test_is_zombie_linux_no_proc(monkeypatch):
|
||||
"""Missing /proc entry returns False (not zombie, just gone)."""
|
||||
_real_open = open
|
||||
|
||||
def _fake_open(path, *a, **kw):
|
||||
if "/proc/99999/" in str(path):
|
||||
raise FileNotFoundError(path)
|
||||
return _real_open(path, *a, **kw)
|
||||
|
||||
monkeypatch.setattr("builtins.open", _fake_open)
|
||||
assert _is_zombie_linux(99999) is False
|
||||
|
||||
|
||||
# --- _check_lock tests ------------------------------------------------
|
||||
|
||||
|
||||
@@ -222,7 +342,7 @@ def test_check_lock_alive_pid(tmp_path, monkeypatch):
|
||||
lock_file = lock_dir / ".dispatch.lock"
|
||||
lock_data = {"pid": 1234, "timestamp": "2026-03-29T10:00:00"}
|
||||
lock_file.write_text(json.dumps(lock_data), encoding="utf-8")
|
||||
monkeypatch.setattr(os, "kill", lambda pid, sig: None)
|
||||
monkeypatch.setattr(wake_mod, "_check_pid_alive", lambda pid: True)
|
||||
result = _check_lock(tmp_path)
|
||||
assert result is not None
|
||||
assert result["pid"] == 1234
|
||||
@@ -235,7 +355,7 @@ def test_check_lock_dead_pid_removes_lock(tmp_path, monkeypatch):
|
||||
lock_file = lock_dir / ".dispatch.lock"
|
||||
lock_data = {"pid": 99999, "timestamp": "2026-03-29T10:00:00"}
|
||||
lock_file.write_text(json.dumps(lock_data), encoding="utf-8")
|
||||
monkeypatch.setattr(os, "kill", _raise_process_lookup)
|
||||
monkeypatch.setattr(wake_mod, "_check_pid_alive", lambda pid: False)
|
||||
result = _check_lock(tmp_path)
|
||||
assert result is None
|
||||
assert not lock_file.exists()
|
||||
@@ -257,6 +377,7 @@ def test_check_lock_stale_old_timestamp(tmp_path, monkeypatch):
|
||||
|
||||
def test_check_lock_permission_error_treated_active(tmp_path, monkeypatch):
|
||||
"""Lock PID that raises PermissionError is treated as active."""
|
||||
monkeypatch.setattr("sys.platform", "linux")
|
||||
lock_dir = tmp_path / ".ai_mail.local"
|
||||
lock_dir.mkdir(parents=True)
|
||||
lock_file = lock_dir / ".dispatch.lock"
|
||||
|
||||
@@ -18,6 +18,7 @@ Auto-discovery architecture:
|
||||
import os
|
||||
import sys
|
||||
import importlib
|
||||
import importlib.metadata
|
||||
from pathlib import Path
|
||||
from typing import List, Any
|
||||
|
||||
@@ -43,9 +44,13 @@ from aipass.prax import logger
|
||||
MODULES_DIR = Path(__file__).parent / "modules"
|
||||
|
||||
|
||||
_import_failures: dict[str, Exception] = {}
|
||||
|
||||
|
||||
def discover_modules() -> List[Any]:
|
||||
"""Auto-discover modules in modules/ directory."""
|
||||
modules = []
|
||||
_import_failures.clear()
|
||||
|
||||
if not MODULES_DIR.exists():
|
||||
return modules
|
||||
@@ -62,18 +67,25 @@ def discover_modules() -> List[Any]:
|
||||
modules.append(module)
|
||||
except Exception as e:
|
||||
logger.error(f"[AIPASS] Failed to load module {module_name}: {e}")
|
||||
_import_failures[file_path.stem] = e
|
||||
|
||||
return modules
|
||||
|
||||
|
||||
def route_command(command: str, args: List[str], modules: List[Any]) -> bool:
|
||||
"""Route command to appropriate module."""
|
||||
"""Route command to appropriate module.
|
||||
|
||||
Returns True on success. Raises on handler crash so callers can
|
||||
distinguish 'not found' (False) from 'found but broken'.
|
||||
"""
|
||||
for module in modules:
|
||||
try:
|
||||
if module.handle_command(command, args):
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"[AIPASS] Module {module.__name__} error: {e}")
|
||||
mod_name = module.__name__.split(".")[-1]
|
||||
logger.error(f"[AIPASS] Module {mod_name} crashed: {e}")
|
||||
raise
|
||||
return False
|
||||
|
||||
|
||||
@@ -88,14 +100,20 @@ def main():
|
||||
args = sys.argv[1:]
|
||||
|
||||
if len(args) > 0 and args[0] in ["--version", "-V"]:
|
||||
print("aipass 0.1.0")
|
||||
try:
|
||||
version = importlib.metadata.version("aipass")
|
||||
except importlib.metadata.PackageNotFoundError:
|
||||
logger.info("[AIPASS] Package metadata not found, version unknown")
|
||||
version = "unknown"
|
||||
print(f"aipass {version}")
|
||||
return 0
|
||||
|
||||
show_root_help = len(args) == 0 or args[0] in ["--help", "-h"] or (args[0] == "help" and len(args) == 1)
|
||||
if show_root_help:
|
||||
print(f"AIPASS - {len(modules)} modules discovered")
|
||||
for module in modules:
|
||||
name = module.__name__.split(".")[-1]
|
||||
stem = module.__name__.split(".")[-1]
|
||||
name = getattr(module, "COMMAND", stem)
|
||||
desc = (module.__doc__ or "").strip().split("\n")[0] if module.__doc__ else "No description"
|
||||
print(f" {name:20} {desc}")
|
||||
return 0
|
||||
@@ -103,8 +121,34 @@ def main():
|
||||
command = args[0]
|
||||
remaining = args[1:] if len(args) > 1 else []
|
||||
|
||||
if route_command(command, remaining, modules):
|
||||
return 0
|
||||
# Subcommand --help guard: intercept before dispatch
|
||||
if remaining and remaining[0] in ("--help", "-h"):
|
||||
for module in modules:
|
||||
if module.handle_command(command, ["--help"]):
|
||||
return 0
|
||||
print(f"Unknown command: {command}")
|
||||
return 1
|
||||
|
||||
try:
|
||||
if route_command(command, remaining, modules):
|
||||
return 0
|
||||
except Exception as e:
|
||||
print(f"Error: '{command}' crashed: {e}")
|
||||
logger.error(f"[AIPASS] '{command}' traceback", exc_info=True)
|
||||
return 1
|
||||
|
||||
if command.startswith("@"):
|
||||
print(f"{command} is a drone routing target, not an aipass command.")
|
||||
print("aipass is your front-door CLI; drone is the agent router — two separate tools.")
|
||||
print()
|
||||
print(f" Reach an agent: drone {command} ... · drone systems")
|
||||
print(" aipass commands: aipass --help")
|
||||
return 1
|
||||
|
||||
for stem, err in _import_failures.items():
|
||||
if command in (stem, stem.replace("_", "")):
|
||||
print(f"Error: '{command}' failed to load: {err}")
|
||||
return 1
|
||||
|
||||
print(f"Unknown command: {command}")
|
||||
return 1
|
||||
|
||||
@@ -33,8 +33,10 @@ RULES:
|
||||
import importlib.util
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import tempfile
|
||||
import uuid
|
||||
from datetime import date
|
||||
from pathlib import Path
|
||||
@@ -43,6 +45,29 @@ from aipass.aipass.apps.handlers.init import scaffold_content as sc
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_STALE_MANAGED_FILES: list[Path] = [
|
||||
Path(".aipass") / "aipass_global_prompt.md",
|
||||
]
|
||||
|
||||
|
||||
def is_throwaway_path(path: str | Path) -> bool:
|
||||
"""True if path is under a temp dir or Claude Code scratchpad."""
|
||||
resolved = str(Path(path).resolve())
|
||||
tmp_roots = [tempfile.gettempdir()]
|
||||
if os.name == "posix":
|
||||
tmp_roots.append("/tmp")
|
||||
for root in tmp_roots:
|
||||
try:
|
||||
r = str(Path(root).resolve())
|
||||
except OSError:
|
||||
logger.info("is_throwaway_path: could not resolve %s", root)
|
||||
continue
|
||||
if resolved == r or resolved.startswith(r + os.sep):
|
||||
return True
|
||||
if "scratchpad" in resolved.lower():
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _sanitize_name(raw: str) -> str:
|
||||
"""Sanitize a project name for use in filenames.
|
||||
@@ -211,8 +236,13 @@ def _claude_settings(aipass_home: str | None = None) -> str:
|
||||
],
|
||||
}
|
||||
|
||||
if aipass_home:
|
||||
if aipass_home and not is_throwaway_path(aipass_home):
|
||||
data["env"] = {"AIPASS_HOME": aipass_home}
|
||||
elif aipass_home:
|
||||
logger.warning(
|
||||
"AIPASS_HOME '%s' is a throwaway path — not writing to settings",
|
||||
aipass_home,
|
||||
)
|
||||
return json.dumps(data, indent=2, ensure_ascii=False) + "\n"
|
||||
|
||||
|
||||
@@ -474,6 +504,7 @@ def update_project(target: Path) -> dict:
|
||||
updated: list[str] = []
|
||||
already_current: list[str] = []
|
||||
skipped: list[str] = []
|
||||
removed: list[str] = []
|
||||
aipass_home: str | None = None
|
||||
|
||||
# Managed directories — create if missing (graceful recovery).
|
||||
@@ -595,11 +626,20 @@ def update_project(target: Path) -> dict:
|
||||
venv_link.symlink_to(aipass_venv)
|
||||
updated.append(f".venv (symlink to AIPass runtime: {aipass_venv})")
|
||||
|
||||
# --- Cruft cleanup: remove known-stale AIPass-managed artifacts ---
|
||||
for rel in _STALE_MANAGED_FILES:
|
||||
stale_path = target / rel
|
||||
if stale_path.is_file():
|
||||
stale_path.unlink()
|
||||
removed.append(str(stale_path))
|
||||
logger.info("Removed stale managed file: %s", stale_path)
|
||||
|
||||
return {
|
||||
"project_name": name,
|
||||
"target": str(target),
|
||||
"updated_files": updated,
|
||||
"already_current": already_current,
|
||||
"skipped_files": skipped,
|
||||
"removed_files": removed,
|
||||
"aipass_home": aipass_home,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
# =================== AIPass ====================
|
||||
# Name: provider_wire.py
|
||||
# Description: Auto-wire provider settings from manifest into user config
|
||||
# Version: 1.0.0
|
||||
# Created: 2026-07-11
|
||||
# Modified: 2026-07-11
|
||||
# =============================================
|
||||
|
||||
"""provider_wire — auto-wire provider settings.
|
||||
|
||||
Implements the additive merge of manifest into ~/.claude/settings.json.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import shutil
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Dict, List
|
||||
|
||||
from aipass.aipass.apps.handlers.json import json_handler
|
||||
|
||||
# =============================================================================
|
||||
# HOOK & ENV DESCRIPTIONS
|
||||
# =============================================================================
|
||||
|
||||
HOOK_DESCRIPTIONS: Dict[str, str] = {
|
||||
"pre_edit_gate.py": "blocks edits outside agent's branch",
|
||||
"subagent_stop_gate.py": "validates agent output on exit",
|
||||
"auto_fix_diagnostics.py": "auto-fixes lint issues after edits",
|
||||
"global_prompt_loader.py": "injects branch context on each turn",
|
||||
"identity_injector.py": "injects agent identity on each turn",
|
||||
"email_notification.py": "notifies on incoming agent mail",
|
||||
"branch_prompt_loader.py": "loads branch-specific prompts",
|
||||
"pre_compact.py": "saves state before context compaction",
|
||||
}
|
||||
|
||||
ENV_DESCRIPTIONS: Dict[str, str] = {
|
||||
"AIPASS_HOME": "tells agents where AIPass lives",
|
||||
"CLAUDE_CODE_DISABLE_AUTO_MEMORY": "prevents conflict with .trinity/ memory system",
|
||||
}
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# AUTO-WIRE
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def auto_wire_provider(manifest_path: Path, interactive: bool = True) -> List[str]:
|
||||
"""Auto-wire provider settings from manifest into ~/.claude/settings.json.
|
||||
|
||||
Additive merge only — never removes or overwrites existing keys/values.
|
||||
Returns list of action descriptions (for logging/display).
|
||||
"""
|
||||
actions: List[str] = []
|
||||
|
||||
manifest = json_handler.load_path(manifest_path)
|
||||
if manifest is None:
|
||||
return actions
|
||||
claude_section = manifest.get("cli", {}).get("claude", {})
|
||||
if not claude_section:
|
||||
return actions
|
||||
|
||||
settings_path = Path.home() / ".claude" / "settings.json"
|
||||
if settings_path.exists():
|
||||
settings = json_handler.load_path(settings_path) or {}
|
||||
else:
|
||||
settings = {}
|
||||
|
||||
if settings_path.exists():
|
||||
date_stamp = datetime.now(tz=timezone.utc).strftime("%Y-%m-%d")
|
||||
backup_path = settings_path.with_suffix(f".json.bak.{date_stamp}")
|
||||
shutil.copy2(settings_path, backup_path)
|
||||
actions.append(f"Backed up settings to {backup_path.name}")
|
||||
|
||||
manifest_hooks = claude_section.get("hooks", [])
|
||||
|
||||
for hook in manifest_hooks:
|
||||
command = hook.get("command", "")
|
||||
event = hook.get("event", "")
|
||||
if not command or not event:
|
||||
continue
|
||||
|
||||
if "hooks" not in settings:
|
||||
settings["hooks"] = {}
|
||||
if event not in settings["hooks"]:
|
||||
settings["hooks"][event] = []
|
||||
event_hooks = settings["hooks"][event]
|
||||
if not isinstance(event_hooks, list):
|
||||
event_hooks = [event_hooks]
|
||||
settings["hooks"][event] = event_hooks
|
||||
|
||||
hook_matcher = hook.get("matcher", "")
|
||||
already_wired = any(
|
||||
isinstance(h, dict) and command in json.dumps(h) and h.get("matcher", "") == hook_matcher
|
||||
for h in event_hooks
|
||||
)
|
||||
if not already_wired:
|
||||
cmd_entry: Dict[str, object] = {
|
||||
"type": "command",
|
||||
"command": command,
|
||||
}
|
||||
if hook.get("timeout"):
|
||||
cmd_entry["timeout"] = hook["timeout"]
|
||||
wrapper: Dict[str, object] = {}
|
||||
if hook.get("matcher"):
|
||||
wrapper["matcher"] = hook["matcher"]
|
||||
wrapper["hooks"] = [cmd_entry]
|
||||
event_hooks.append(wrapper)
|
||||
label = command.rsplit(" ", 1)[-1] if " " in command else command
|
||||
actions.append(f"Wired hook {label} -> {event}")
|
||||
|
||||
manifest_env = claude_section.get("env", {})
|
||||
if manifest_env:
|
||||
if "env" not in settings:
|
||||
settings["env"] = {}
|
||||
repo_root = str(manifest_path.parent.parent)
|
||||
project_root = str(Path.cwd())
|
||||
for key, value in manifest_env.items():
|
||||
if key not in settings["env"]:
|
||||
resolved = value.replace("{{REPO_ROOT}}", repo_root)
|
||||
resolved = resolved.replace("{{PROJECT_ROOT}}", project_root)
|
||||
settings["env"][key] = resolved
|
||||
actions.append(f"Set env {key}={resolved}")
|
||||
|
||||
manifest_perms = claude_section.get("permissions", {})
|
||||
manifest_deny = manifest_perms.get("deny", [])
|
||||
manifest_ask = manifest_perms.get("ask", [])
|
||||
|
||||
if manifest_deny or manifest_ask:
|
||||
if "permissions" not in settings:
|
||||
settings["permissions"] = {}
|
||||
if "deny" not in settings["permissions"]:
|
||||
settings["permissions"]["deny"] = []
|
||||
if "ask" not in settings["permissions"]:
|
||||
settings["permissions"]["ask"] = []
|
||||
|
||||
existing_deny = set(settings["permissions"]["deny"])
|
||||
for rule in manifest_deny:
|
||||
if rule not in existing_deny:
|
||||
settings["permissions"]["deny"].append(rule)
|
||||
actions.append(f"Added deny rule: {rule}")
|
||||
|
||||
existing_ask = set(settings["permissions"]["ask"])
|
||||
for rule in manifest_ask:
|
||||
if rule not in existing_ask:
|
||||
settings["permissions"]["ask"].append(rule)
|
||||
actions.append(f"Added ask rule: {rule}")
|
||||
|
||||
json_handler.save_path(settings_path, settings)
|
||||
actions.append("Updated ~/.claude/settings.json")
|
||||
|
||||
json_handler.log_operation("auto_wire_provider", {"actions": len(actions)})
|
||||
return actions
|
||||
@@ -17,7 +17,7 @@ import sys
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, NamedTuple
|
||||
|
||||
from aipass.cli.apps.modules import console
|
||||
from aipass.cli.apps.modules import console, error as cli_error, success
|
||||
from aipass.prax import logger
|
||||
|
||||
from aipass.aipass.shared.registry_discovery import find_registry as _discover_registry
|
||||
@@ -60,7 +60,8 @@ from aipass.aipass.apps.modules.doctor_fix import (
|
||||
)
|
||||
from aipass.aipass.apps.modules.doctor_wire import (
|
||||
_auto_wire_provider,
|
||||
prompt_auto_wire,
|
||||
_prompt_auto_wire as prompt_auto_wire,
|
||||
check_wire_verify,
|
||||
reconcile_stale_deny,
|
||||
)
|
||||
from aipass.aipass.apps.handlers.system_detect.system_detector import (
|
||||
@@ -151,6 +152,118 @@ def _check_system() -> List[CheckResult]:
|
||||
return results
|
||||
|
||||
|
||||
def _check_global_aipass_home() -> List[CheckResult]:
|
||||
"""Check ~/.claude/settings.json env.AIPASS_HOME for stale or temp paths."""
|
||||
from aipass.aipass.apps.handlers.init.bootstrap import is_throwaway_path
|
||||
|
||||
results: List[CheckResult] = []
|
||||
settings_path = Path.home() / ".claude" / "settings.json"
|
||||
if not settings_path.exists():
|
||||
return results
|
||||
try:
|
||||
data = json.loads(settings_path.read_text(encoding="utf-8"))
|
||||
except (json.JSONDecodeError, OSError) as exc:
|
||||
logger.info("[doctor] global settings.json unreadable: %s", exc)
|
||||
return results
|
||||
home_val = data.get("env", {}).get("AIPASS_HOME", "")
|
||||
if not home_val:
|
||||
return results
|
||||
home_path = Path(home_val)
|
||||
if not home_path.exists():
|
||||
results.append(
|
||||
CheckResult(
|
||||
"global AIPASS_HOME",
|
||||
GLYPH_FAIL,
|
||||
f"path does not exist: {home_val}",
|
||||
"Fix: edit ~/.claude/settings.json env.AIPASS_HOME to the real repo root",
|
||||
)
|
||||
)
|
||||
elif is_throwaway_path(home_val):
|
||||
results.append(
|
||||
CheckResult(
|
||||
"global AIPASS_HOME",
|
||||
GLYPH_FAIL,
|
||||
f"points to throwaway path: {home_val}",
|
||||
"Fix: edit ~/.claude/settings.json env.AIPASS_HOME to the real repo root",
|
||||
)
|
||||
)
|
||||
else:
|
||||
results.append(CheckResult("global AIPASS_HOME", GLYPH_PASS, home_val, ""))
|
||||
return results
|
||||
|
||||
|
||||
def _check_owner_seating() -> List[CheckResult]:
|
||||
"""Check owner/identity health via the frozen sync-registry --check contract."""
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
["drone", "@spawn", "sync-registry", "--check", "--json"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=30,
|
||||
)
|
||||
except FileNotFoundError:
|
||||
logger.info("[doctor] drone not on PATH — skipping owner seating check")
|
||||
return [CheckResult("owner", GLYPH_WARN, "drone not found", "Install drone to check owner seating")]
|
||||
except subprocess.TimeoutExpired:
|
||||
logger.warning("[doctor] sync-registry --check timed out")
|
||||
return [CheckResult("owner", GLYPH_WARN, "check timed out", "")]
|
||||
|
||||
stdout = proc.stdout.strip()
|
||||
if not stdout:
|
||||
if proc.returncode == 0:
|
||||
return [CheckResult("owner", GLYPH_PASS, "clean (no details)", "")]
|
||||
return [CheckResult("owner", GLYPH_WARN, "no output from check", "")]
|
||||
|
||||
try:
|
||||
data = json.loads(stdout)
|
||||
except json.JSONDecodeError:
|
||||
logger.warning("[doctor] sync-registry --check returned non-JSON: %s", stdout[:200])
|
||||
return [CheckResult("owner", GLYPH_WARN, "unparseable check output", "")]
|
||||
|
||||
issues = data.get("issues", [])
|
||||
owner_name = data.get("owner")
|
||||
owner_uid = data.get("owner_uid", "")
|
||||
uid_short = owner_uid[:8] if owner_uid else ""
|
||||
|
||||
if data.get("clean", False) and not issues:
|
||||
detail = f"@{owner_name} OK (seated, uid {uid_short})" if owner_name else "OK"
|
||||
return [CheckResult("owner", GLYPH_PASS, detail, "")]
|
||||
|
||||
results: List[CheckResult] = []
|
||||
for issue in issues:
|
||||
flag = issue.get("flag", "unknown")
|
||||
detail = issue.get("detail", flag)
|
||||
results.append(CheckResult(f"owner/{flag}", GLYPH_FAIL, detail, "Run 'aipass doctor --fix'"))
|
||||
|
||||
if not results:
|
||||
label = f"@{owner_name} ISSUES" if owner_name else "UNSEATED"
|
||||
results.append(CheckResult("owner", GLYPH_FAIL, label, "Run 'aipass doctor --fix'"))
|
||||
|
||||
return results
|
||||
|
||||
|
||||
def _fix_owner_seating() -> List[CheckResult]:
|
||||
"""Delegate owner/identity repair to spawn's sync-registry --fix."""
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
["drone", "@spawn", "sync-registry", "--fix"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=60,
|
||||
)
|
||||
except FileNotFoundError:
|
||||
logger.info("[doctor] drone not on PATH — skipping owner fix")
|
||||
return [CheckResult("owner fix", GLYPH_WARN, "drone not found", "")]
|
||||
except subprocess.TimeoutExpired:
|
||||
logger.warning("[doctor] sync-registry --fix timed out")
|
||||
return [CheckResult("owner fix", GLYPH_WARN, "fix timed out", "")]
|
||||
|
||||
if proc.returncode == 0:
|
||||
return [CheckResult("owner fix", GLYPH_PASS, "registry reconciled", "")]
|
||||
detail = proc.stderr.strip()[:120] if proc.stderr else "non-zero exit"
|
||||
return [CheckResult("owner fix", GLYPH_FAIL, detail, "")]
|
||||
|
||||
|
||||
def _check_identity() -> List[CheckResult]:
|
||||
"""Run Identity group checks."""
|
||||
results: List[CheckResult] = []
|
||||
@@ -173,6 +286,8 @@ def _check_identity() -> List[CheckResult]:
|
||||
)
|
||||
)
|
||||
|
||||
results.extend(_check_global_aipass_home())
|
||||
|
||||
if reg_path is None:
|
||||
results.append(CheckResult("registry", GLYPH_FAIL, "not found", "Run 'aipass init' to create registry"))
|
||||
return results
|
||||
@@ -222,6 +337,8 @@ def _check_identity() -> List[CheckResult]:
|
||||
else:
|
||||
results.append(CheckResult("passport", GLYPH_WARN, "not found", ""))
|
||||
|
||||
results.extend(_check_owner_seating())
|
||||
|
||||
return results
|
||||
|
||||
|
||||
@@ -381,7 +498,7 @@ def _check_provider_manifest(interactive: bool = False, fix: bool = False) -> Li
|
||||
if fix:
|
||||
actions = _auto_wire_provider(manifest_path, interactive=False)
|
||||
for action in actions:
|
||||
console.print(f"[green]✓[/green] {action}")
|
||||
success(action)
|
||||
wired = bool(actions)
|
||||
else:
|
||||
wired = prompt_auto_wire(manifest_path, missing_hooks, missing_env, missing_deny, missing_ask)
|
||||
@@ -468,6 +585,9 @@ def _check_services(verbose: bool = False) -> List[CheckResult]:
|
||||
manifest_checks = _check_provider_manifest()
|
||||
results.extend(manifest_checks)
|
||||
|
||||
# wire_verify guard — catch empty/orphaned/duplicate provider hook entries
|
||||
results.extend(CheckResult(*r) for r in check_wire_verify())
|
||||
|
||||
# stale rm deny rules — detect only (fix runs in run_doctor when --fix)
|
||||
for tup in reconcile_stale_deny(fix=False):
|
||||
results.append(CheckResult(*tup))
|
||||
@@ -853,11 +973,11 @@ def run_cross_os_record(path: str | None = None, run_e2e: bool = False) -> int:
|
||||
try:
|
||||
written = generate_run_record(path, run_heavy_e2e=run_e2e)
|
||||
except RunRecordError as exc:
|
||||
console.print(f"[red]✗[/red] {exc}")
|
||||
cli_error(str(exc))
|
||||
logger.error("[doctor] cross-os run record failed: %s", exc)
|
||||
return 1
|
||||
|
||||
console.print(f"[green]✓[/green] Run Record written: [bold]{written}[/bold]")
|
||||
success(f"Run Record written: {written}")
|
||||
console.print("[dim]Complete the '— human' rows and run the real Layer-3 acceptance pass before it counts.[/dim]")
|
||||
console.print()
|
||||
logger.info("[doctor] cross-os run record written to %s", written)
|
||||
@@ -901,6 +1021,14 @@ def run_doctor(verbose: bool = False, interactive: bool = False, fix: bool = Fal
|
||||
services = groups.get("Services", [])
|
||||
groups["Services"] = [r for r in services if r.label != "rm deny migration"] + stale_results
|
||||
|
||||
wire_recheck = [CheckResult(*r) for r in check_wire_verify()]
|
||||
services = groups.get("Services", [])
|
||||
groups["Services"] = [r for r in services if r.label != "wire verify"] + wire_recheck
|
||||
|
||||
owner_fix = _fix_owner_seating()
|
||||
identity = groups.get("Identity", [])
|
||||
groups["Identity"] = [r for r in identity if not r.label.startswith("owner")] + owner_fix
|
||||
|
||||
pass_count = 0
|
||||
warn_count = 0
|
||||
error_count = 0
|
||||
@@ -953,7 +1081,7 @@ def print_help() -> None:
|
||||
console.print("[yellow]USAGE:[/yellow]")
|
||||
console.print(" [green]aipass doctor[/green] [dim]# Run all checks[/dim]")
|
||||
console.print(" [green]aipass doctor --verbose[/green] [dim]# Show sub-check detail[/dim]")
|
||||
console.print(" [green]aipass doctor --fix[/green] [dim]# Auto-wire + remediation report[/dim]")
|
||||
console.print(" [green]aipass doctor --fix[/green] [dim]# Auto-wire, owner seat repair + remediation[/dim]")
|
||||
console.print(" [green]aipass doctor --fix --json[/green][dim]# Remediation as JSON (for spawn)[/dim]")
|
||||
console.print(" [green]aipass doctor --cross-os[/green][dim]# OS-gap + routing/version/hooks pre-flight[/dim]")
|
||||
console.print(" [green]aipass doctor --cross-os --e2e[/green][dim]# …also run the heavy e2e suite[/dim]")
|
||||
@@ -962,7 +1090,7 @@ def print_help() -> None:
|
||||
"[dim]# write a machine pre-flight Run Record draft (human completes it)[/dim]"
|
||||
)
|
||||
console.print()
|
||||
console.print("[yellow]OUTPUT:[/yellow] [green]✓[/green] pass [yellow]![/yellow] warn [red]✗[/red] error")
|
||||
console.print("[yellow]OUTPUT:[/yellow] pass / warn / error (color-coded)")
|
||||
console.print("[yellow]EXIT:[/yellow] 0 = pass/warn | 1 = errors found")
|
||||
console.print()
|
||||
|
||||
|
||||
@@ -18,36 +18,20 @@ Provides:
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import shutil
|
||||
from datetime import datetime, timezone
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Dict, List
|
||||
from typing import List, NamedTuple
|
||||
|
||||
from aipass.cli.apps.modules import console
|
||||
from aipass.cli.apps.modules import console, success
|
||||
from aipass.prax import logger
|
||||
|
||||
from aipass.aipass.apps.handlers.json import json_handler
|
||||
|
||||
# =============================================================================
|
||||
# HOOK & ENV DESCRIPTIONS (for interactive "no" warning)
|
||||
# =============================================================================
|
||||
|
||||
HOOK_DESCRIPTIONS: Dict[str, str] = {
|
||||
"pre_edit_gate.py": "blocks edits outside agent's branch",
|
||||
"subagent_stop_gate.py": "validates agent output on exit",
|
||||
"auto_fix_diagnostics.py": "auto-fixes lint issues after edits",
|
||||
"global_prompt_loader.py": "injects branch context on each turn",
|
||||
"identity_injector.py": "injects agent identity on each turn",
|
||||
"email_notification.py": "notifies on incoming agent mail",
|
||||
"branch_prompt_loader.py": "loads branch-specific prompts",
|
||||
"pre_compact.py": "saves state before context compaction",
|
||||
}
|
||||
|
||||
ENV_DESCRIPTIONS: Dict[str, str] = {
|
||||
"AIPASS_HOME": "tells agents where AIPass lives",
|
||||
"CLAUDE_CODE_DISABLE_AUTO_MEMORY": "prevents conflict with .trinity/ memory system",
|
||||
}
|
||||
from aipass.aipass.apps.handlers.provider_wire import ( # noqa: F401
|
||||
HOOK_DESCRIPTIONS,
|
||||
ENV_DESCRIPTIONS,
|
||||
auto_wire_provider as _auto_wire_provider,
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
@@ -57,130 +41,12 @@ ENV_DESCRIPTIONS: Dict[str, str] = {
|
||||
from aipass.aipass.apps.handlers.provider_reconcile import reconcile_stale_deny # noqa: E402, F401
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# AUTO-WIRE
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def _auto_wire_provider(manifest_path: Path, interactive: bool = True) -> List[str]:
|
||||
"""Auto-wire provider settings from manifest into ~/.claude/settings.json.
|
||||
|
||||
Additive merge only — never removes or overwrites existing keys/values.
|
||||
Returns list of action descriptions (for logging/display).
|
||||
"""
|
||||
actions: List[str] = []
|
||||
|
||||
manifest = json_handler.load_path(manifest_path)
|
||||
if manifest is None:
|
||||
return actions
|
||||
claude_section = manifest.get("cli", {}).get("claude", {})
|
||||
if not claude_section:
|
||||
return actions
|
||||
|
||||
# Read existing settings
|
||||
settings_path = Path.home() / ".claude" / "settings.json"
|
||||
if settings_path.exists():
|
||||
settings = json_handler.load_path(settings_path) or {}
|
||||
else:
|
||||
settings = {}
|
||||
|
||||
# Backup
|
||||
if settings_path.exists():
|
||||
date_stamp = datetime.now(tz=timezone.utc).strftime("%Y-%m-%d")
|
||||
backup_path = settings_path.with_suffix(f".json.bak.{date_stamp}")
|
||||
shutil.copy2(settings_path, backup_path)
|
||||
actions.append(f"Backed up settings to {backup_path.name}")
|
||||
|
||||
# Hooks — add bridge entries to provider settings
|
||||
manifest_hooks = claude_section.get("hooks", [])
|
||||
|
||||
for hook in manifest_hooks:
|
||||
command = hook.get("command", "")
|
||||
event = hook.get("event", "")
|
||||
if not command or not event:
|
||||
continue
|
||||
|
||||
if "hooks" not in settings:
|
||||
settings["hooks"] = {}
|
||||
if event not in settings["hooks"]:
|
||||
settings["hooks"][event] = []
|
||||
event_hooks = settings["hooks"][event]
|
||||
if not isinstance(event_hooks, list):
|
||||
event_hooks = [event_hooks]
|
||||
settings["hooks"][event] = event_hooks
|
||||
|
||||
hook_matcher = hook.get("matcher", "")
|
||||
already_wired = any(
|
||||
isinstance(h, dict) and command in json.dumps(h) and h.get("matcher", "") == hook_matcher
|
||||
for h in event_hooks
|
||||
)
|
||||
if not already_wired:
|
||||
cmd_entry: Dict[str, object] = {
|
||||
"type": "command",
|
||||
"command": command,
|
||||
}
|
||||
if hook.get("timeout"):
|
||||
cmd_entry["timeout"] = hook["timeout"]
|
||||
wrapper: Dict[str, object] = {}
|
||||
if hook.get("matcher"):
|
||||
wrapper["matcher"] = hook["matcher"]
|
||||
wrapper["hooks"] = [cmd_entry]
|
||||
event_hooks.append(wrapper)
|
||||
label = command.rsplit(" ", 1)[-1] if " " in command else command
|
||||
actions.append(f"Wired hook {label} -> {event}")
|
||||
|
||||
# Env vars
|
||||
manifest_env = claude_section.get("env", {})
|
||||
if manifest_env:
|
||||
if "env" not in settings:
|
||||
settings["env"] = {}
|
||||
repo_root = str(manifest_path.parent.parent)
|
||||
project_root = str(Path.cwd())
|
||||
for key, value in manifest_env.items():
|
||||
if key not in settings["env"]:
|
||||
resolved = value.replace("{{REPO_ROOT}}", repo_root)
|
||||
resolved = resolved.replace("{{PROJECT_ROOT}}", project_root)
|
||||
settings["env"][key] = resolved
|
||||
actions.append(f"Set env {key}={resolved}")
|
||||
|
||||
# Permissions
|
||||
manifest_perms = claude_section.get("permissions", {})
|
||||
manifest_deny = manifest_perms.get("deny", [])
|
||||
manifest_ask = manifest_perms.get("ask", [])
|
||||
|
||||
if manifest_deny or manifest_ask:
|
||||
if "permissions" not in settings:
|
||||
settings["permissions"] = {}
|
||||
if "deny" not in settings["permissions"]:
|
||||
settings["permissions"]["deny"] = []
|
||||
if "ask" not in settings["permissions"]:
|
||||
settings["permissions"]["ask"] = []
|
||||
|
||||
existing_deny = set(settings["permissions"]["deny"])
|
||||
for rule in manifest_deny:
|
||||
if rule not in existing_deny:
|
||||
settings["permissions"]["deny"].append(rule)
|
||||
actions.append(f"Added deny rule: {rule}")
|
||||
|
||||
existing_ask = set(settings["permissions"]["ask"])
|
||||
for rule in manifest_ask:
|
||||
if rule not in existing_ask:
|
||||
settings["permissions"]["ask"].append(rule)
|
||||
actions.append(f"Added ask rule: {rule}")
|
||||
|
||||
# Write settings back
|
||||
json_handler.save_path(settings_path, settings)
|
||||
actions.append("Updated ~/.claude/settings.json")
|
||||
|
||||
return actions
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# INTERACTIVE WIRE PROMPTS
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def prompt_auto_wire(
|
||||
def _prompt_auto_wire(
|
||||
manifest_path: Path,
|
||||
missing_hooks: List[str],
|
||||
missing_env: List[str],
|
||||
@@ -205,16 +71,20 @@ def prompt_auto_wire(
|
||||
console.print(f"\n[bold]{', '.join(parts)} missing[/bold]")
|
||||
console.print("[dim]Review details: .claude/hooks/README.md[/dim]")
|
||||
|
||||
try:
|
||||
answer = input("Auto-wire provider settings? [y/N]: ").strip().lower()
|
||||
except (EOFError, KeyboardInterrupt) as exc:
|
||||
logger.info("[doctor] auto-wire prompt interrupted: %s", type(exc).__name__)
|
||||
if not sys.stdin.isatty():
|
||||
logger.info("[doctor] non-interactive stdin — auto-wire prompt skipped, treating as decline")
|
||||
answer = "n"
|
||||
else:
|
||||
try:
|
||||
answer = input("Auto-wire provider settings? [y/N]: ").strip().lower()
|
||||
except (EOFError, KeyboardInterrupt) as exc:
|
||||
logger.info("[doctor] auto-wire prompt interrupted: %s", type(exc).__name__)
|
||||
answer = "n"
|
||||
|
||||
if answer in ("y", "yes"):
|
||||
actions = _auto_wire_provider(manifest_path, interactive=True)
|
||||
for action in actions:
|
||||
console.print(f"[green]✓[/green] {action}")
|
||||
success(action)
|
||||
return bool(actions)
|
||||
|
||||
_print_manual_wire_warning(missing_hooks, missing_env, missing_deny, missing_ask)
|
||||
@@ -304,3 +174,51 @@ def handle_command(command: str, args: list[str]) -> bool:
|
||||
|
||||
json_handler.log_operation("doctor_wire_noop", {"command": command})
|
||||
return False
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# WIRE VERIFY GUARD (doctor check row)
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class WireCheckResult(NamedTuple):
|
||||
"""Single doctor check result (mirrors doctor.CheckResult without importing it)."""
|
||||
|
||||
label: str
|
||||
glyph: str
|
||||
detail: str
|
||||
remediation: str
|
||||
|
||||
|
||||
_GLYPH_PASS = "[green]✓[/green]"
|
||||
_GLYPH_FAIL = "[red]✗[/red]"
|
||||
_GLYPH_WARN = "[yellow]![/yellow]"
|
||||
|
||||
|
||||
def check_wire_verify() -> list[WireCheckResult]:
|
||||
"""Run the hooks wire_verify guard — catch empty/orphaned/duplicate provider entries."""
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
["drone", "@hooks", "verify"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10,
|
||||
)
|
||||
if proc.returncode == 0:
|
||||
return [WireCheckResult("wire verify", _GLYPH_PASS, "provider hooks wired correctly", "")]
|
||||
lines = [ln.strip() for ln in proc.stdout.splitlines() if ln.strip()]
|
||||
detail = lines[-1] if lines else "errors detected"
|
||||
return [
|
||||
WireCheckResult(
|
||||
"wire verify",
|
||||
_GLYPH_FAIL,
|
||||
detail,
|
||||
"Run 'aipass doctor --fix' to re-wire, then re-run doctor to confirm",
|
||||
)
|
||||
]
|
||||
except FileNotFoundError as exc:
|
||||
logger.warning("[doctor] drone not found for wire_verify: %s", exc)
|
||||
return [WireCheckResult("wire verify", _GLYPH_WARN, "drone not found", "")]
|
||||
except subprocess.TimeoutExpired as exc:
|
||||
logger.warning("[doctor] wire_verify timed out: %s", exc)
|
||||
return [WireCheckResult("wire verify", _GLYPH_WARN, "timed out", "")]
|
||||
|
||||
@@ -21,7 +21,7 @@ Usage:
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from aipass.cli.apps.modules import console, warning
|
||||
from aipass.cli.apps.modules import console, success, warning
|
||||
from aipass.prax import logger
|
||||
|
||||
from aipass.aipass.apps.handlers.json import json_handler
|
||||
@@ -64,7 +64,7 @@ def do_handoff(
|
||||
|
||||
if launched:
|
||||
console.print()
|
||||
console.print(f"[green]✓[/green] Session started via tmux/wt — CLI: [cyan]{cli}[/cyan]")
|
||||
success(f"Session started via tmux/wt — CLI: {cli}")
|
||||
console.print(f"[dim]Session name: aipass-handoff | cwd: {cwd}[/dim]")
|
||||
console.print()
|
||||
else:
|
||||
|
||||
@@ -35,7 +35,7 @@ from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List
|
||||
|
||||
from aipass.cli.apps.modules import console, warning
|
||||
from aipass.cli.apps.modules import console, error as cli_error, success, warning
|
||||
from aipass.prax import logger
|
||||
|
||||
from aipass.aipass.apps.handlers.json import json_handler
|
||||
@@ -210,7 +210,7 @@ def _choose(msg: str, choices: List[str], default: str | None = None) -> str:
|
||||
return choices[idx]
|
||||
except ValueError as exc:
|
||||
logger.info("[init_flow] invalid menu input %r: %s", raw, exc)
|
||||
console.print("[red]Invalid choice.[/red]")
|
||||
cli_error("Invalid choice.")
|
||||
|
||||
|
||||
# --- STAGE FUNCTIONS ---
|
||||
@@ -358,7 +358,7 @@ def stage_3_user_profile(
|
||||
else:
|
||||
profile_mod.save_profile(existing)
|
||||
|
||||
console.print(f"[green]✓[/green] Hello, {name}!")
|
||||
success(f"Hello, {name}!")
|
||||
_save_stage(3, {"name": name}, dry_run=dry_run)
|
||||
return {"name": name}
|
||||
|
||||
@@ -379,7 +379,7 @@ def stage_4_style_questions(
|
||||
else:
|
||||
style = _choose("What are you looking to do?", STYLE_CHOICES, default=STYLE_CHOICES[0])
|
||||
|
||||
console.print(f"[green]✓[/green] Got it: {style}")
|
||||
success(f"Got it: {style}")
|
||||
_save_stage(4, {"style": style}, dry_run=dry_run)
|
||||
return {"style": style}
|
||||
|
||||
@@ -424,7 +424,7 @@ def _handle_missing_claude(non_interactive: bool) -> None:
|
||||
if raw.lower() in ("y", "yes", ""):
|
||||
console.print("[cyan]Installing Claude Code[/cyan] [dim](this can take a minute)…[/dim]")
|
||||
if _install_claude_code():
|
||||
console.print("[green]✓[/green] Claude Code installed successfully.")
|
||||
success("Claude Code installed successfully.")
|
||||
else:
|
||||
warning("[bold yellow]Installation failed.[/bold yellow]")
|
||||
console.print(" Install manually: https://claude.ai/download")
|
||||
@@ -460,7 +460,7 @@ def stage_5_tool_choice(
|
||||
default="default",
|
||||
)
|
||||
|
||||
console.print(f"[green]✓[/green] {cli_choice} ({flag_variant})")
|
||||
success(f"{cli_choice} ({flag_variant})")
|
||||
_save_stage(5, {"cli": cli_choice, "flag_variant": flag_variant}, dry_run=dry_run)
|
||||
|
||||
if dry_run:
|
||||
@@ -496,14 +496,14 @@ def stage_6_first_agent(non_interactive: bool = False, dry_run: bool = False) ->
|
||||
agent_path = f"src/{agent_name}"
|
||||
console.print(f"[cyan]Creating your first agent[/cyan] [dim](drone @spawn create {agent_path})…[/dim]")
|
||||
|
||||
success = False
|
||||
spawned = False
|
||||
if dry_run:
|
||||
console.print(f"[yellow]\\[dry-run][/yellow] would run: drone @spawn create {agent_path}")
|
||||
success = True
|
||||
spawned = True
|
||||
else:
|
||||
try:
|
||||
proc = subprocess.run(["drone", "@spawn", "create", agent_path], timeout=60)
|
||||
success = proc.returncode == 0
|
||||
spawned = proc.returncode == 0
|
||||
except FileNotFoundError as exc:
|
||||
logger.warning("[init_flow] drone not found in stage 6: %s", exc)
|
||||
warning("drone not found — skipping agent creation.")
|
||||
@@ -511,10 +511,10 @@ def stage_6_first_agent(non_interactive: bool = False, dry_run: bool = False) ->
|
||||
logger.warning("[init_flow] spawn timed out in stage 6: %s", exc)
|
||||
warning("spawn timed out — agent may still be created.")
|
||||
|
||||
if success:
|
||||
console.print(f"[green]✓[/green] Agent created at {agent_path}")
|
||||
if spawned:
|
||||
success(f"Agent created at {agent_path}")
|
||||
|
||||
_save_stage(6, {"agent_name": agent_name, "agent_path": agent_path, "success": success}, dry_run=dry_run)
|
||||
_save_stage(6, {"agent_name": agent_name, "agent_path": agent_path, "success": spawned}, dry_run=dry_run)
|
||||
return {"agent_name": agent_name, "agent_path": agent_path}
|
||||
|
||||
|
||||
@@ -576,12 +576,12 @@ def stage_8_smoke_test(non_interactive: bool = False, dry_run: bool = False) ->
|
||||
aipass_bin = shutil.which("aipass")
|
||||
|
||||
if drone_bin:
|
||||
console.print(f"[green]✓[/green] drone: {drone_bin}")
|
||||
success(f"drone: {drone_bin}")
|
||||
else:
|
||||
warning("drone not on PATH — clone the repo and run setup.sh")
|
||||
|
||||
if aipass_bin:
|
||||
console.print(f"[green]✓[/green] aipass: {aipass_bin}")
|
||||
success(f"aipass: {aipass_bin}")
|
||||
else:
|
||||
warning("aipass not on PATH — clone the repo and run setup.sh")
|
||||
|
||||
@@ -640,7 +640,7 @@ def stage_9_handoff(
|
||||
if accumulated:
|
||||
_write_init_report(accumulated.get("agent_path", agent_path), accumulated, dry_run=dry_run)
|
||||
console.print()
|
||||
console.print("[bold green]✓ Setup complete![/bold green]")
|
||||
success("Setup complete!")
|
||||
console.print()
|
||||
from aipass.aipass.apps.handlers.handoff_platform import launch_inline
|
||||
|
||||
@@ -716,7 +716,7 @@ def stage_10_done(accumulated: Dict[str, Any] | None = None, dry_run: bool = Fal
|
||||
console.print()
|
||||
console.print(render_step_header(10, TOTAL_STAGES, "Done!"))
|
||||
console.print()
|
||||
console.print("[bold green]✓ Setup complete![/bold green]")
|
||||
success("Setup complete!")
|
||||
console.print()
|
||||
console.print(" [cyan]aipass help[/cyan] [dim]# Ask any question[/dim]")
|
||||
console.print(" [cyan]aipass doctor[/cyan] [dim]# Check system health[/dim]")
|
||||
@@ -777,7 +777,7 @@ def run_init(
|
||||
# Pre-flight: refuse to run inside existing projects or agent dirs
|
||||
err = _preflight_check()
|
||||
if err:
|
||||
console.print(f"[red]✗[/red] {err}")
|
||||
cli_error(str(err))
|
||||
return 1
|
||||
|
||||
# Template selection — before scaffold
|
||||
@@ -799,7 +799,7 @@ def run_init(
|
||||
if not dry_run:
|
||||
with activity_spinner("Building project scaffold…"):
|
||||
init_project(cwd)
|
||||
console.print("[green]✓[/green] Project scaffold ready")
|
||||
success("Project scaffold ready")
|
||||
else:
|
||||
console.print("[yellow]\\[dry-run][/yellow] would create project scaffold")
|
||||
|
||||
@@ -807,7 +807,7 @@ def run_init(
|
||||
last_done = 0 if dry_run else _get_last_completed_stage()
|
||||
|
||||
if last_done >= TOTAL_STAGES:
|
||||
console.print("[green]✓[/green] Setup already complete.")
|
||||
success("Setup already complete.")
|
||||
console.print("[dim]Run 'aipass doctor' to check status.[/dim]")
|
||||
return 0
|
||||
|
||||
@@ -859,7 +859,7 @@ def run_init(
|
||||
|
||||
if template != TEMPLATE_AIPASS:
|
||||
console.print()
|
||||
console.print("[green]✓[/green] Project initialized.")
|
||||
success("Project initialized.")
|
||||
console.print("[dim]Run 'aipass init agent <name>' to add an agent.[/dim]")
|
||||
|
||||
return 0
|
||||
@@ -877,7 +877,7 @@ def print_introspection() -> None:
|
||||
if last == 0:
|
||||
console.print("[dim]Setup not started. Run: aipass init run[/dim]")
|
||||
elif last >= TOTAL_STAGES:
|
||||
console.print("[green]✓[/green] Setup complete.")
|
||||
success("Setup complete.")
|
||||
else:
|
||||
console.print(f"[yellow]In progress:[/yellow] stage {last}/{TOTAL_STAGES} completed.")
|
||||
console.print(f"[dim]Run 'aipass init run' to resume from stage {last + 1}.[/dim]")
|
||||
@@ -911,7 +911,7 @@ def _handle_init_scaffold(args: list[str]) -> int:
|
||||
project_name = args[1] if len(args) > 1 else None
|
||||
try:
|
||||
result = init_project(target, project_name)
|
||||
console.print(f"\n[green]✓[/green] Project initialized at [bold]{target}[/bold]")
|
||||
success(f"Project initialized at {target}")
|
||||
console.print()
|
||||
|
||||
# Find the package directory to show in guidance
|
||||
@@ -938,7 +938,7 @@ def _handle_init_scaffold(args: list[str]) -> int:
|
||||
return 0
|
||||
except Exception as exc:
|
||||
logger.warning("[init_flow] scaffold failed: %s", exc)
|
||||
console.print(f"[red]✗[/red] Init failed: {exc}")
|
||||
cli_error(f"Init failed: {exc}")
|
||||
return 1
|
||||
|
||||
|
||||
@@ -952,23 +952,39 @@ def _handle_init_update(args: list[str]) -> int:
|
||||
updated = result.get("updated_files", [])
|
||||
current = result.get("already_current", [])
|
||||
if updated:
|
||||
console.print(f"[green]✓[/green] Updated {len(updated)} file(s):")
|
||||
success(f"Updated {len(updated)} file(s):")
|
||||
for f in updated:
|
||||
console.print(f" [green]+[/green] {f}")
|
||||
console.print(f" + {f}")
|
||||
else:
|
||||
console.print("[green]✓[/green] All files already current.")
|
||||
success("All files already current.")
|
||||
if current:
|
||||
console.print(f" ({len(current)} already up to date)")
|
||||
# Heal registry: prune stale entries (e.g. cross-project ../paths)
|
||||
# Owner/identity check + heal via the frozen sync-registry contract
|
||||
try:
|
||||
sync_proc = subprocess.run(
|
||||
["drone", "@spawn", "sync-registry", "--fix"],
|
||||
check_proc = subprocess.run(
|
||||
["drone", "@spawn", "sync-registry", "--check"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=30,
|
||||
)
|
||||
if sync_proc.returncode == 0:
|
||||
console.print(" [green]Registry synced.[/green]")
|
||||
if check_proc.returncode != 0:
|
||||
warning("Owner/identity issues detected — auto-repairing…")
|
||||
fix_proc = subprocess.run(
|
||||
["drone", "@spawn", "sync-registry", "--fix"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=60,
|
||||
)
|
||||
if fix_proc.returncode == 0:
|
||||
success("Registry owner/identity reconciled.")
|
||||
else:
|
||||
logger.warning("[init_flow] sync-registry --fix exit %s", fix_proc.returncode)
|
||||
else:
|
||||
success("Owner/identity OK.")
|
||||
except FileNotFoundError:
|
||||
logger.info("[init_flow] drone not on PATH — skipping owner check")
|
||||
except subprocess.TimeoutExpired:
|
||||
logger.warning("[init_flow] sync-registry timed out during update")
|
||||
except Exception as sync_exc:
|
||||
logger.warning("[init_flow] registry sync during update skipped: %s", sync_exc)
|
||||
|
||||
@@ -976,14 +992,14 @@ def _handle_init_update(args: list[str]) -> int:
|
||||
return 0
|
||||
except Exception as exc:
|
||||
logger.warning("[init_flow] update failed: %s", exc)
|
||||
console.print(f"[red]✗[/red] Update failed: {exc}")
|
||||
cli_error(f"Update failed: {exc}")
|
||||
return 1
|
||||
|
||||
|
||||
def _handle_init_agent(args: list[str]) -> int:
|
||||
"""Handle `aipass init agent <name>` — create a new agent via spawn."""
|
||||
if not args:
|
||||
console.print("[red]✗[/red] Usage: aipass init agent <name>")
|
||||
cli_error("Usage: aipass init agent <name>")
|
||||
return 1
|
||||
agent_name = args[0]
|
||||
import subprocess as _sp
|
||||
@@ -1075,7 +1091,7 @@ def handle_command(command: str, args: list[str]) -> bool:
|
||||
# Positional args = target path and/or project name for scaffold
|
||||
err = _preflight_check()
|
||||
if err:
|
||||
console.print(f"[red]✗[/red] {err}")
|
||||
cli_error(str(err))
|
||||
sys.exit(1)
|
||||
sys.exit(_handle_init_scaffold(args))
|
||||
return True
|
||||
|
||||
@@ -43,9 +43,10 @@ import sys
|
||||
from pathlib import Path
|
||||
from typing import Dict
|
||||
|
||||
from aipass.cli.apps.modules import console, warning
|
||||
from aipass.cli.apps.modules import console, success, warning
|
||||
from aipass.prax import logger
|
||||
|
||||
from aipass.aipass.apps.handlers.init.bootstrap import is_throwaway_path
|
||||
from aipass.aipass.apps.handlers.json import json_handler
|
||||
from aipass.aipass.apps.handlers.ui.progress import render_step_header
|
||||
|
||||
@@ -120,20 +121,26 @@ def _clone_repo(home: Path, dry_run: bool) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def _run_setup(home: Path, dry_run: bool) -> bool:
|
||||
def _run_setup(home: Path, dry_run: bool, no_symlink: bool = False, force_symlink: bool = False) -> bool:
|
||||
"""Run the repo's setup.sh (venv + editable install + hook wiring + binaries)."""
|
||||
setup = home / "setup.sh"
|
||||
# --no-init: install owns the init handoff (_handoff_to_init) — without it,
|
||||
# setup.sh's own init chain (DPLAN-0234) would scaffold the project twice.
|
||||
# --no-symlink / --force-symlink (#660) pass through to setup.sh's CLI-symlink guard.
|
||||
setup_args = ["bash", str(setup), "--no-init"]
|
||||
if no_symlink:
|
||||
setup_args.append("--no-symlink")
|
||||
if force_symlink:
|
||||
setup_args.append("--force-symlink")
|
||||
if dry_run:
|
||||
console.print(f"[yellow]\\[dry-run][/yellow] would run: bash {setup}")
|
||||
console.print(f"[yellow]\\[dry-run][/yellow] would run: {' '.join(setup_args)}")
|
||||
return True
|
||||
if not setup.is_file():
|
||||
warning(f"setup.sh not found at {setup} — cannot build the environment.")
|
||||
return False
|
||||
console.print("[cyan]Building environment[/cyan] [dim](venv, dependencies, hook wiring)…[/dim]")
|
||||
try:
|
||||
# --no-init: install owns the init handoff (_handoff_to_init) — without it,
|
||||
# setup.sh's own init chain (DPLAN-0234) would scaffold the project twice.
|
||||
proc = subprocess.run(["bash", str(setup), "--no-init"], cwd=str(home), timeout=_SETUP_TIMEOUT)
|
||||
proc = subprocess.run(setup_args, cwd=str(home), timeout=_SETUP_TIMEOUT)
|
||||
if proc.returncode == 0:
|
||||
return True
|
||||
logger.warning("[install] setup.sh exited %s", proc.returncode)
|
||||
@@ -162,11 +169,11 @@ def _verify_binaries(home: Path) -> Dict[str, str | None]:
|
||||
)
|
||||
aipass = _resolve_aipass_bin(home)
|
||||
if drone:
|
||||
console.print(f"[green]✓[/green] drone: {drone}")
|
||||
success(f"drone: {drone}")
|
||||
else:
|
||||
warning("drone not found after setup — check the setup output above.")
|
||||
if aipass:
|
||||
console.print(f"[green]✓[/green] aipass: {aipass}")
|
||||
success(f"aipass: {aipass}")
|
||||
else:
|
||||
warning("aipass not found after setup — check the setup output above.")
|
||||
return {"drone": drone, "aipass": aipass}
|
||||
@@ -201,7 +208,7 @@ def _handoff_to_init(
|
||||
headless, init is launched headless too so the whole chain stays non-blocking.
|
||||
"""
|
||||
console.print()
|
||||
console.print(f"[bold green]✓ AIPass is installed at {home}[/bold green]")
|
||||
success(f"AIPass is installed at {home}")
|
||||
console.print()
|
||||
console.print(" [cyan]drone systems[/cyan] [dim]# list every agent[/dim]")
|
||||
console.print(" [cyan]aipass doctor[/cyan] [dim]# check system health[/dim]")
|
||||
@@ -235,6 +242,39 @@ def _handoff_to_init(
|
||||
warning(f"Could not launch init: {exc}. Run 'aipass init run' in {project_dir} yourself.")
|
||||
|
||||
|
||||
def _check_and_fix_owner(home: Path) -> None:
|
||||
"""Run sync-registry --check; if issues found, auto-heal with --fix."""
|
||||
try:
|
||||
check_proc = subprocess.run(
|
||||
["drone", "@spawn", "sync-registry", "--check"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=30,
|
||||
cwd=str(home),
|
||||
)
|
||||
if check_proc.returncode != 0:
|
||||
warning("Owner/identity issues detected — auto-repairing…")
|
||||
fix_proc = subprocess.run(
|
||||
["drone", "@spawn", "sync-registry", "--fix"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=60,
|
||||
cwd=str(home),
|
||||
)
|
||||
if fix_proc.returncode == 0:
|
||||
success("Registry owner/identity reconciled.")
|
||||
else:
|
||||
logger.warning("[install] sync-registry --fix exit %s", fix_proc.returncode)
|
||||
else:
|
||||
success("Owner/identity OK.")
|
||||
except FileNotFoundError:
|
||||
logger.info("[install] drone not on PATH — skipping owner check")
|
||||
except subprocess.TimeoutExpired:
|
||||
logger.warning("[install] sync-registry timed out during install")
|
||||
except Exception as exc:
|
||||
logger.warning("[install] owner check skipped: %s", exc)
|
||||
|
||||
|
||||
def _resolve_project_dir(project: str | None, non_interactive: bool) -> Path | None:
|
||||
"""Resolve the first-project directory — --project / prompt / DEFAULT_PROJECT."""
|
||||
if project:
|
||||
@@ -258,6 +298,8 @@ def run_install(
|
||||
with_init: bool = False,
|
||||
no_init: bool = False,
|
||||
project: str | None = None,
|
||||
no_symlink: bool = False,
|
||||
force_symlink: bool = False,
|
||||
) -> int:
|
||||
"""Run the 4-step one-command install. Returns 0 on success, 1 on failure."""
|
||||
console.print()
|
||||
@@ -277,27 +319,41 @@ def run_install(
|
||||
return 1
|
||||
console.print(f" Home: [cyan]{home}[/cyan]")
|
||||
|
||||
if is_throwaway_path(home):
|
||||
warning(
|
||||
f"REFUSED: '{home}' is a temporary/scratchpad path. "
|
||||
"Installing here would hijack the machine-wide AIPASS_HOME. "
|
||||
"Use a permanent directory, or pass --force-global-home to override."
|
||||
)
|
||||
if "--force-global-home" not in sys.argv:
|
||||
return 1
|
||||
logger.warning("[install] --force-global-home override: proceeding with throwaway home %s", home)
|
||||
|
||||
if _looks_like_aipass_tree(home):
|
||||
console.print(f"[green]✓[/green] AIPass already present at {home} — skipping download")
|
||||
success(f"AIPass already present at {home} — skipping download")
|
||||
elif not _clone_repo(home, dry_run):
|
||||
warning("Could not fetch AIPass — aborting install.")
|
||||
return 1
|
||||
else:
|
||||
console.print(f"[green]✓[/green] AIPass downloaded to {home}")
|
||||
success(f"AIPass downloaded to {home}")
|
||||
|
||||
# Step 2 — build the environment via setup.sh
|
||||
console.print()
|
||||
console.print(render_step_header(2, TOTAL_STEPS, "Building environment"))
|
||||
if not _run_setup(home, dry_run):
|
||||
if not _run_setup(home, dry_run, no_symlink=no_symlink, force_symlink=force_symlink):
|
||||
warning("Environment build failed — aborting install.")
|
||||
return 1
|
||||
console.print("[green]✓[/green] Environment ready")
|
||||
success("Environment ready")
|
||||
|
||||
# Step 3 — verify the binaries landed
|
||||
console.print()
|
||||
console.print(render_step_header(3, TOTAL_STEPS, "Verifying install"))
|
||||
bins = _verify_binaries(home) if not dry_run else {"drone": "dry-run", "aipass": "dry-run"}
|
||||
|
||||
# Owner/identity retro-trigger — check and self-heal via spawn
|
||||
if not dry_run:
|
||||
_check_and_fix_owner(home)
|
||||
|
||||
# Step 4 — hand off into init (or print next steps)
|
||||
console.print()
|
||||
console.print(render_step_header(4, TOTAL_STEPS, "First project"))
|
||||
@@ -323,7 +379,10 @@ def print_help() -> None:
|
||||
console.print(" [green]aipass install --here[/green] [dim]# install into current dir[/dim]")
|
||||
console.print(" [green]aipass install --no-init[/green] [dim]# install only, skip init[/dim]")
|
||||
console.print(" [green]aipass install --with-init[/green] [dim]# force init even when headless[/dim]")
|
||||
console.print(" [green]aipass install --no-symlink[/green] [dim]# skip global CLI symlinks[/dim]")
|
||||
console.print(" [green]aipass install --force-symlink[/green] [dim]# repoint from another install[/dim]")
|
||||
console.print(" [green]aipass install --project DIR[/green] [dim]# where the first project scaffolds[/dim]")
|
||||
console.print(" [green]aipass install --force-global-home[/green] [dim]# allow install into /tmp (unsafe)[/dim]")
|
||||
console.print(" [green]aipass install --dry-run[/green] [dim]# walk steps, no side effects[/dim]")
|
||||
console.print()
|
||||
console.print("[yellow]STEPS:[/yellow] resolve home -> fetch -> setup.sh -> verify -> launch init")
|
||||
@@ -368,6 +427,8 @@ def handle_command(command: str, args: list[str]) -> bool:
|
||||
here = "--here" in run_args
|
||||
with_init = "--with-init" in run_args
|
||||
no_init = "--no-init" in run_args
|
||||
no_symlink = "--no-symlink" in run_args
|
||||
force_symlink = "--force-symlink" in run_args
|
||||
path = _flag_value("--path")
|
||||
project = _flag_value("--project")
|
||||
|
||||
@@ -379,6 +440,8 @@ def handle_command(command: str, args: list[str]) -> bool:
|
||||
with_init=with_init,
|
||||
no_init=no_init,
|
||||
project=project,
|
||||
no_symlink=no_symlink,
|
||||
force_symlink=force_symlink,
|
||||
)
|
||||
json_handler.log_operation(
|
||||
"install_run",
|
||||
|
||||
@@ -24,7 +24,7 @@ import os
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
from aipass.cli.apps.modules import console, error, warning
|
||||
from aipass.cli.apps.modules import console, error, success, warning
|
||||
from aipass.prax import logger
|
||||
|
||||
from aipass.aipass.apps.handlers.json import json_handler
|
||||
@@ -151,13 +151,13 @@ def handle_command(command: str, args: list[str]) -> bool:
|
||||
return True
|
||||
field, value = args[1], args[2]
|
||||
if field not in USER_FIELDS:
|
||||
console.print(f"[red]Unknown field: {field}[/red]")
|
||||
error(f"Unknown field: {field}")
|
||||
console.print("[dim]Valid fields: " + ", ".join(USER_FIELDS) + "[/dim]")
|
||||
return True
|
||||
profile = get_user_profile()
|
||||
profile[field] = value
|
||||
save_profile(profile)
|
||||
console.print(f"[green]✓[/green] {field} = {value}")
|
||||
success(f"{field} = {value}")
|
||||
return True
|
||||
|
||||
if args[0] == "clear":
|
||||
@@ -166,7 +166,7 @@ def handle_command(command: str, args: list[str]) -> bool:
|
||||
skip_confirm = any(a in ("--yes", "-y") for a in args[1:])
|
||||
if skip_confirm:
|
||||
save_profile({f: None for f in USER_FIELDS})
|
||||
console.print("[green]✓[/green] Profile cleared.")
|
||||
success("Profile cleared.")
|
||||
return True
|
||||
warning("Type 'aipass' to confirm clearing your profile (ctrl-C to cancel):")
|
||||
try:
|
||||
@@ -177,7 +177,7 @@ def handle_command(command: str, args: list[str]) -> bool:
|
||||
return True
|
||||
if confirm == "aipass":
|
||||
save_profile({f: None for f in USER_FIELDS})
|
||||
console.print("[green]✓[/green] Profile cleared.")
|
||||
success("Profile cleared.")
|
||||
else:
|
||||
console.print("[yellow]Cancelled.[/yellow]")
|
||||
return True
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
# Probe Hygiene SOP
|
||||
|
||||
Standard operating procedure for throwaway test installs of AIPass.
|
||||
|
||||
## Principle
|
||||
|
||||
Temporary environments are used, then deleted, gone. Nothing permanent may ever point at a temp path.
|
||||
|
||||
## Rules
|
||||
|
||||
- Install probes and throwaway test installs live ONLY in throwaway directories (system temp dir, `/tmp`, Claude Code scratchpad dirs).
|
||||
- Used = deleted = GONE. Delete the probe directory immediately after the test completes.
|
||||
- NOTHING permanent may ever point at a temporary path: no global settings (`~/.claude/settings.json` `env.AIPASS_HOME`), no symlinks, no registry entries.
|
||||
- `aipass install` now refuses throwaway homes automatically. The `--force-global-home` flag is the explicit unsafe override, for probe use only.
|
||||
- `aipass doctor` now detects a hijacked global `AIPASS_HOME` (nonexistent or temp path) and flags it as an error with fix guidance.
|
||||
|
||||
## What the defenses do
|
||||
|
||||
1. **`is_throwaway_path()`** (bootstrap.py) — detects paths under `tempfile.gettempdir()`, `/tmp` (POSIX), or containing `scratchpad`. Shared gate used by both install and bootstrap.
|
||||
2. **`run_install()` gate** (install.py) — refuses to proceed when the resolved home is throwaway. Prints a loud `REFUSED` message with guidance. `--force-global-home` overrides.
|
||||
3. **`_claude_settings()` gate** (bootstrap.py) — refuses to write `env.AIPASS_HOME` into project settings when the detected home is throwaway. Defense-in-depth behind the install gate.
|
||||
4. **`_check_global_aipass_home()`** (doctor.py) — reads `~/.claude/settings.json` and flags `env.AIPASS_HOME` pointing at a nonexistent or throwaway path as an error.
|
||||
|
||||
## Correct probe workflow
|
||||
|
||||
```bash
|
||||
# 1. Create throwaway dir
|
||||
cd /tmp && mkdir aipass_probe && cd aipass_probe
|
||||
|
||||
# 2. Run the probe (install will refuse — this is correct)
|
||||
aipass install --here
|
||||
# → REFUSED: '/tmp/aipass_probe' is a temporary/scratchpad path.
|
||||
|
||||
# 3. If you genuinely need a temp install (testing only):
|
||||
aipass install --here --force-global-home
|
||||
|
||||
# 4. IMMEDIATELY after testing, delete the probe
|
||||
rm -rf /tmp/aipass_probe
|
||||
|
||||
# 5. Verify global settings are clean
|
||||
aipass doctor
|
||||
```
|
||||
@@ -10,6 +10,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.metadata
|
||||
import types
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
@@ -129,23 +130,15 @@ class TestRouteCommand:
|
||||
mod2.handle_command.assert_called_once_with("cmd", ["arg1"])
|
||||
mod3.handle_command.assert_not_called()
|
||||
|
||||
def test_handles_module_exception(self) -> None:
|
||||
"""Exception in a module is caught; returns False if no other handles."""
|
||||
def test_module_exception_re_raises(self) -> None:
|
||||
"""Exception in a handler is re-raised so callers see the real error."""
|
||||
mod = MagicMock()
|
||||
mod.handle_command.side_effect = RuntimeError("crash")
|
||||
mod.__name__ = "broken_mod"
|
||||
assert route_command("cmd", [], [mod]) is False
|
||||
import pytest
|
||||
|
||||
def test_exception_in_first_tries_second(self) -> None:
|
||||
"""Exception in first module does not prevent second from handling."""
|
||||
mod1 = MagicMock()
|
||||
mod1.handle_command.side_effect = RuntimeError("crash")
|
||||
mod1.__name__ = "mod1"
|
||||
mod2 = MagicMock()
|
||||
mod2.handle_command.return_value = True
|
||||
|
||||
assert route_command("cmd", [], [mod1, mod2]) is True
|
||||
mod2.handle_command.assert_called_once()
|
||||
with pytest.raises(RuntimeError, match="crash"):
|
||||
route_command("cmd", [], [mod])
|
||||
|
||||
|
||||
# =============================================================================
|
||||
@@ -157,22 +150,39 @@ class TestMain:
|
||||
"""Tests for the main() entry point."""
|
||||
|
||||
def test_version_flag(self) -> None:
|
||||
"""--version prints version and returns 0."""
|
||||
"""--version prints real package version and returns 0."""
|
||||
with patch("aipass.aipass.apps.aipass.sys.argv", ["aipass", "--version"]):
|
||||
with patch("aipass.aipass.apps.aipass.discover_modules", return_value=[]):
|
||||
with patch("builtins.print") as mock_print:
|
||||
result = main()
|
||||
assert result == 0
|
||||
mock_print.assert_called_once_with("aipass 0.1.0")
|
||||
printed = mock_print.call_args[0][0]
|
||||
assert printed.startswith("aipass ")
|
||||
assert printed != "aipass 0.1.0"
|
||||
|
||||
def test_version_flag_short(self) -> None:
|
||||
"""-V prints version and returns 0."""
|
||||
"""-V prints real package version and returns 0."""
|
||||
with patch("aipass.aipass.apps.aipass.sys.argv", ["aipass", "-V"]):
|
||||
with patch("aipass.aipass.apps.aipass.discover_modules", return_value=[]):
|
||||
with patch("builtins.print") as mock_print:
|
||||
result = main()
|
||||
assert result == 0
|
||||
mock_print.assert_called_once_with("aipass 0.1.0")
|
||||
printed = mock_print.call_args[0][0]
|
||||
assert printed.startswith("aipass ")
|
||||
|
||||
def test_version_flag_fallback(self) -> None:
|
||||
"""--version prints 'unknown' when package metadata unavailable."""
|
||||
_not_found = importlib.metadata.PackageNotFoundError
|
||||
with patch("aipass.aipass.apps.aipass.sys.argv", ["aipass", "--version"]):
|
||||
with patch("aipass.aipass.apps.aipass.discover_modules", return_value=[]):
|
||||
with patch(
|
||||
"aipass.aipass.apps.aipass.importlib.metadata.version",
|
||||
side_effect=_not_found,
|
||||
):
|
||||
with patch("builtins.print") as mock_print:
|
||||
result = main()
|
||||
assert result == 0
|
||||
mock_print.assert_called_once_with("aipass unknown")
|
||||
|
||||
def test_help_flag_shows_help(self) -> None:
|
||||
"""--help shows module list and returns 0."""
|
||||
@@ -252,6 +262,38 @@ class TestMain:
|
||||
assert result == 0
|
||||
mod.handle_command.assert_called_once_with("doctor", [])
|
||||
|
||||
def test_at_prefix_shows_drone_guidance(self) -> None:
|
||||
"""@drone prints guidance pointing to drone, not 'Unknown command'."""
|
||||
with patch("aipass.aipass.apps.aipass.sys.argv", ["aipass", "@drone"]):
|
||||
with patch("aipass.aipass.apps.aipass.discover_modules", return_value=[]):
|
||||
with patch("builtins.print") as mock_print:
|
||||
result = main()
|
||||
assert result == 1
|
||||
printed = " ".join(str(a) for call in mock_print.call_args_list for a in call[0])
|
||||
assert "@drone" in printed
|
||||
assert "drone routing target" in printed
|
||||
assert "Unknown command" not in printed
|
||||
|
||||
def test_at_prefix_uses_actual_name(self) -> None:
|
||||
"""@memory prints guidance with the actual @name the user typed."""
|
||||
with patch("aipass.aipass.apps.aipass.sys.argv", ["aipass", "@memory"]):
|
||||
with patch("aipass.aipass.apps.aipass.discover_modules", return_value=[]):
|
||||
with patch("builtins.print") as mock_print:
|
||||
result = main()
|
||||
assert result == 1
|
||||
printed = " ".join(str(a) for call in mock_print.call_args_list for a in call[0])
|
||||
assert "@memory" in printed
|
||||
assert "drone @memory" in printed
|
||||
|
||||
def test_plain_bad_command_still_unknown(self) -> None:
|
||||
"""Non-@ bad command still prints 'Unknown command', not drone guidance."""
|
||||
with patch("aipass.aipass.apps.aipass.sys.argv", ["aipass", "frobnicate"]):
|
||||
with patch("aipass.aipass.apps.aipass.discover_modules", return_value=[]):
|
||||
with patch("builtins.print") as mock_print:
|
||||
result = main()
|
||||
assert result == 1
|
||||
mock_print.assert_called_with("Unknown command: frobnicate")
|
||||
|
||||
def test_command_with_remaining_args(self) -> None:
|
||||
"""Remaining args are passed to route_command."""
|
||||
mod = MagicMock()
|
||||
@@ -262,3 +304,72 @@ class TestMain:
|
||||
with patch("aipass.aipass.apps.aipass.discover_modules", return_value=[mod]):
|
||||
main()
|
||||
mod.handle_command.assert_called_once_with("doctor", ["--verbose", "--fix"])
|
||||
|
||||
def test_help_shows_command_constant(self) -> None:
|
||||
"""Help listing uses module COMMAND constant, not file stem."""
|
||||
mod = types.ModuleType("aipass.aipass.apps.modules.help_chat")
|
||||
mod.__doc__ = "Help chatbot"
|
||||
mod.COMMAND = "help" # type: ignore[attr-defined]
|
||||
mod.handle_command = lambda c, a: True # type: ignore[attr-defined]
|
||||
with patch("aipass.aipass.apps.aipass.sys.argv", ["aipass"]):
|
||||
with patch(
|
||||
"aipass.aipass.apps.aipass.discover_modules",
|
||||
return_value=[mod],
|
||||
):
|
||||
with patch("builtins.print") as mock_print:
|
||||
main()
|
||||
printed = " ".join(str(a) for call in mock_print.call_args_list for a in call[0])
|
||||
assert "help" in printed
|
||||
assert "help_chat" not in printed
|
||||
|
||||
def test_help_falls_back_to_stem(self) -> None:
|
||||
"""Without COMMAND constant, help listing uses file stem."""
|
||||
mod = types.ModuleType("aipass.aipass.apps.modules.doctor")
|
||||
mod.__doc__ = "Doctor module"
|
||||
mod.handle_command = lambda c, a: True # type: ignore[attr-defined]
|
||||
with patch("aipass.aipass.apps.aipass.sys.argv", ["aipass"]):
|
||||
with patch(
|
||||
"aipass.aipass.apps.aipass.discover_modules",
|
||||
return_value=[mod],
|
||||
):
|
||||
with patch("builtins.print") as mock_print:
|
||||
main()
|
||||
printed = " ".join(str(a) for call in mock_print.call_args_list for a in call[0])
|
||||
assert "doctor" in printed
|
||||
|
||||
def test_handler_crash_surfaces_error(self) -> None:
|
||||
"""Handler crash prints real error, not 'Unknown command'."""
|
||||
mod = MagicMock()
|
||||
mod.handle_command.side_effect = RuntimeError("db connection failed")
|
||||
mod.__name__ = "aipass.aipass.apps.modules.doctor"
|
||||
with patch("aipass.aipass.apps.aipass.sys.argv", ["aipass", "doctor"]):
|
||||
with patch(
|
||||
"aipass.aipass.apps.aipass.discover_modules",
|
||||
return_value=[mod],
|
||||
):
|
||||
with patch("builtins.print") as mock_print:
|
||||
result = main()
|
||||
assert result == 1
|
||||
printed = " ".join(str(a) for call in mock_print.call_args_list for a in call[0])
|
||||
assert "db connection failed" in printed
|
||||
assert "Unknown command" not in printed
|
||||
|
||||
def test_import_failure_surfaces_on_command(self) -> None:
|
||||
"""Failed module import surfaces when user types that command."""
|
||||
import aipass.aipass.apps.aipass as aipass_mod
|
||||
|
||||
with patch("aipass.aipass.apps.aipass.sys.argv", ["aipass", "broken"]):
|
||||
with patch(
|
||||
"aipass.aipass.apps.aipass.discover_modules",
|
||||
return_value=[],
|
||||
):
|
||||
aipass_mod._import_failures.clear()
|
||||
aipass_mod._import_failures["broken"] = ImportError("no module")
|
||||
with patch("builtins.print") as mock_print:
|
||||
result = main()
|
||||
assert result == 1
|
||||
printed = " ".join(str(a) for call in mock_print.call_args_list for a in call[0])
|
||||
assert "failed to load" in printed
|
||||
assert "no module" in printed
|
||||
assert "Unknown command" not in printed
|
||||
aipass_mod._import_failures.clear()
|
||||
|
||||
@@ -24,6 +24,7 @@ from aipass.aipass.apps.handlers.init import scaffold_content as sc
|
||||
from aipass.aipass.apps.handlers.init.bootstrap import (
|
||||
_merge_hooks_json,
|
||||
_sanitize_name,
|
||||
is_throwaway_path,
|
||||
init_project,
|
||||
update_project,
|
||||
)
|
||||
@@ -277,8 +278,12 @@ def test_init_project_claude_settings_content(tmp_path):
|
||||
assert "deny" in data["permissions"]
|
||||
|
||||
|
||||
def test_init_project_settings_no_hooks(tmp_path):
|
||||
def test_init_project_settings_no_hooks(tmp_path, monkeypatch):
|
||||
""".claude/settings.json has no hooks — all hooks fire from provider level."""
|
||||
monkeypatch.setattr(
|
||||
"aipass.aipass.apps.handlers.init.bootstrap.is_throwaway_path",
|
||||
lambda _: False,
|
||||
)
|
||||
target = tmp_path / "proj"
|
||||
target.mkdir()
|
||||
|
||||
@@ -440,6 +445,7 @@ def test_update_project_return_dict_structure(tmp_path):
|
||||
"updated_files",
|
||||
"already_current",
|
||||
"skipped_files",
|
||||
"removed_files",
|
||||
"aipass_home",
|
||||
}
|
||||
assert result["project_name"] == "UPD"
|
||||
@@ -449,8 +455,12 @@ def test_update_project_return_dict_structure(tmp_path):
|
||||
assert isinstance(result["skipped_files"], list)
|
||||
|
||||
|
||||
def test_update_project_already_current_after_init(tmp_path):
|
||||
def test_update_project_already_current_after_init(tmp_path, monkeypatch):
|
||||
"""Running update immediately after init reports all managed files as already current."""
|
||||
monkeypatch.setattr(
|
||||
"aipass.aipass.apps.handlers.init.bootstrap.is_throwaway_path",
|
||||
lambda _: False,
|
||||
)
|
||||
target = tmp_path / "proj"
|
||||
target.mkdir()
|
||||
init_project(target, project_name="fresh")
|
||||
@@ -461,8 +471,12 @@ def test_update_project_already_current_after_init(tmp_path):
|
||||
assert len(result["already_current"]) >= 5
|
||||
|
||||
|
||||
def test_update_project_idempotent(tmp_path):
|
||||
def test_update_project_idempotent(tmp_path, monkeypatch):
|
||||
"""Running update twice in a row produces no changes on second run."""
|
||||
monkeypatch.setattr(
|
||||
"aipass.aipass.apps.handlers.init.bootstrap.is_throwaway_path",
|
||||
lambda _: False,
|
||||
)
|
||||
target = tmp_path / "proj"
|
||||
target.mkdir()
|
||||
init_project(target, project_name="idem")
|
||||
@@ -569,8 +583,12 @@ def test_init_project_returns_aipass_home(tmp_path):
|
||||
assert result["aipass_home"] is None or isinstance(result["aipass_home"], str)
|
||||
|
||||
|
||||
def test_init_project_settings_has_aipass_home_when_detected(tmp_path):
|
||||
def test_init_project_settings_has_aipass_home_when_detected(tmp_path, monkeypatch):
|
||||
"""When AIPASS_HOME is detected, settings.json includes env.AIPASS_HOME."""
|
||||
monkeypatch.setattr(
|
||||
"aipass.aipass.apps.handlers.init.bootstrap.is_throwaway_path",
|
||||
lambda _: False,
|
||||
)
|
||||
target = tmp_path / "proj"
|
||||
target.mkdir()
|
||||
|
||||
@@ -596,8 +614,12 @@ def test_update_project_returns_aipass_home(tmp_path):
|
||||
assert result["aipass_home"] is None or isinstance(result["aipass_home"], str)
|
||||
|
||||
|
||||
def test_update_project_adds_aipass_home_if_missing(tmp_path):
|
||||
def test_update_project_adds_aipass_home_if_missing(tmp_path, monkeypatch):
|
||||
"""update_project injects AIPASS_HOME into settings.json if env section is absent."""
|
||||
monkeypatch.setattr(
|
||||
"aipass.aipass.apps.handlers.init.bootstrap.is_throwaway_path",
|
||||
lambda _: False,
|
||||
)
|
||||
target = tmp_path / "proj"
|
||||
target.mkdir()
|
||||
init_project(target, project_name="addenv")
|
||||
@@ -1097,3 +1119,147 @@ def test_with_source_header_is_first_line():
|
||||
lines = result.split("\n")
|
||||
assert lines[0] == "<!-- Source: /test.md -->"
|
||||
assert lines[1] == "content"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GAP 1: AGENTS.md sync on update (#676)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_update_project_syncs_agents_md(tmp_path):
|
||||
"""update restores AGENTS.md when its content has been altered."""
|
||||
target = tmp_path / "proj"
|
||||
target.mkdir()
|
||||
init_project(target, project_name="sync")
|
||||
|
||||
agents_md = target / "AGENTS.md"
|
||||
agents_md.write_text("# Corrupted\n", encoding="utf-8")
|
||||
|
||||
result = update_project(target)
|
||||
|
||||
assert str(agents_md.resolve()) in result["updated_files"]
|
||||
restored = agents_md.read_text(encoding="utf-8")
|
||||
assert "# SYNC" in restored
|
||||
assert "Startup protocol" in restored
|
||||
|
||||
|
||||
def test_update_project_creates_missing_agents_md(tmp_path):
|
||||
"""update creates AGENTS.md if it was deleted from the project."""
|
||||
target = tmp_path / "proj"
|
||||
target.mkdir()
|
||||
init_project(target, project_name="miss")
|
||||
|
||||
agents_md = target / "AGENTS.md"
|
||||
agents_md.unlink()
|
||||
|
||||
result = update_project(target)
|
||||
|
||||
assert agents_md.exists()
|
||||
assert str(agents_md.resolve()) in result["updated_files"]
|
||||
content = agents_md.read_text(encoding="utf-8")
|
||||
assert "# MISS" in content
|
||||
|
||||
|
||||
def test_update_project_agents_md_already_current(tmp_path):
|
||||
"""update reports AGENTS.md as already_current when unchanged."""
|
||||
target = tmp_path / "proj"
|
||||
target.mkdir()
|
||||
init_project(target, project_name="cur")
|
||||
|
||||
result = update_project(target)
|
||||
|
||||
assert any("AGENTS.md" in f for f in result["already_current"])
|
||||
assert not any("AGENTS.md" in f for f in result["updated_files"])
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GAP 2: Cruft cleanup on update (#676)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_update_project_removes_stale_global_prompt(tmp_path):
|
||||
"""update removes retired .aipass/aipass_global_prompt.md."""
|
||||
target = tmp_path / "proj"
|
||||
target.mkdir()
|
||||
init_project(target, project_name="cruft")
|
||||
|
||||
stale = target / ".aipass" / "aipass_global_prompt.md"
|
||||
stale.write_text("# old\n", encoding="utf-8")
|
||||
|
||||
result = update_project(target)
|
||||
|
||||
assert not stale.exists()
|
||||
assert str(stale) in result["removed_files"]
|
||||
|
||||
|
||||
def test_update_project_cleanup_does_not_touch_user_files(tmp_path):
|
||||
"""Cruft cleanup never removes user-owned files."""
|
||||
target = tmp_path / "proj"
|
||||
target.mkdir()
|
||||
init_project(target, project_name="safe")
|
||||
|
||||
readme = target / "README.md"
|
||||
registry = target / "SAFE_REGISTRY.json"
|
||||
|
||||
result = update_project(target)
|
||||
|
||||
assert readme.exists()
|
||||
assert registry.exists()
|
||||
assert len(result["removed_files"]) == 0
|
||||
|
||||
|
||||
def test_update_project_removed_files_in_result(tmp_path):
|
||||
"""Return dict always contains the removed_files key."""
|
||||
target = tmp_path / "proj"
|
||||
target.mkdir()
|
||||
init_project(target, project_name="rkey")
|
||||
|
||||
result = update_project(target)
|
||||
|
||||
assert "removed_files" in result
|
||||
assert isinstance(result["removed_files"], list)
|
||||
|
||||
|
||||
def test_update_project_cleanup_no_stale_is_noop(tmp_path):
|
||||
"""When no stale files exist, removed_files is empty."""
|
||||
target = tmp_path / "proj"
|
||||
target.mkdir()
|
||||
init_project(target, project_name="clean")
|
||||
|
||||
result = update_project(target)
|
||||
|
||||
assert result["removed_files"] == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# is_throwaway_path tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_throwaway_path_detects_tmp(tmp_path):
|
||||
"""Paths under the system temp dir are throwaway."""
|
||||
assert is_throwaway_path(str(tmp_path))
|
||||
|
||||
|
||||
def test_throwaway_path_detects_scratchpad():
|
||||
"""Paths containing 'scratchpad' are throwaway."""
|
||||
assert is_throwaway_path(str(Path.home() / ".claude" / "scratchpad" / "probe_1"))
|
||||
|
||||
|
||||
def test_throwaway_path_allows_normal():
|
||||
"""Normal home-directory paths are not throwaway."""
|
||||
assert not is_throwaway_path(str(Path.home() / "AIPass"))
|
||||
|
||||
|
||||
def test_throwaway_path_allows_project():
|
||||
"""A typical project path is not throwaway."""
|
||||
assert not is_throwaway_path(str(Path.home() / "Projects" / "myapp"))
|
||||
|
||||
|
||||
def test_settings_omits_throwaway_aipass_home(tmp_path):
|
||||
"""_claude_settings refuses to write AIPASS_HOME when it's a throwaway path."""
|
||||
from aipass.aipass.apps.handlers.init.bootstrap import _claude_settings
|
||||
|
||||
content = _claude_settings(str(tmp_path))
|
||||
data = json.loads(content)
|
||||
assert "AIPASS_HOME" not in data.get("env", {})
|
||||
|
||||
@@ -658,7 +658,7 @@ class TestReconcileStaleDeny:
|
||||
"""Missing settings.json returns no results."""
|
||||
from aipass.aipass.apps.modules.doctor_wire import reconcile_stale_deny
|
||||
|
||||
with patch("aipass.aipass.apps.modules.doctor_wire.Path.home", return_value=tmp_path):
|
||||
with patch("aipass.aipass.apps.handlers.provider_reconcile.Path.home", return_value=tmp_path):
|
||||
results = reconcile_stale_deny(fix=False)
|
||||
assert results == []
|
||||
|
||||
@@ -672,7 +672,7 @@ class TestReconcileStaleDeny:
|
||||
json.dumps({"permissions": {"deny": ["Bash(git push --force*)", "Bash(git reset --hard*)"]}}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
with patch("aipass.aipass.apps.modules.doctor_wire.Path.home", return_value=tmp_path):
|
||||
with patch("aipass.aipass.apps.handlers.provider_reconcile.Path.home", return_value=tmp_path):
|
||||
results = reconcile_stale_deny(fix=False)
|
||||
assert len(results) == 1
|
||||
assert results[0][1] == GLYPH_PASS
|
||||
@@ -688,7 +688,7 @@ class TestReconcileStaleDeny:
|
||||
json.dumps({"permissions": {"deny": ["Bash(rm -rf*)", "Bash(git push --force*)", "Bash(rm -r *)"]}}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
with patch("aipass.aipass.apps.modules.doctor_wire.Path.home", return_value=tmp_path):
|
||||
with patch("aipass.aipass.apps.handlers.provider_reconcile.Path.home", return_value=tmp_path):
|
||||
results = reconcile_stale_deny(fix=False)
|
||||
assert len(results) == 1
|
||||
assert results[0][1] == GLYPH_WARN
|
||||
@@ -706,7 +706,7 @@ class TestReconcileStaleDeny:
|
||||
"env": {"AIPASS_HOME": "/test"},
|
||||
}
|
||||
settings.write_text(json.dumps(original), encoding="utf-8")
|
||||
with patch("aipass.aipass.apps.modules.doctor_wire.Path.home", return_value=tmp_path):
|
||||
with patch("aipass.aipass.apps.handlers.provider_reconcile.Path.home", return_value=tmp_path):
|
||||
results = reconcile_stale_deny(fix=True)
|
||||
assert len(results) == 1
|
||||
assert results[0][1] == GLYPH_PASS
|
||||
@@ -727,7 +727,7 @@ class TestReconcileStaleDeny:
|
||||
json.dumps({"permissions": {"deny": ["Bash(rm -rf*)", "Bash(git reset --hard*)"]}}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
with patch("aipass.aipass.apps.modules.doctor_wire.Path.home", return_value=tmp_path):
|
||||
with patch("aipass.aipass.apps.handlers.provider_reconcile.Path.home", return_value=tmp_path):
|
||||
results = reconcile_stale_deny(fix=True)
|
||||
assert len(results) == 1
|
||||
assert results[0][1] == GLYPH_PASS
|
||||
@@ -744,7 +744,7 @@ class TestReconcileStaleDeny:
|
||||
json.dumps({"permissions": {"deny": ["Bash(rm -rf*)", "Bash(rm -r *)"]}}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
with patch("aipass.aipass.apps.modules.doctor_wire.Path.home", return_value=tmp_path):
|
||||
with patch("aipass.aipass.apps.handlers.provider_reconcile.Path.home", return_value=tmp_path):
|
||||
reconcile_stale_deny(fix=True)
|
||||
results = reconcile_stale_deny(fix=True)
|
||||
assert len(results) == 1
|
||||
@@ -758,7 +758,7 @@ class TestReconcileStaleDeny:
|
||||
settings = tmp_path / ".claude" / "settings.json"
|
||||
settings.parent.mkdir(parents=True)
|
||||
settings.write_text(json.dumps({"permissions": {"deny": []}}), encoding="utf-8")
|
||||
with patch("aipass.aipass.apps.modules.doctor_wire.Path.home", return_value=tmp_path):
|
||||
with patch("aipass.aipass.apps.handlers.provider_reconcile.Path.home", return_value=tmp_path):
|
||||
results = reconcile_stale_deny(fix=False)
|
||||
assert len(results) == 1
|
||||
assert results[0][1] == GLYPH_PASS
|
||||
@@ -770,7 +770,346 @@ class TestReconcileStaleDeny:
|
||||
settings = tmp_path / ".claude" / "settings.json"
|
||||
settings.parent.mkdir(parents=True)
|
||||
settings.write_text(json.dumps({"env": {"FOO": "bar"}}), encoding="utf-8")
|
||||
with patch("aipass.aipass.apps.modules.doctor_wire.Path.home", return_value=tmp_path):
|
||||
with patch("aipass.aipass.apps.handlers.provider_reconcile.Path.home", return_value=tmp_path):
|
||||
results = reconcile_stale_deny(fix=False)
|
||||
assert len(results) == 1
|
||||
assert results[0][1] == GLYPH_PASS
|
||||
|
||||
|
||||
class TestCheckWireVerify:
|
||||
"""Tests for check_wire_verify() — hooks wire_verify guard."""
|
||||
|
||||
def test_pass_on_zero_exit(self) -> None:
|
||||
"""Exit 0 from drone @hooks verify produces a PASS row."""
|
||||
from aipass.aipass.apps.modules.doctor_wire import check_wire_verify
|
||||
|
||||
fake = MagicMock(returncode=0, stdout="✓ Wire check passed\n\n0 errors, 0 warnings\n")
|
||||
with patch("aipass.aipass.apps.modules.doctor_wire.subprocess.run", return_value=fake):
|
||||
results = check_wire_verify()
|
||||
assert len(results) == 1
|
||||
assert results[0].label == "wire verify"
|
||||
assert results[0].glyph == "[green]✓[/green]"
|
||||
|
||||
def test_fail_on_nonzero_exit(self) -> None:
|
||||
"""Non-zero exit from drone @hooks verify produces a FAIL row."""
|
||||
from aipass.aipass.apps.modules.doctor_wire import check_wire_verify
|
||||
|
||||
fake = MagicMock(returncode=1, stdout="ERROR empty array\n2 errors, 0 warnings\n")
|
||||
with patch("aipass.aipass.apps.modules.doctor_wire.subprocess.run", return_value=fake):
|
||||
results = check_wire_verify()
|
||||
assert len(results) == 1
|
||||
assert results[0].glyph == "[red]✗[/red]"
|
||||
assert "errors" in results[0].detail
|
||||
|
||||
def test_warn_on_drone_not_found(self) -> None:
|
||||
"""FileNotFoundError (drone missing) produces a WARN row."""
|
||||
from aipass.aipass.apps.modules.doctor_wire import check_wire_verify
|
||||
|
||||
with patch(
|
||||
"aipass.aipass.apps.modules.doctor_wire.subprocess.run",
|
||||
side_effect=FileNotFoundError("drone"),
|
||||
):
|
||||
results = check_wire_verify()
|
||||
assert len(results) == 1
|
||||
assert results[0].glyph == "[yellow]![/yellow]"
|
||||
|
||||
def test_warn_on_timeout(self) -> None:
|
||||
"""TimeoutExpired produces a WARN row."""
|
||||
import subprocess as sp
|
||||
|
||||
from aipass.aipass.apps.modules.doctor_wire import check_wire_verify
|
||||
|
||||
with patch(
|
||||
"aipass.aipass.apps.modules.doctor_wire.subprocess.run",
|
||||
side_effect=sp.TimeoutExpired(cmd="drone", timeout=10),
|
||||
):
|
||||
results = check_wire_verify()
|
||||
assert len(results) == 1
|
||||
assert results[0].glyph == "[yellow]![/yellow]"
|
||||
assert "timed out" in results[0].detail
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# prompt_auto_wire — non-interactive stdin guard (issue #663)
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class TestPromptAutoWireIsatty:
|
||||
"""Guard: non-tty stdin must not block on input() (#663)."""
|
||||
|
||||
@staticmethod
|
||||
def _args() -> dict:
|
||||
return {
|
||||
"manifest_path": MagicMock(),
|
||||
"missing_hooks": ["some_hook"],
|
||||
"missing_env": [],
|
||||
"missing_deny": [],
|
||||
"missing_ask": [],
|
||||
}
|
||||
|
||||
def test_non_tty_stdin_skips_prompt_and_declines(self) -> None:
|
||||
"""Non-tty stdin must NOT call input() — it declines and warns instead."""
|
||||
from aipass.aipass.apps.modules import doctor_wire
|
||||
|
||||
with (
|
||||
patch.object(doctor_wire.sys, "stdin") as mock_stdin,
|
||||
patch("builtins.input") as mock_input,
|
||||
patch.object(doctor_wire, "_print_manual_wire_warning") as mock_warn,
|
||||
):
|
||||
mock_stdin.isatty.return_value = False
|
||||
result = doctor_wire._prompt_auto_wire(**self._args())
|
||||
|
||||
assert result is False
|
||||
mock_input.assert_not_called()
|
||||
mock_warn.assert_called_once()
|
||||
|
||||
def test_tty_stdin_prompts_and_respects_decline(self) -> None:
|
||||
"""Tty stdin still prompts; a 'n' answer declines."""
|
||||
from aipass.aipass.apps.modules import doctor_wire
|
||||
|
||||
with (
|
||||
patch.object(doctor_wire.sys, "stdin") as mock_stdin,
|
||||
patch("builtins.input", return_value="n") as mock_input,
|
||||
patch.object(doctor_wire, "_print_manual_wire_warning"),
|
||||
):
|
||||
mock_stdin.isatty.return_value = True
|
||||
result = doctor_wire._prompt_auto_wire(**self._args())
|
||||
|
||||
assert result is False
|
||||
mock_input.assert_called_once()
|
||||
|
||||
def test_tty_stdin_accepts_and_wires(self) -> None:
|
||||
"""Tty stdin with a 'y' answer runs the wire and returns True."""
|
||||
from aipass.aipass.apps.modules import doctor_wire
|
||||
|
||||
with (
|
||||
patch.object(doctor_wire.sys, "stdin") as mock_stdin,
|
||||
patch("builtins.input", return_value="y"),
|
||||
patch.object(doctor_wire, "_auto_wire_provider", return_value=["wired hook"]) as mock_wire,
|
||||
):
|
||||
mock_stdin.isatty.return_value = True
|
||||
result = doctor_wire._prompt_auto_wire(**self._args())
|
||||
|
||||
assert result is True
|
||||
mock_wire.assert_called_once()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _check_global_aipass_home tests (#688)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestCheckGlobalAipassHome:
|
||||
"""Tests for _check_global_aipass_home doctor check."""
|
||||
|
||||
def test_nonexistent_path_is_error(self, tmp_path):
|
||||
"""AIPASS_HOME pointing to a nonexistent path is flagged as error."""
|
||||
from aipass.aipass.apps.modules.doctor import _check_global_aipass_home
|
||||
|
||||
settings = tmp_path / ".claude" / "settings.json"
|
||||
settings.parent.mkdir(parents=True)
|
||||
settings.write_text(
|
||||
json.dumps({"env": {"AIPASS_HOME": str(tmp_path / "gone")}}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
with patch("aipass.aipass.apps.modules.doctor.Path.home", return_value=tmp_path):
|
||||
results = _check_global_aipass_home()
|
||||
fails = [r for r in results if "does not exist" in r.detail]
|
||||
assert len(fails) == 1
|
||||
|
||||
def test_throwaway_path_is_error(self, tmp_path):
|
||||
"""AIPASS_HOME pointing to a temp path is flagged as error."""
|
||||
from aipass.aipass.apps.modules.doctor import _check_global_aipass_home
|
||||
|
||||
settings = tmp_path / ".claude" / "settings.json"
|
||||
settings.parent.mkdir(parents=True)
|
||||
settings.write_text(
|
||||
json.dumps({"env": {"AIPASS_HOME": str(tmp_path)}}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
with patch("aipass.aipass.apps.modules.doctor.Path.home", return_value=tmp_path):
|
||||
results = _check_global_aipass_home()
|
||||
fails = [r for r in results if "throwaway" in r.detail]
|
||||
assert len(fails) == 1
|
||||
|
||||
def test_valid_path_passes(self, tmp_path):
|
||||
"""AIPASS_HOME pointing to a real, non-temp path passes."""
|
||||
from aipass.aipass.apps.modules.doctor import _check_global_aipass_home, GLYPH_PASS
|
||||
|
||||
real_home = tmp_path / "AIPass"
|
||||
real_home.mkdir()
|
||||
settings = tmp_path / ".claude" / "settings.json"
|
||||
settings.parent.mkdir(parents=True)
|
||||
settings.write_text(
|
||||
json.dumps({"env": {"AIPASS_HOME": str(real_home)}}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
with (
|
||||
patch("aipass.aipass.apps.modules.doctor.Path.home", return_value=tmp_path),
|
||||
patch(
|
||||
"aipass.aipass.apps.handlers.init.bootstrap.is_throwaway_path",
|
||||
return_value=False,
|
||||
),
|
||||
):
|
||||
results = _check_global_aipass_home()
|
||||
assert any(r.glyph == GLYPH_PASS for r in results)
|
||||
|
||||
def test_no_settings_file_is_noop(self, tmp_path):
|
||||
"""Missing ~/.claude/settings.json produces no results."""
|
||||
from aipass.aipass.apps.modules.doctor import _check_global_aipass_home
|
||||
|
||||
with patch("aipass.aipass.apps.modules.doctor.Path.home", return_value=tmp_path):
|
||||
results = _check_global_aipass_home()
|
||||
assert results == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _check_owner_seating / _fix_owner_seating tests (DPLAN-0239 P3+P5)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestCheckOwnerSeating:
|
||||
"""Tests for owner/identity detection via sync-registry --check."""
|
||||
|
||||
def test_clean_owner_returns_pass(self):
|
||||
from aipass.aipass.apps.modules.doctor import _check_owner_seating
|
||||
|
||||
check_json = json.dumps({"clean": True, "owner": "vera", "owner_uid": "8fb38c96-abcd", "issues": []})
|
||||
mock_proc = MagicMock(returncode=0, stdout=check_json, stderr="")
|
||||
with patch("aipass.aipass.apps.modules.doctor.subprocess.run", return_value=mock_proc):
|
||||
results = _check_owner_seating()
|
||||
assert len(results) == 1
|
||||
assert results[0].glyph == GLYPH_PASS
|
||||
assert "@vera" in results[0].detail
|
||||
assert "8fb38c96" in results[0].detail
|
||||
|
||||
def test_unseated_owner_returns_errors(self):
|
||||
from aipass.aipass.apps.modules.doctor import _check_owner_seating
|
||||
|
||||
check_json = json.dumps(
|
||||
{
|
||||
"clean": False,
|
||||
"owner": None,
|
||||
"owner_uid": "",
|
||||
"issues": [
|
||||
{"flag": "no_owner", "detail": "No owner:true in registry"},
|
||||
{"flag": "metadata_id_missing", "detail": "metadata.id absent"},
|
||||
],
|
||||
}
|
||||
)
|
||||
mock_proc = MagicMock(returncode=1, stdout=check_json, stderr="")
|
||||
with patch("aipass.aipass.apps.modules.doctor.subprocess.run", return_value=mock_proc):
|
||||
results = _check_owner_seating()
|
||||
assert len(results) == 2
|
||||
assert all(r.glyph == GLYPH_FAIL for r in results)
|
||||
assert results[0].label == "owner/no_owner"
|
||||
|
||||
def test_issue_with_branch_field(self):
|
||||
from aipass.aipass.apps.modules.doctor import _check_owner_seating
|
||||
|
||||
check_json = json.dumps(
|
||||
{
|
||||
"clean": False,
|
||||
"owner": "vera",
|
||||
"owner_uid": "8fb38c96",
|
||||
"issues": [
|
||||
{"flag": "entry_rid_stale", "detail": "stale rid", "branch": "vera"},
|
||||
],
|
||||
}
|
||||
)
|
||||
mock_proc = MagicMock(returncode=1, stdout=check_json, stderr="")
|
||||
with patch("aipass.aipass.apps.modules.doctor.subprocess.run", return_value=mock_proc):
|
||||
results = _check_owner_seating()
|
||||
assert len(results) == 1
|
||||
assert results[0].glyph == GLYPH_FAIL
|
||||
assert results[0].label == "owner/entry_rid_stale"
|
||||
|
||||
def test_drone_not_found_returns_warn(self):
|
||||
from aipass.aipass.apps.modules.doctor import _check_owner_seating
|
||||
|
||||
with patch(
|
||||
"aipass.aipass.apps.modules.doctor.subprocess.run",
|
||||
side_effect=FileNotFoundError("drone"),
|
||||
):
|
||||
results = _check_owner_seating()
|
||||
assert len(results) == 1
|
||||
assert results[0].glyph == GLYPH_WARN
|
||||
assert "drone" in results[0].detail
|
||||
|
||||
def test_timeout_returns_warn(self):
|
||||
import subprocess as _sp
|
||||
|
||||
from aipass.aipass.apps.modules.doctor import _check_owner_seating
|
||||
|
||||
with patch(
|
||||
"aipass.aipass.apps.modules.doctor.subprocess.run",
|
||||
side_effect=_sp.TimeoutExpired("drone", 30),
|
||||
):
|
||||
results = _check_owner_seating()
|
||||
assert len(results) == 1
|
||||
assert results[0].glyph == GLYPH_WARN
|
||||
|
||||
def test_non_json_output_returns_warn(self):
|
||||
from aipass.aipass.apps.modules.doctor import _check_owner_seating
|
||||
|
||||
mock_proc = MagicMock(returncode=1, stdout="not json at all", stderr="")
|
||||
with patch("aipass.aipass.apps.modules.doctor.subprocess.run", return_value=mock_proc):
|
||||
results = _check_owner_seating()
|
||||
assert len(results) == 1
|
||||
assert results[0].glyph == GLYPH_WARN
|
||||
|
||||
def test_empty_stdout_exit_zero(self):
|
||||
from aipass.aipass.apps.modules.doctor import _check_owner_seating
|
||||
|
||||
mock_proc = MagicMock(returncode=0, stdout="", stderr="")
|
||||
with patch("aipass.aipass.apps.modules.doctor.subprocess.run", return_value=mock_proc):
|
||||
results = _check_owner_seating()
|
||||
assert len(results) == 1
|
||||
assert results[0].glyph == GLYPH_PASS
|
||||
|
||||
|
||||
class TestFixOwnerSeating:
|
||||
"""Tests for owner/identity repair via sync-registry --fix."""
|
||||
|
||||
def test_fix_success_returns_pass(self):
|
||||
from aipass.aipass.apps.modules.doctor import _fix_owner_seating
|
||||
|
||||
mock_proc = MagicMock(returncode=0, stdout="", stderr="")
|
||||
with patch("aipass.aipass.apps.modules.doctor.subprocess.run", return_value=mock_proc):
|
||||
results = _fix_owner_seating()
|
||||
assert len(results) == 1
|
||||
assert results[0].glyph == GLYPH_PASS
|
||||
assert "reconciled" in results[0].detail
|
||||
|
||||
def test_fix_failure_returns_fail(self):
|
||||
from aipass.aipass.apps.modules.doctor import _fix_owner_seating
|
||||
|
||||
mock_proc = MagicMock(returncode=1, stdout="", stderr="owner conflict")
|
||||
with patch("aipass.aipass.apps.modules.doctor.subprocess.run", return_value=mock_proc):
|
||||
results = _fix_owner_seating()
|
||||
assert len(results) == 1
|
||||
assert results[0].glyph == GLYPH_FAIL
|
||||
|
||||
def test_fix_drone_not_found(self):
|
||||
from aipass.aipass.apps.modules.doctor import _fix_owner_seating
|
||||
|
||||
with patch(
|
||||
"aipass.aipass.apps.modules.doctor.subprocess.run",
|
||||
side_effect=FileNotFoundError("drone"),
|
||||
):
|
||||
results = _fix_owner_seating()
|
||||
assert len(results) == 1
|
||||
assert results[0].glyph == GLYPH_WARN
|
||||
|
||||
def test_fix_timeout(self):
|
||||
import subprocess as _sp
|
||||
|
||||
from aipass.aipass.apps.modules.doctor import _fix_owner_seating
|
||||
|
||||
with patch(
|
||||
"aipass.aipass.apps.modules.doctor.subprocess.run",
|
||||
side_effect=_sp.TimeoutExpired("drone", 60),
|
||||
):
|
||||
results = _fix_owner_seating()
|
||||
assert len(results) == 1
|
||||
assert results[0].glyph == GLYPH_WARN
|
||||
|
||||
@@ -261,6 +261,11 @@ def _bypass_preflight():
|
||||
|
||||
|
||||
class TestRunInit:
|
||||
@pytest.fixture(autouse=True)
|
||||
def _isolate_cwd(self, tmp_path, monkeypatch):
|
||||
"""Avoid _guard_init rejecting the real cwd when it has .trinity/."""
|
||||
monkeypatch.chdir(tmp_path)
|
||||
|
||||
def _patch_all_stages(self):
|
||||
"""Context manager that patches all 10 stage functions to no-ops."""
|
||||
stage_names = [
|
||||
@@ -655,47 +660,74 @@ _MOD_UPDATE = "aipass.aipass.apps.modules.init_flow"
|
||||
|
||||
|
||||
class TestInitUpdateRegistrySync:
|
||||
"""Tests for registry sync subprocess call in _handle_init_update."""
|
||||
"""Tests for owner/identity check+fix in _handle_init_update (DPLAN-0239 P5)."""
|
||||
|
||||
def test_sync_success_prints_message(self, tmp_path: Path) -> None:
|
||||
"""Successful drone sync-registry prints 'Registry synced.'"""
|
||||
mock_result = MagicMock(returncode=0)
|
||||
def test_clean_check_prints_ok(self, tmp_path: Path) -> None:
|
||||
"""Clean --check (exit 0) prints 'Owner/identity OK.' and skips --fix."""
|
||||
check_proc = MagicMock(returncode=0, stdout="", stderr="")
|
||||
with (
|
||||
patch(
|
||||
"aipass.aipass.apps.handlers.init.bootstrap.update_project",
|
||||
return_value={"updated_files": [], "already_current": []},
|
||||
),
|
||||
patch(f"{_MOD_UPDATE}.subprocess.run", return_value=mock_result) as mock_run,
|
||||
patch(f"{_MOD_UPDATE}.console") as mock_console,
|
||||
patch(f"{_MOD_UPDATE}.subprocess.run", return_value=check_proc) as mock_run,
|
||||
patch(f"{_MOD_UPDATE}.console"),
|
||||
patch(f"{_MOD_UPDATE}.success") as mock_success,
|
||||
patch(f"{_MOD_UPDATE}.json_handler"),
|
||||
):
|
||||
rc = _handle_init_update([str(tmp_path)])
|
||||
assert rc == 0
|
||||
mock_run.assert_called_once_with(
|
||||
["drone", "@spawn", "sync-registry", "--fix"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=30,
|
||||
)
|
||||
sync_calls = [c for c in mock_console.print.call_args_list if "Registry synced" in str(c)]
|
||||
assert len(sync_calls) == 1
|
||||
mock_run.assert_called_once()
|
||||
args = mock_run.call_args[0][0]
|
||||
assert "--check" in args
|
||||
ok_calls = [c for c in mock_success.call_args_list if "Owner/identity OK" in str(c)]
|
||||
assert len(ok_calls) == 1
|
||||
|
||||
def test_sync_failure_degrades_silently(self, tmp_path: Path) -> None:
|
||||
"""Non-zero exit from drone sync-registry is silently skipped."""
|
||||
mock_result = MagicMock(returncode=1)
|
||||
def test_issues_trigger_fix(self, tmp_path: Path) -> None:
|
||||
"""Non-zero --check triggers --fix; success prints reconciled."""
|
||||
check_proc = MagicMock(returncode=1, stdout="", stderr="")
|
||||
fix_proc = MagicMock(returncode=0, stdout="", stderr="")
|
||||
with (
|
||||
patch(
|
||||
"aipass.aipass.apps.handlers.init.bootstrap.update_project",
|
||||
return_value={"updated_files": [], "already_current": []},
|
||||
),
|
||||
patch(f"{_MOD_UPDATE}.subprocess.run", return_value=mock_result),
|
||||
patch(f"{_MOD_UPDATE}.console") as mock_console,
|
||||
patch(
|
||||
f"{_MOD_UPDATE}.subprocess.run",
|
||||
side_effect=[check_proc, fix_proc],
|
||||
) as mock_run,
|
||||
patch(f"{_MOD_UPDATE}.console"),
|
||||
patch(f"{_MOD_UPDATE}.success") as mock_success,
|
||||
patch(f"{_MOD_UPDATE}.warning"),
|
||||
patch(f"{_MOD_UPDATE}.json_handler"),
|
||||
):
|
||||
rc = _handle_init_update([str(tmp_path)])
|
||||
assert rc == 0
|
||||
assert mock_run.call_count == 2
|
||||
fix_args = mock_run.call_args_list[1][0][0]
|
||||
assert "--fix" in fix_args
|
||||
reconciled = [c for c in mock_success.call_args_list if "reconciled" in str(c)]
|
||||
assert len(reconciled) == 1
|
||||
|
||||
def test_fix_failure_degrades_silently(self, tmp_path: Path) -> None:
|
||||
"""Non-zero --fix exit degrades gracefully (no crash)."""
|
||||
check_proc = MagicMock(returncode=1, stdout="", stderr="")
|
||||
fix_proc = MagicMock(returncode=1, stdout="", stderr="")
|
||||
with (
|
||||
patch(
|
||||
"aipass.aipass.apps.handlers.init.bootstrap.update_project",
|
||||
return_value={"updated_files": [], "already_current": []},
|
||||
),
|
||||
patch(
|
||||
f"{_MOD_UPDATE}.subprocess.run",
|
||||
side_effect=[check_proc, fix_proc],
|
||||
),
|
||||
patch(f"{_MOD_UPDATE}.console"),
|
||||
patch(f"{_MOD_UPDATE}.warning"),
|
||||
patch(f"{_MOD_UPDATE}.json_handler"),
|
||||
):
|
||||
rc = _handle_init_update([str(tmp_path)])
|
||||
assert rc == 0
|
||||
sync_calls = [c for c in mock_console.print.call_args_list if "Registry synced" in str(c)]
|
||||
assert len(sync_calls) == 0
|
||||
|
||||
def test_sync_missing_drone_degrades_silently(self, tmp_path: Path) -> None:
|
||||
"""FileNotFoundError (no drone binary) degrades gracefully."""
|
||||
@@ -705,13 +737,11 @@ class TestInitUpdateRegistrySync:
|
||||
return_value={"updated_files": [], "already_current": []},
|
||||
),
|
||||
patch(f"{_MOD_UPDATE}.subprocess.run", side_effect=FileNotFoundError("drone not found")),
|
||||
patch(f"{_MOD_UPDATE}.console") as mock_console,
|
||||
patch(f"{_MOD_UPDATE}.console"),
|
||||
patch(f"{_MOD_UPDATE}.json_handler"),
|
||||
):
|
||||
rc = _handle_init_update([str(tmp_path)])
|
||||
assert rc == 0
|
||||
sync_calls = [c for c in mock_console.print.call_args_list if "Registry synced" in str(c)]
|
||||
assert len(sync_calls) == 0
|
||||
|
||||
def test_sync_timeout_degrades_silently(self, tmp_path: Path) -> None:
|
||||
"""subprocess.TimeoutExpired degrades gracefully."""
|
||||
|
||||
@@ -146,6 +146,32 @@ class TestRunSetup:
|
||||
assert _run_setup(tmp_path, dry_run=False) is True
|
||||
run.assert_called_once()
|
||||
|
||||
def test_no_symlink_flag_forwarded(self, tmp_path: Path) -> None:
|
||||
"""--no-symlink passes through to setup.sh (#660)."""
|
||||
(tmp_path / "setup.sh").write_text("#!/usr/bin/env bash\n", encoding="utf-8")
|
||||
with patch(f"{_MOD}.subprocess.run", return_value=MagicMock(returncode=0)) as run:
|
||||
assert _run_setup(tmp_path, dry_run=False, no_symlink=True) is True
|
||||
argv = run.call_args[0][0]
|
||||
assert "--no-symlink" in argv
|
||||
assert "--force-symlink" not in argv
|
||||
|
||||
def test_force_symlink_flag_forwarded(self, tmp_path: Path) -> None:
|
||||
"""--force-symlink passes through to setup.sh (#660)."""
|
||||
(tmp_path / "setup.sh").write_text("#!/usr/bin/env bash\n", encoding="utf-8")
|
||||
with patch(f"{_MOD}.subprocess.run", return_value=MagicMock(returncode=0)) as run:
|
||||
assert _run_setup(tmp_path, dry_run=False, force_symlink=True) is True
|
||||
argv = run.call_args[0][0]
|
||||
assert "--force-symlink" in argv
|
||||
|
||||
def test_symlink_flags_absent_by_default(self, tmp_path: Path) -> None:
|
||||
"""No symlink flags forwarded unless requested (#660)."""
|
||||
(tmp_path / "setup.sh").write_text("#!/usr/bin/env bash\n", encoding="utf-8")
|
||||
with patch(f"{_MOD}.subprocess.run", return_value=MagicMock(returncode=0)) as run:
|
||||
assert _run_setup(tmp_path, dry_run=False) is True
|
||||
argv = run.call_args[0][0]
|
||||
assert "--no-symlink" not in argv
|
||||
assert "--force-symlink" not in argv
|
||||
|
||||
|
||||
class TestRunInstall:
|
||||
"""The four-step orchestrator."""
|
||||
@@ -170,13 +196,15 @@ class TestRunInstall:
|
||||
setup.assert_not_called()
|
||||
|
||||
def test_full_happy_path(self, tmp_path: Path) -> None:
|
||||
"""Clone + setup + verify + next-steps returns success."""
|
||||
"""Clone + setup + verify + owner check + next-steps returns success."""
|
||||
home = tmp_path / "AIPass"
|
||||
with (
|
||||
patch(f"{_MOD}._resolve_home", return_value=home),
|
||||
patch(f"{_MOD}.is_throwaway_path", return_value=False),
|
||||
patch(f"{_MOD}._clone_repo", return_value=True),
|
||||
patch(f"{_MOD}._run_setup", return_value=True),
|
||||
patch(f"{_MOD}._verify_binaries", return_value={"drone": "/x/drone", "aipass": "/x/aipass"}),
|
||||
patch(f"{_MOD}._check_and_fix_owner"),
|
||||
patch(f"{_MOD}._handoff_to_init") as nxt,
|
||||
):
|
||||
rc = run_install(non_interactive=True, dry_run=False)
|
||||
@@ -313,3 +341,109 @@ class TestSmoke:
|
||||
def test_total_steps_constant(self) -> None:
|
||||
"""The install flow advertises four steps."""
|
||||
assert TOTAL_STEPS == 4
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Throwaway-path gate (#688)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestThrowawayGate:
|
||||
"""Install refuses throwaway homes unless --force-global-home."""
|
||||
|
||||
def test_refuses_tmp_home(self, tmp_path) -> None:
|
||||
"""run_install returns 1 when home resolves to a temp path."""
|
||||
with (
|
||||
patch(
|
||||
"aipass.aipass.apps.modules.install._resolve_home",
|
||||
return_value=tmp_path,
|
||||
),
|
||||
patch("aipass.aipass.apps.modules.install.sys.argv", ["aipass", "install"]),
|
||||
):
|
||||
result = run_install(non_interactive=True, no_init=True)
|
||||
assert result == 1
|
||||
|
||||
def test_force_flag_overrides(self, tmp_path) -> None:
|
||||
"""--force-global-home lets a temp home proceed past the gate."""
|
||||
with (
|
||||
patch(
|
||||
"aipass.aipass.apps.modules.install._resolve_home",
|
||||
return_value=tmp_path,
|
||||
),
|
||||
patch(
|
||||
"aipass.aipass.apps.modules.install.sys.argv",
|
||||
["aipass", "install", "--force-global-home"],
|
||||
),
|
||||
patch(
|
||||
"aipass.aipass.apps.modules.install._looks_like_aipass_tree",
|
||||
return_value=True,
|
||||
),
|
||||
patch(
|
||||
"aipass.aipass.apps.modules.install._run_setup",
|
||||
return_value=True,
|
||||
),
|
||||
patch(
|
||||
"aipass.aipass.apps.modules.install._verify_binaries",
|
||||
return_value={"drone": "x", "aipass": "x"},
|
||||
),
|
||||
patch("aipass.aipass.apps.modules.install._handoff_to_init"),
|
||||
patch("aipass.aipass.apps.modules.install._check_and_fix_owner"),
|
||||
):
|
||||
result = run_install(non_interactive=True, no_init=True)
|
||||
assert result == 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _check_and_fix_owner tests (DPLAN-0239 P5)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestCheckAndFixOwner:
|
||||
"""Tests for install-time owner/identity check+fix retro-trigger."""
|
||||
|
||||
def test_clean_check_skips_fix(self, tmp_path) -> None:
|
||||
from aipass.aipass.apps.modules.install import _check_and_fix_owner
|
||||
|
||||
mock_proc = MagicMock(returncode=0, stdout="", stderr="")
|
||||
with patch(
|
||||
"aipass.aipass.apps.modules.install.subprocess.run",
|
||||
return_value=mock_proc,
|
||||
) as mock_run:
|
||||
_check_and_fix_owner(tmp_path)
|
||||
mock_run.assert_called_once()
|
||||
args = mock_run.call_args[0][0]
|
||||
assert "--check" in args
|
||||
|
||||
def test_issues_trigger_fix(self, tmp_path) -> None:
|
||||
from aipass.aipass.apps.modules.install import _check_and_fix_owner
|
||||
|
||||
check_proc = MagicMock(returncode=1, stdout="", stderr="")
|
||||
fix_proc = MagicMock(returncode=0, stdout="", stderr="")
|
||||
with patch(
|
||||
"aipass.aipass.apps.modules.install.subprocess.run",
|
||||
side_effect=[check_proc, fix_proc],
|
||||
) as mock_run:
|
||||
_check_and_fix_owner(tmp_path)
|
||||
assert mock_run.call_count == 2
|
||||
fix_args = mock_run.call_args_list[1][0][0]
|
||||
assert "--fix" in fix_args
|
||||
|
||||
def test_drone_not_found_is_silent(self, tmp_path) -> None:
|
||||
from aipass.aipass.apps.modules.install import _check_and_fix_owner
|
||||
|
||||
with patch(
|
||||
"aipass.aipass.apps.modules.install.subprocess.run",
|
||||
side_effect=FileNotFoundError("drone"),
|
||||
):
|
||||
_check_and_fix_owner(tmp_path)
|
||||
|
||||
def test_timeout_is_silent(self, tmp_path) -> None:
|
||||
import subprocess as _sp
|
||||
|
||||
from aipass.aipass.apps.modules.install import _check_and_fix_owner
|
||||
|
||||
with patch(
|
||||
"aipass.aipass.apps.modules.install.subprocess.run",
|
||||
side_effect=_sp.TimeoutExpired("drone", 30),
|
||||
):
|
||||
_check_and_fix_owner(tmp_path)
|
||||
|
||||
@@ -149,11 +149,11 @@ def print_help():
|
||||
console.print("[bold cyan]WHAT IS API?[/bold cyan]")
|
||||
console.print()
|
||||
console.print("API Branch provides:")
|
||||
console.print(" [green]✓[/green] OpenRouter API client integration")
|
||||
console.print(" [green]✓[/green] API key management and validation")
|
||||
console.print(" [green]✓[/green] Model discovery and availability")
|
||||
console.print(" [green]✓[/green] Usage tracking and statistics")
|
||||
console.print(" [green]✓[/green] Connection testing and diagnostics")
|
||||
console.print(" [cyan]•[/cyan] OpenRouter API client integration")
|
||||
console.print(" [cyan]•[/cyan] API key management and validation")
|
||||
console.print(" [cyan]•[/cyan] Model discovery and availability")
|
||||
console.print(" [cyan]•[/cyan] Usage tracking and statistics")
|
||||
console.print(" [cyan]•[/cyan] Connection testing and diagnostics")
|
||||
console.print()
|
||||
|
||||
console.print("[bold cyan]AVAILABLE COMMANDS:[/bold cyan]")
|
||||
@@ -287,6 +287,14 @@ def main():
|
||||
command = args[0]
|
||||
remaining_args = args[1:] if len(args) > 1 else []
|
||||
|
||||
# Subcommand --help guard
|
||||
if remaining_args and remaining_args[0] in ["--help", "-h"]:
|
||||
for module in modules:
|
||||
if module.handle_command(command, ["--help"]):
|
||||
return 0
|
||||
print_help()
|
||||
return 0
|
||||
|
||||
# Log api command attempt
|
||||
json_handler.log_operation("api_command_attempted", {"command": command, "modules_discovered": len(modules)})
|
||||
|
||||
|
||||
@@ -323,9 +323,5 @@ if __name__ == "__main__":
|
||||
if handle_command(command, remaining_args):
|
||||
sys.exit(0)
|
||||
else:
|
||||
console.print()
|
||||
console.print(f"[red]Unknown command: {command}[/red]")
|
||||
console.print()
|
||||
console.print("Run [dim]drone @api --help[/dim] for available commands")
|
||||
console.print()
|
||||
error(f"Unknown command: {command}", suggestion="Run 'drone @api --help' for available commands")
|
||||
sys.exit(1)
|
||||
|
||||
@@ -357,9 +357,5 @@ if __name__ == "__main__":
|
||||
if handle_command(command, remaining_args):
|
||||
sys.exit(0)
|
||||
else:
|
||||
console.print()
|
||||
console.print(f"[red]Unknown command: {command}[/red]")
|
||||
console.print()
|
||||
console.print("Run [dim]drone @api --help[/dim] for available commands")
|
||||
console.print()
|
||||
error(f"Unknown command: {command}", suggestion="Run 'drone @api --help' for available commands")
|
||||
sys.exit(1)
|
||||
|
||||
@@ -29,7 +29,7 @@ if sys.platform == "win32":
|
||||
|
||||
from typing import List
|
||||
from aipass.prax.apps.modules.logger import system_logger as logger
|
||||
from aipass.cli.apps.modules import console, header, success, error
|
||||
from aipass.cli.apps.modules import console, header, success, error, warning
|
||||
from aipass.api.apps.handlers.json import json_handler
|
||||
from aipass.api.apps.handlers.auth import keys
|
||||
from aipass.api.apps.handlers.openrouter import client, models
|
||||
@@ -286,7 +286,7 @@ def check_status():
|
||||
console.print(" [cyan]Key configured:[/cyan] [green]yes[/green]")
|
||||
console.print(f" [cyan]Key:[/cyan] {masked}")
|
||||
else:
|
||||
console.print(" [cyan]Key configured:[/cyan] [red]no[/red]")
|
||||
warning("API key not configured")
|
||||
diagnosis = keys.diagnose_key("openrouter")
|
||||
console.print(f" [cyan]Reason:[/cyan] {diagnosis}")
|
||||
|
||||
@@ -300,7 +300,7 @@ def check_status():
|
||||
console.print(" [cyan]OpenAI SDK:[/cyan] [green]available[/green]")
|
||||
except ImportError:
|
||||
logger.warning("OpenAI SDK not installed")
|
||||
console.print(" [cyan]OpenAI SDK:[/cyan] [red]missing[/red]")
|
||||
warning("OpenAI SDK not installed")
|
||||
|
||||
# Client cache stats
|
||||
cache_stats = client.get_cache_stats()
|
||||
@@ -365,9 +365,5 @@ if __name__ == "__main__":
|
||||
if handle_command(command, remaining_args):
|
||||
sys.exit(0)
|
||||
else:
|
||||
console.print()
|
||||
console.print(f"[red]Unknown command: {command}[/red]")
|
||||
console.print()
|
||||
console.print("Run [dim]drone @api --help[/dim] for available commands")
|
||||
console.print()
|
||||
error(f"Unknown command: {command}", suggestion="Run 'drone @api --help' for available commands")
|
||||
sys.exit(1)
|
||||
|
||||
@@ -32,7 +32,7 @@ if sys.platform == "win32":
|
||||
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
|
||||
from aipass.cli.apps.modules import console, header, error
|
||||
from aipass.api.apps.handlers.json import json_handler
|
||||
from aipass.api.apps.handlers.auth import secrets as _handler
|
||||
|
||||
@@ -156,7 +156,5 @@ if __name__ == "__main__":
|
||||
print_help()
|
||||
sys.exit(0)
|
||||
|
||||
console.print()
|
||||
console.print(f"[red]Unknown command: {args[0]}[/red]")
|
||||
console.print()
|
||||
error(f"Unknown command: {args[0]}")
|
||||
sys.exit(1)
|
||||
|
||||
@@ -288,9 +288,5 @@ if __name__ == "__main__":
|
||||
if handle_command(command, remaining_args):
|
||||
sys.exit(0)
|
||||
else:
|
||||
console.print()
|
||||
console.print(f"[red]Unknown command: {command}[/red]")
|
||||
console.print()
|
||||
console.print("Run [dim]drone @api --help[/dim] for available commands")
|
||||
console.print()
|
||||
error(f"Unknown command: {command}", suggestion="Run 'drone @api --help' for available commands")
|
||||
sys.exit(1)
|
||||
|
||||
@@ -30,7 +30,7 @@ if sys.platform == "win32":
|
||||
os.environ.setdefault("AIPASS_BRANCH_NAME", "backup")
|
||||
|
||||
from aipass.prax import logger
|
||||
from aipass.cli.apps.modules import console, header
|
||||
from aipass.cli.apps.modules import console, error, header
|
||||
|
||||
VERSION = "1.0.0"
|
||||
MODULE_NAME = "backup"
|
||||
@@ -138,6 +138,14 @@ def main():
|
||||
return 0
|
||||
|
||||
command = args[0]
|
||||
remaining = args[1:]
|
||||
|
||||
if remaining and remaining[0] in ["--help", "-h"]:
|
||||
for module in modules:
|
||||
if module.handle_command(command, ["--help"]):
|
||||
return 0
|
||||
print_help()
|
||||
return 0
|
||||
|
||||
if command == "backup" and len(args) > 1:
|
||||
from aipass.backup.apps.modules.register import resolve_project
|
||||
@@ -145,7 +153,7 @@ def main():
|
||||
target = args[1]
|
||||
project_root = resolve_project(target)
|
||||
if project_root is None:
|
||||
console.print(f"[red]Error:[/red] Cannot resolve project: {target}")
|
||||
error(f"Cannot resolve project: {target}")
|
||||
return 1
|
||||
remaining = [project_root] + args[2:]
|
||||
mode = "snapshot"
|
||||
@@ -158,7 +166,7 @@ def main():
|
||||
|
||||
if route_command(mode, remaining, modules):
|
||||
return 0
|
||||
console.print(f"[red]Error:[/red] Unknown mode: {mode}")
|
||||
error(f"Unknown mode: {mode}")
|
||||
return 1
|
||||
|
||||
remaining = args[1:] if len(args) > 1 else []
|
||||
@@ -168,14 +176,14 @@ def main():
|
||||
|
||||
resolved = resolve_project(remaining[0])
|
||||
if resolved is None:
|
||||
console.print(f"[red]Error:[/red] Cannot resolve project: {remaining[0]}")
|
||||
error(f"Cannot resolve project: {remaining[0]}")
|
||||
return 1
|
||||
remaining = [resolved] + remaining[1:]
|
||||
|
||||
if route_command(command, remaining, modules):
|
||||
return 0
|
||||
|
||||
console.print(f"[red]Unknown command:[/red] {command}")
|
||||
error(f"Unknown command: {command}")
|
||||
return 1
|
||||
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ import tempfile
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
from aipass.prax import logger
|
||||
from aipass.prax import append_jsonl, logger
|
||||
|
||||
|
||||
def log_operation(operation: str, data: dict) -> None:
|
||||
@@ -24,12 +24,9 @@ def log_operation(operation: str, data: dict) -> None:
|
||||
"operation": operation,
|
||||
**data,
|
||||
}
|
||||
log_dir = Path(__file__).resolve().parents[3] / "logs"
|
||||
log_dir.mkdir(exist_ok=True)
|
||||
log_file = log_dir / "operations.jsonl"
|
||||
log_file = Path(__file__).resolve().parents[3] / "logs" / "operations.jsonl"
|
||||
try:
|
||||
with open(log_file, "a", encoding="utf-8") as f:
|
||||
f.write(json.dumps(entry) + "\n")
|
||||
append_jsonl(log_file, entry)
|
||||
except OSError as e:
|
||||
logger.warning(f"Failed to write operation log: {e}")
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ if sys.platform == "win32":
|
||||
_reconfigure(encoding="utf-8", errors="replace")
|
||||
|
||||
from aipass.prax import logger
|
||||
from aipass.cli.apps.modules import console
|
||||
from aipass.cli.apps.modules import console, error as cli_error
|
||||
|
||||
from aipass.backup.apps.handlers.json import json_handler
|
||||
|
||||
@@ -61,7 +61,7 @@ def run_drive_check() -> bool:
|
||||
console.print(f" Backup folder ID: {result['folder_id']}")
|
||||
logger.info("[backup] Drive test passed")
|
||||
else:
|
||||
console.print(f"[red]Drive connectivity test FAILED: {result['error']}[/red]")
|
||||
cli_error(f"Drive connectivity test FAILED: {result['error']}")
|
||||
logger.warning(f"[backup] Drive test failed: {result['error']}")
|
||||
|
||||
json_handler.log_operation(
|
||||
|
||||
@@ -19,7 +19,7 @@ if sys.platform == "win32":
|
||||
_reconfigure(encoding="utf-8", errors="replace")
|
||||
|
||||
from aipass.prax import logger
|
||||
from aipass.cli.apps.modules import console
|
||||
from aipass.cli.apps.modules import console, error as cli_error
|
||||
|
||||
from aipass.backup.apps.handlers.json import json_handler
|
||||
|
||||
@@ -65,7 +65,7 @@ def run_drive_clear(project_root: str, force: bool = False) -> bool:
|
||||
console.print("[green]Drive tracker cleared.[/green]")
|
||||
logger.info(f"[backup] Drive tracker cleared for {project_root}")
|
||||
else:
|
||||
console.print("[red]Failed to clear Drive tracker.[/red]")
|
||||
cli_error("Failed to clear Drive tracker.")
|
||||
|
||||
json_handler.log_operation(
|
||||
"drive_clear_complete",
|
||||
|
||||
@@ -19,7 +19,7 @@ if sys.platform == "win32":
|
||||
_reconfigure(encoding="utf-8", errors="replace")
|
||||
|
||||
from aipass.prax import logger
|
||||
from aipass.cli.apps.modules import console
|
||||
from aipass.cli.apps.modules import console, error as cli_error
|
||||
|
||||
from aipass.backup.apps.handlers.json import json_handler
|
||||
|
||||
@@ -78,7 +78,7 @@ def run_drive_stats(project_root: str) -> bool:
|
||||
return True
|
||||
except Exception as exc:
|
||||
logger.warning(f"Failed to load tracker for {project_root}: {exc}")
|
||||
console.print(f"[red]Error loading tracker: {exc}[/red]")
|
||||
cli_error(f"Error loading tracker: {exc}")
|
||||
return False
|
||||
|
||||
|
||||
|
||||
@@ -30,6 +30,7 @@ if sys.platform == "win32":
|
||||
from aipass.prax import logger
|
||||
from aipass.cli.apps.modules import console
|
||||
|
||||
from aipass.backup.apps.handlers.ignore.patterns import is_ignored, load_spec
|
||||
from aipass.backup.apps.handlers.json import json_handler
|
||||
from aipass.backup.apps.handlers.path.builder import build_versioned_store
|
||||
from aipass.backup.apps.modules.display import show_drive_result
|
||||
@@ -116,8 +117,14 @@ def run_drive_sync(
|
||||
logger.warning(f"[backup] {result['error']}")
|
||||
return result
|
||||
|
||||
# 3. Scan for ALL files (no dotfile filter — the store is already filtered by .backupignore)
|
||||
all_files = [f for f in store_path.rglob("*") if f.is_file()]
|
||||
# 3. Scan store and re-filter through .backupignore (legacy stores may
|
||||
# contain files swept in before an ignore rule was added).
|
||||
spec = load_spec(str(project_root))
|
||||
raw_files = [f for f in store_path.rglob("*") if f.is_file()]
|
||||
all_files = [f for f in raw_files if not is_ignored(str(f.relative_to(store_path)), spec)]
|
||||
ignored_count = len(raw_files) - len(all_files)
|
||||
if ignored_count:
|
||||
logger.info(f"[backup] Drive sync: filtered {ignored_count} ignored files from store")
|
||||
|
||||
result["total"] = len(all_files)
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ if sys.platform == "win32":
|
||||
_reconfigure(encoding="utf-8", errors="replace")
|
||||
|
||||
from aipass.prax import logger
|
||||
from aipass.cli.apps.modules import console
|
||||
from aipass.cli.apps.modules import console, error
|
||||
|
||||
from aipass.backup.apps.handlers.json import json_handler
|
||||
from aipass.backup.apps.handlers.project.registry import lookup_project as _lookup_project
|
||||
@@ -91,12 +91,12 @@ def handle_command(command: str, args: list) -> bool:
|
||||
name = args[idx + 1]
|
||||
|
||||
if not Path(project_path).is_dir():
|
||||
console.print(f"[red]Error:[/red] {project_path} is not a directory")
|
||||
error(f"{project_path} is not a directory")
|
||||
return True
|
||||
|
||||
backup_dir = create_backup_dir(project_path)
|
||||
if backup_dir is None:
|
||||
console.print(f"[red]Error:[/red] Failed to create .backup/ in {project_path}")
|
||||
error(f"Failed to create .backup/ in {project_path}")
|
||||
return True
|
||||
|
||||
register_project(name, project_path)
|
||||
|
||||
@@ -19,7 +19,7 @@ if sys.platform == "win32":
|
||||
_reconfigure(encoding="utf-8", errors="replace")
|
||||
|
||||
from aipass.prax import logger
|
||||
from aipass.cli.apps.modules import console
|
||||
from aipass.cli.apps.modules import console, error as cli_error
|
||||
|
||||
from aipass.backup.apps.handlers.json import json_handler
|
||||
|
||||
@@ -71,10 +71,10 @@ def run_share(file_path: str, *, public: bool = False) -> dict:
|
||||
|
||||
client = DriveClient()
|
||||
if not client.authenticate():
|
||||
error = f"Drive authentication failed: {client.last_error}"
|
||||
console.print(f"[red]{error}[/red]")
|
||||
logger.warning(f"[backup] {error}")
|
||||
return {"success": False, "link": None, "file_id": None, "error": error}
|
||||
err_msg = f"Drive authentication failed: {client.last_error}"
|
||||
cli_error(err_msg)
|
||||
logger.warning(f"[backup] {err_msg}")
|
||||
return {"success": False, "link": None, "file_id": None, "error": err_msg}
|
||||
|
||||
console.print(f"[dim]Uploading {file_path}...[/dim]")
|
||||
result = share_file(client, file_path, public=public)
|
||||
@@ -84,7 +84,7 @@ def run_share(file_path: str, *, public: bool = False) -> dict:
|
||||
console.print(f"[green]Shared ({mode}):[/green] {result['link']}")
|
||||
logger.info(f"[backup] Shared {file_path} ({mode})")
|
||||
else:
|
||||
console.print(f"[red]Share failed:[/red] {result['error']}")
|
||||
cli_error(f"Share failed: {result['error']}")
|
||||
logger.warning(f"[backup] Share failed: {result['error']}")
|
||||
|
||||
if result.get("link"):
|
||||
|
||||
@@ -549,13 +549,13 @@ class TestDriveUpload:
|
||||
result = mod.upload_single_file(client, missing, "testproj", tmp_path)
|
||||
assert result is False
|
||||
|
||||
def test_upload_batch_empty(self) -> None:
|
||||
def test_upload_batch_empty(self, tmp_path: Path) -> None:
|
||||
"""Empty file list returns success immediately."""
|
||||
mod = _fresh_import("aipass.backup.apps.handlers.drive.upload")
|
||||
client_mod = _fresh_import("aipass.backup.apps.handlers.drive.client")
|
||||
|
||||
client = client_mod.DriveClient()
|
||||
result = mod.upload_batch(client, [], "proj", Path("/tmp"), {})
|
||||
result = mod.upload_batch(client, [], "proj", tmp_path, {})
|
||||
assert result["success"] is True
|
||||
assert result["uploaded"] == 0
|
||||
assert result["failed"] == 0
|
||||
@@ -603,7 +603,7 @@ class TestDriveUpload:
|
||||
mod = _fresh_import("aipass.backup.apps.handlers.drive.upload")
|
||||
client_mod = _fresh_import("aipass.backup.apps.handlers.drive.client")
|
||||
|
||||
mod.MEDIA_UPLOAD_AVAILABLE = False
|
||||
mod.MEDIA_UPLOAD_AVAILABLE = False # type: ignore[attr-defined]
|
||||
|
||||
client = client_mod.DriveClient()
|
||||
client._drive_service = MagicMock()
|
||||
@@ -813,6 +813,59 @@ class TestDriveSync:
|
||||
assert result["uploaded"] == 3
|
||||
mock_upload_mod.upload_batch.assert_called_once()
|
||||
|
||||
def test_run_drive_sync_filters_ignored_files(self, tmp_path: Path) -> None:
|
||||
"""Files matching .backupignore are excluded from upload list."""
|
||||
project = tmp_path / "project"
|
||||
project.mkdir()
|
||||
bs = project / ".backup" / "versioned"
|
||||
|
||||
(bs / "src" / "app.py" / "app.py").parent.mkdir(parents=True)
|
||||
(bs / "src" / "app.py" / "app.py").write_text("code", encoding="utf-8")
|
||||
(bs / "node_modules" / "pkg" / "index.js" / "index.js").parent.mkdir(parents=True)
|
||||
(bs / "node_modules" / "pkg" / "index.js" / "index.js").write_text("junk", encoding="utf-8")
|
||||
(bs / "node_modules" / "other" / "lib.js" / "lib.js").parent.mkdir(parents=True)
|
||||
(bs / "node_modules" / "other" / "lib.js" / "lib.js").write_text("junk2", encoding="utf-8")
|
||||
|
||||
ignore_file = project / ".backupignore"
|
||||
ignore_file.write_text("node_modules/\n", encoding="utf-8")
|
||||
|
||||
mod = _fresh_import("aipass.backup.apps.modules.drive_sync")
|
||||
mock_class, mock_inst = self._make_mock_client_class(authenticate_rv=True)
|
||||
mock_client_module = MagicMock()
|
||||
mock_client_module.DriveClient = mock_class
|
||||
|
||||
mock_tracker_mod = MagicMock()
|
||||
mock_tracker_mod.load_tracker.return_value = {}
|
||||
mock_tracker_mod.check_needs_upload.return_value = True
|
||||
mock_tracker_mod.save_tracker = MagicMock()
|
||||
|
||||
mock_upload_mod = MagicMock()
|
||||
mock_upload_mod.upload_batch.return_value = {
|
||||
"success": True,
|
||||
"uploaded": 1,
|
||||
"failed": 0,
|
||||
}
|
||||
|
||||
with (
|
||||
patch.dict(
|
||||
sys.modules,
|
||||
{
|
||||
"aipass.backup.apps.handlers.drive.client": mock_client_module,
|
||||
"aipass.backup.apps.handlers.drive.tracker": mock_tracker_mod,
|
||||
"aipass.backup.apps.handlers.drive.upload": mock_upload_mod,
|
||||
},
|
||||
),
|
||||
patch.object(mod, "build_versioned_store", return_value=bs),
|
||||
):
|
||||
result = mod.run_drive_sync(str(project), show_panels=False)
|
||||
|
||||
assert result["total"] == 1
|
||||
uploaded_files = mock_upload_mod.upload_batch.call_args[0][1]
|
||||
names = [f.name for f in uploaded_files]
|
||||
assert "app.py" in names
|
||||
assert "index.js" not in names
|
||||
assert "lib.js" not in names
|
||||
|
||||
def test_handle_command_help(self) -> None:
|
||||
"""--help returns True."""
|
||||
mod = _fresh_import("aipass.backup.apps.modules.drive_sync")
|
||||
@@ -838,14 +891,17 @@ class TestDriveCheckModule:
|
||||
"""Tests for drive_check module."""
|
||||
|
||||
def test_handle_command_primary(self) -> None:
|
||||
"""Primary command returns True."""
|
||||
mod = _fresh_import("aipass.backup.apps.modules.drive_check")
|
||||
assert mod.handle_command("drive_check", []) is True
|
||||
|
||||
def test_handle_command_help(self) -> None:
|
||||
"""--help returns True."""
|
||||
mod = _fresh_import("aipass.backup.apps.modules.drive_check")
|
||||
assert mod.handle_command("drive_check", ["--help"]) is True
|
||||
|
||||
def test_handle_command_wrong(self) -> None:
|
||||
"""Wrong command returns False."""
|
||||
mod = _fresh_import("aipass.backup.apps.modules.drive_check")
|
||||
assert mod.handle_command("wrong", []) is False
|
||||
|
||||
@@ -879,14 +935,17 @@ class TestDriveStatsModule:
|
||||
"""Tests for drive_stats module."""
|
||||
|
||||
def test_handle_command_primary(self) -> None:
|
||||
"""Primary command returns True."""
|
||||
mod = _fresh_import("aipass.backup.apps.modules.drive_stats")
|
||||
assert mod.handle_command("drive_stats", []) is True
|
||||
|
||||
def test_handle_command_help(self) -> None:
|
||||
"""--help returns True."""
|
||||
mod = _fresh_import("aipass.backup.apps.modules.drive_stats")
|
||||
assert mod.handle_command("drive_stats", ["--help"]) is True
|
||||
|
||||
def test_handle_command_wrong(self) -> None:
|
||||
"""Wrong command returns False."""
|
||||
mod = _fresh_import("aipass.backup.apps.modules.drive_stats")
|
||||
assert mod.handle_command("wrong", []) is False
|
||||
|
||||
@@ -913,21 +972,24 @@ class TestDriveClearModule:
|
||||
"""Tests for drive_clear module."""
|
||||
|
||||
def test_handle_command_primary(self) -> None:
|
||||
"""Primary command returns True."""
|
||||
mod = _fresh_import("aipass.backup.apps.modules.drive_clear")
|
||||
assert mod.handle_command("drive_clear", []) is True
|
||||
|
||||
def test_handle_command_help(self) -> None:
|
||||
"""--help returns True."""
|
||||
mod = _fresh_import("aipass.backup.apps.modules.drive_clear")
|
||||
assert mod.handle_command("drive_clear", ["--help"]) is True
|
||||
|
||||
def test_handle_command_wrong(self) -> None:
|
||||
"""Wrong command returns False."""
|
||||
mod = _fresh_import("aipass.backup.apps.modules.drive_clear")
|
||||
assert mod.handle_command("wrong", []) is False
|
||||
|
||||
def test_run_drive_clear_no_force(self) -> None:
|
||||
def test_run_drive_clear_no_force(self, tmp_path: Path) -> None:
|
||||
"""Without --force, returns False."""
|
||||
mod = _fresh_import("aipass.backup.apps.modules.drive_clear")
|
||||
result = mod.run_drive_clear("/tmp/project", force=False)
|
||||
result = mod.run_drive_clear(str(tmp_path / "project"), force=False)
|
||||
assert result is False
|
||||
|
||||
def test_run_drive_clear_with_force(self, tmp_path: Path) -> None:
|
||||
@@ -1101,24 +1163,28 @@ class TestCommandRouting:
|
||||
"""Verify drive commands route by underscore names."""
|
||||
|
||||
def test_drive_sync_routes_underscore(self) -> None:
|
||||
"""drive_sync accepts underscore, rejects hyphen."""
|
||||
mod = _fresh_import("aipass.backup.apps.modules.drive_sync")
|
||||
assert mod.PRIMARY_COMMAND == "drive_sync"
|
||||
assert mod.handle_command("drive_sync", []) is True
|
||||
assert mod.handle_command("drive-sync", []) is False
|
||||
|
||||
def test_drive_check_routes_underscore(self) -> None:
|
||||
"""drive_check accepts underscore, rejects hyphen."""
|
||||
mod = _fresh_import("aipass.backup.apps.modules.drive_check")
|
||||
assert mod.PRIMARY_COMMAND == "drive_check"
|
||||
assert mod.handle_command("drive_check", []) is True
|
||||
assert mod.handle_command("drive-check", []) is False
|
||||
|
||||
def test_drive_stats_routes_underscore(self) -> None:
|
||||
"""drive_stats accepts underscore, rejects hyphen."""
|
||||
mod = _fresh_import("aipass.backup.apps.modules.drive_stats")
|
||||
assert mod.PRIMARY_COMMAND == "drive_stats"
|
||||
assert mod.handle_command("drive_stats", []) is True
|
||||
assert mod.handle_command("drive-stats", []) is False
|
||||
|
||||
def test_drive_clear_routes_underscore(self) -> None:
|
||||
"""drive_clear accepts underscore, rejects hyphen."""
|
||||
mod = _fresh_import("aipass.backup.apps.modules.drive_clear")
|
||||
assert mod.PRIMARY_COMMAND == "drive_clear"
|
||||
assert mod.handle_command("drive_clear", []) is True
|
||||
|
||||
@@ -127,6 +127,27 @@ class TestLogOperation:
|
||||
"""
|
||||
assert callable(json_handler.log_operation)
|
||||
|
||||
def test_log_operation_handles_path_objects(self, tmp_path: Path) -> None:
|
||||
"""log_operation serializes pathlib.Path values via default=str."""
|
||||
log_dir = tmp_path / "logs"
|
||||
log_dir.mkdir()
|
||||
with patch(
|
||||
"aipass.backup.apps.handlers.json.json_handler.Path",
|
||||
) as mock_path:
|
||||
mock_resolve = mock_path.return_value.resolve.return_value
|
||||
mock_resolve.parents.__getitem__ = lambda self, i: tmp_path
|
||||
mock_path.return_value.__truediv__ = Path.__truediv__
|
||||
json_handler.log_operation(
|
||||
"test_op",
|
||||
{"project_root": Path("/some/project")},
|
||||
)
|
||||
log_file = log_dir / "operations.jsonl"
|
||||
if log_file.exists():
|
||||
entry = json.loads(log_file.read_text(encoding="utf-8").strip())
|
||||
# default=str serializes via str(Path(...)) — platform-native separators,
|
||||
# so compare against the same (POSIX "/some/project", Windows "\some\project").
|
||||
assert entry["project_root"] == str(Path("/some/project"))
|
||||
|
||||
|
||||
class TestEnsureAndGetPath:
|
||||
"""Token coverage for standard json_handler API that backup doesn't implement.
|
||||
|
||||
@@ -282,6 +282,13 @@ def main() -> int:
|
||||
command = args[0]
|
||||
remaining = args[1:] if len(args) > 1 else []
|
||||
|
||||
if remaining and remaining[0] in ["--help", "-h"]:
|
||||
for module in modules:
|
||||
if module.handle_command(command, ["--help"]):
|
||||
return 0
|
||||
print_help()
|
||||
return 0
|
||||
|
||||
# Route to modules
|
||||
if route_command(command, remaining, modules):
|
||||
return 0
|
||||
|
||||
@@ -26,6 +26,9 @@ from aipass.cli.apps.modules.display import console, err_console
|
||||
# Display functions
|
||||
from aipass.cli.apps.modules.display import header, success, error, warning, fatal, section
|
||||
|
||||
# Exit-code failure-flag API
|
||||
from aipass.cli.apps.modules.display import mark_command_failed, command_failed, reset_command_state, resolve_exit
|
||||
|
||||
# Operation templates
|
||||
from aipass.cli.apps.modules.templates import operation_start, operation_complete
|
||||
|
||||
@@ -40,6 +43,11 @@ __all__ = [
|
||||
"warning",
|
||||
"fatal",
|
||||
"section",
|
||||
# Exit-code failure-flag API
|
||||
"mark_command_failed",
|
||||
"command_failed",
|
||||
"reset_command_state",
|
||||
"resolve_exit",
|
||||
# Templates
|
||||
"operation_start",
|
||||
"operation_complete",
|
||||
|
||||
@@ -28,6 +28,7 @@ from typing import Dict, Any, Optional, List
|
||||
from rich.console import Console
|
||||
from rich.panel import Panel
|
||||
from rich.table import Table
|
||||
from rich.text import Text
|
||||
from rich.columns import Columns
|
||||
|
||||
from aipass.cli.apps.handlers.json import json_handler
|
||||
@@ -48,6 +49,36 @@ err_console = Console(stderr=True, force_terminal=sys.stderr.isatty()) # Stderr
|
||||
_TRIGGER = None
|
||||
_TRIGGER_LOADED = False
|
||||
|
||||
# Process-level command failure flag — mutable container avoids global statement
|
||||
_CMD_STATE = {"failed": False}
|
||||
|
||||
|
||||
def mark_command_failed() -> None:
|
||||
"""Set the process-level failure flag (called automatically by error())."""
|
||||
_CMD_STATE["failed"] = True
|
||||
|
||||
|
||||
def command_failed() -> bool:
|
||||
"""Return whether mark_command_failed() has been called since last reset."""
|
||||
return _CMD_STATE["failed"]
|
||||
|
||||
|
||||
def reset_command_state() -> None:
|
||||
"""Reset the failure flag to False (for tests and main() entry)."""
|
||||
_CMD_STATE["failed"] = False
|
||||
|
||||
|
||||
def resolve_exit(handled: bool) -> int:
|
||||
"""Map handled/failed state to an exit code.
|
||||
|
||||
Returns 1 if not handled, 2 if handled but failed, 0 otherwise.
|
||||
"""
|
||||
if not handled:
|
||||
return 1
|
||||
if _CMD_STATE["failed"]:
|
||||
return 2
|
||||
return 0
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# MODULE PATTERN FUNCTIONS (SEEDGO compliant)
|
||||
@@ -341,9 +372,14 @@ def error(message: str, suggestion: str | None = None) -> None:
|
||||
Example:
|
||||
error('Branch not found', suggestion='Check branch name spelling')
|
||||
"""
|
||||
err_console.print(f"❌ [red bold]{message}[/red bold]")
|
||||
mark_command_failed()
|
||||
msg = Text("❌ ")
|
||||
msg.append(message, style="red bold")
|
||||
err_console.print(msg)
|
||||
if suggestion:
|
||||
err_console.print(f" [yellow]→ Try: {suggestion}[/yellow]")
|
||||
hint = Text(" → Try: ")
|
||||
hint.append(suggestion, style="yellow")
|
||||
err_console.print(hint)
|
||||
|
||||
|
||||
def warning(message: str, details: str | None = None) -> None:
|
||||
@@ -357,9 +393,13 @@ def warning(message: str, details: str | None = None) -> None:
|
||||
Example:
|
||||
warning('Branch already exists, skipping')
|
||||
"""
|
||||
err_console.print(f"⚠️ [yellow]{message}[/yellow]")
|
||||
msg = Text("⚠️ ")
|
||||
msg.append(message, style="yellow")
|
||||
err_console.print(msg)
|
||||
if details:
|
||||
err_console.print(f" [dim]{details}[/dim]")
|
||||
detail_text = Text(" ")
|
||||
detail_text.append(details, style="dim")
|
||||
err_console.print(detail_text)
|
||||
|
||||
|
||||
def fatal(message: str, suggestion: str | None = None) -> None:
|
||||
@@ -375,9 +415,13 @@ def fatal(message: str, suggestion: str | None = None) -> None:
|
||||
Example:
|
||||
fatal('Config file missing', suggestion='Run aipass init first')
|
||||
"""
|
||||
err_console.print(f"❌ [red bold]{message}[/red bold]")
|
||||
msg = Text("❌ ")
|
||||
msg.append(message, style="red bold")
|
||||
err_console.print(msg)
|
||||
if suggestion:
|
||||
err_console.print(f" [yellow]→ Try: {suggestion}[/yellow]")
|
||||
hint = Text(" → Try: ")
|
||||
hint.append(suggestion, style="yellow")
|
||||
err_console.print(hint)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
@@ -411,6 +455,10 @@ __all__ = [
|
||||
"warning",
|
||||
"fatal",
|
||||
"section",
|
||||
"mark_command_failed",
|
||||
"command_failed",
|
||||
"reset_command_state",
|
||||
"resolve_exit",
|
||||
]
|
||||
|
||||
# ============================================================================
|
||||
|
||||
@@ -522,3 +522,62 @@ class TestInfrastructureMocking:
|
||||
module_key = "aipass.cli.apps.modules.display"
|
||||
assert module_key in sys.modules
|
||||
assert sys.modules[module_key] is display
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Exit-code failure-flag tests
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class TestCommandState:
|
||||
"""Verify the process-level failure flag and resolve_exit truth table."""
|
||||
|
||||
def setup_method(self):
|
||||
display.reset_command_state()
|
||||
|
||||
def teardown_method(self):
|
||||
display.reset_command_state()
|
||||
|
||||
def test_initial_state_is_not_failed(self):
|
||||
assert display.command_failed() is False
|
||||
|
||||
def test_mark_command_failed_sets_flag(self):
|
||||
display.mark_command_failed()
|
||||
assert display.command_failed() is True
|
||||
|
||||
def test_reset_command_state_clears_flag(self):
|
||||
display.mark_command_failed()
|
||||
display.reset_command_state()
|
||||
assert display.command_failed() is False
|
||||
|
||||
def test_resolve_exit_not_handled(self):
|
||||
assert display.resolve_exit(handled=False) == 1
|
||||
|
||||
def test_resolve_exit_handled_ok(self):
|
||||
assert display.resolve_exit(handled=True) == 0
|
||||
|
||||
def test_resolve_exit_handled_failed(self):
|
||||
display.mark_command_failed()
|
||||
assert display.resolve_exit(handled=True) == 2
|
||||
|
||||
def test_resolve_exit_not_handled_ignores_flag(self):
|
||||
display.mark_command_failed()
|
||||
assert display.resolve_exit(handled=False) == 1
|
||||
|
||||
def test_error_trips_failure_flag(self):
|
||||
cons, _ = _make_capture_console()
|
||||
with patch.object(display, "err_console", cons):
|
||||
display.error("something broke")
|
||||
assert display.command_failed() is True
|
||||
|
||||
def test_warning_does_not_trip_flag(self):
|
||||
cons, _ = _make_capture_console()
|
||||
with patch.object(display, "err_console", cons):
|
||||
display.warning("just a warning")
|
||||
assert display.command_failed() is False
|
||||
|
||||
def test_success_does_not_trip_flag(self):
|
||||
cons, _ = _make_capture_console()
|
||||
with patch.object(display, "CONSOLE", cons):
|
||||
display.success("all good")
|
||||
assert display.command_failed() is False
|
||||
|
||||
@@ -292,7 +292,7 @@ def print_introspection(modules: List[Any]) -> None:
|
||||
console.print("[dim]A gathering place where branches post, comment, vote, and discuss.[/dim]")
|
||||
console.print()
|
||||
|
||||
console.print(f"[yellow]Discovered Modules:[/yellow] {len(modules)}")
|
||||
warning(f"Discovered Modules: {len(modules)}")
|
||||
console.print()
|
||||
|
||||
if modules:
|
||||
|
||||
@@ -20,12 +20,13 @@ from typing import List
|
||||
from aipass.prax.apps.modules.logger import system_logger as logger
|
||||
|
||||
try:
|
||||
from aipass.cli.apps.modules import console
|
||||
from aipass.cli.apps.modules import console, error
|
||||
except ImportError:
|
||||
logger.warning("[activity] CLI console unavailable, using fallback")
|
||||
from rich.console import Console
|
||||
|
||||
console = Console()
|
||||
error = console.print # type: ignore[assignment]
|
||||
|
||||
from rich.table import Table
|
||||
|
||||
@@ -81,7 +82,7 @@ def _handle_activity(args: List[str]) -> bool:
|
||||
|
||||
if not result["success"]:
|
||||
if result.get("error"):
|
||||
console.print(f"[red]{result['error']}[/red]")
|
||||
error(result["error"])
|
||||
return True
|
||||
|
||||
if result.get("help"):
|
||||
|
||||
@@ -20,12 +20,15 @@ from typing import List
|
||||
from aipass.prax.apps.modules.logger import system_logger as logger
|
||||
|
||||
try:
|
||||
from aipass.cli.apps.modules import console
|
||||
from aipass.cli.apps.modules import console, error, success, warning
|
||||
except ImportError:
|
||||
logger.warning("[artifact] CLI console unavailable, using fallback")
|
||||
from rich.console import Console
|
||||
|
||||
console = Console()
|
||||
error = console.print # type: ignore[assignment]
|
||||
success = console.print # type: ignore[assignment]
|
||||
warning = console.print # type: ignore[assignment]
|
||||
|
||||
from rich.panel import Panel
|
||||
from rich.table import Table
|
||||
@@ -96,12 +99,12 @@ def handle_command(command: str, args: List[str]) -> bool:
|
||||
def _handle_craft(args: List[str]) -> bool:
|
||||
result = craft_artifact(args)
|
||||
if not result["success"]:
|
||||
console.print(f"[red]{result['error']}[/red]")
|
||||
error(result["error"])
|
||||
return True
|
||||
|
||||
rarity_color = RARITY_COLORS.get(result["rarity"], "white")
|
||||
console.print()
|
||||
console.print("[green]Artifact crafted![/green]")
|
||||
success("Artifact crafted!")
|
||||
console.print(f" [dim]ID:[/dim] {result['artifact_id']}")
|
||||
console.print(f" [dim]Name:[/dim] {result['name']}")
|
||||
console.print(f" [dim]Type:[/dim] {result['type']}")
|
||||
@@ -115,7 +118,7 @@ def _handle_craft(args: List[str]) -> bool:
|
||||
def _handle_list(args: List[str]) -> bool:
|
||||
result = list_artifacts(args)
|
||||
if not result["success"]:
|
||||
console.print(f"[red]{result['error']}[/red]")
|
||||
error(result["error"])
|
||||
return True
|
||||
|
||||
artifacts = result["artifacts"]
|
||||
@@ -155,7 +158,7 @@ def _handle_list(args: List[str]) -> bool:
|
||||
def _handle_inspect(args: List[str]) -> bool:
|
||||
result = inspect_artifact(args)
|
||||
if not result["success"]:
|
||||
console.print(f"[red]{result['error']}[/red]")
|
||||
error(result["error"])
|
||||
return True
|
||||
|
||||
artifact = result["artifact"]
|
||||
@@ -212,7 +215,7 @@ def _handle_inspect(args: List[str]) -> bool:
|
||||
elif action == "found":
|
||||
console.print(f" [yellow]*[/yellow] {timestamp[:19]} | Found by {to_agent}")
|
||||
elif action == "expired":
|
||||
console.print(f" [red]x[/red] {timestamp[:19]} | Expired: {entry.get('details', '')}")
|
||||
console.print(f" [dim]x[/dim] {timestamp[:19]} | Expired: {entry.get('details', '')}")
|
||||
else:
|
||||
console.print(f" [dim]-[/dim] {timestamp[:19]} | {action.title()}: {entry.get('details', '')}")
|
||||
|
||||
@@ -228,15 +231,15 @@ def _handle_inspect(args: List[str]) -> bool:
|
||||
def _handle_collab(args: List[str]) -> bool:
|
||||
result = collab_artifact(args)
|
||||
if not result["success"]:
|
||||
console.print(f"[red]{result['error']}[/red]")
|
||||
error(result["error"])
|
||||
return True
|
||||
|
||||
for warning in result.get("warnings", []):
|
||||
console.print(f"[yellow]Warning: {warning}[/yellow]")
|
||||
for warn_msg in result.get("warnings", []):
|
||||
warning(f"Warning: {warn_msg}")
|
||||
|
||||
rarity_color = RARITY_COLORS.get(result["rarity"], "white")
|
||||
console.print()
|
||||
console.print("[green]Joint artifact initiated![/green]")
|
||||
success("Joint artifact initiated!")
|
||||
console.print(f" [dim]Pending ID:[/dim] {result['pending_id']}")
|
||||
console.print(f" [dim]Name:[/dim] {result['name']}")
|
||||
console.print(f" [dim]Rarity:[/dim] [{rarity_color}]{result['rarity']}[/{rarity_color}]")
|
||||
@@ -252,13 +255,13 @@ def _handle_collab(args: List[str]) -> bool:
|
||||
def _handle_sign(args: List[str]) -> bool:
|
||||
result = sign_artifact(args)
|
||||
if not result["success"]:
|
||||
console.print(f"[red]{result['error']}[/red]")
|
||||
error(result["error"])
|
||||
return True
|
||||
|
||||
if result["completed"]:
|
||||
rarity_color = RARITY_COLORS.get(result["rarity"], "white")
|
||||
console.print()
|
||||
console.print("[bold green]Joint artifact completed![/bold green]")
|
||||
success("Joint artifact completed!")
|
||||
console.print(f" [dim]Artifact ID:[/dim] {result['artifact_id']}")
|
||||
console.print(f" [dim]Name:[/dim] [{rarity_color}]{result['name']}[/{rarity_color}]")
|
||||
console.print(f" [dim]Rarity:[/dim] [{rarity_color}]{result['rarity']}[/{rarity_color}]")
|
||||
|
||||
@@ -20,12 +20,13 @@ from typing import List
|
||||
from aipass.prax.apps.modules.logger import system_logger as logger
|
||||
|
||||
try:
|
||||
from aipass.cli.apps.modules import console
|
||||
from aipass.cli.apps.modules import console, error
|
||||
except ImportError:
|
||||
logger.warning("[capsule] CLI console unavailable, using fallback")
|
||||
from rich.console import Console
|
||||
|
||||
console = Console()
|
||||
error = console.print # type: ignore[assignment]
|
||||
|
||||
from rich.panel import Panel
|
||||
from rich.table import Table
|
||||
@@ -84,7 +85,7 @@ def handle_command(command: str, args: List[str]) -> bool:
|
||||
def _handle_seal(args: List[str]) -> bool:
|
||||
result = seal_capsule(args)
|
||||
if not result["success"]:
|
||||
console.print(f"[red]{result['error']}[/red]")
|
||||
error(result["error"])
|
||||
return True
|
||||
|
||||
console.print()
|
||||
@@ -109,7 +110,7 @@ def _handle_seal(args: List[str]) -> bool:
|
||||
def _handle_list(args: List[str]) -> bool:
|
||||
result = list_capsules(args)
|
||||
if not result["success"]:
|
||||
console.print(f"[red]{result['error']}[/red]")
|
||||
error(result["error"])
|
||||
return True
|
||||
|
||||
capsules = result["capsules"]
|
||||
@@ -152,7 +153,7 @@ def _handle_list(args: List[str]) -> bool:
|
||||
def _handle_open(args: List[str]) -> bool:
|
||||
result = open_capsule(args)
|
||||
if not result["success"]:
|
||||
console.print(f"[red]{result['error']}[/red]")
|
||||
error(result["error"])
|
||||
return True
|
||||
|
||||
capsule = result["capsule"]
|
||||
|
||||
@@ -20,12 +20,13 @@ from typing import List
|
||||
from aipass.prax.apps.modules.logger import system_logger as logger
|
||||
|
||||
try:
|
||||
from aipass.cli.apps.modules import console
|
||||
from aipass.cli.apps.modules import console, error
|
||||
except ImportError:
|
||||
logger.warning("[catchup] CLI console unavailable, using fallback")
|
||||
from rich.console import Console
|
||||
|
||||
console = Console()
|
||||
error = console.print # type: ignore[assignment]
|
||||
|
||||
from aipass.commons.apps.handlers.catchup.catchup_ops import run_catchup
|
||||
from aipass.commons.apps.handlers.json import json_handler
|
||||
@@ -75,7 +76,7 @@ def _handle_catchup(args: List[str]) -> bool:
|
||||
result = run_catchup(args)
|
||||
|
||||
if not result["success"]:
|
||||
console.print(f"[red]{result['error']}[/red]")
|
||||
error(result["error"])
|
||||
return True
|
||||
|
||||
is_first_visit = result["is_first_visit"]
|
||||
@@ -139,7 +140,7 @@ def _handle_catchup(args: List[str]) -> bool:
|
||||
if karma_change > 0:
|
||||
console.print(f" [green]KARMA:[/green] +{karma_change} since last session")
|
||||
elif karma_change < 0:
|
||||
console.print(f" [red]KARMA:[/red] {karma_change} since last session")
|
||||
error(f"KARMA: {karma_change} since last session")
|
||||
else:
|
||||
console.print(" [dim]KARMA:[/dim] [dim]No change[/dim]")
|
||||
|
||||
|
||||
@@ -21,12 +21,14 @@ from typing import List
|
||||
from aipass.prax.apps.modules.logger import system_logger as logger
|
||||
|
||||
try:
|
||||
from aipass.cli.apps.modules import console
|
||||
from aipass.cli.apps.modules import console, error, success
|
||||
except ImportError:
|
||||
logger.warning("[central] CLI console unavailable, using fallback")
|
||||
from rich.console import Console
|
||||
|
||||
console = Console()
|
||||
error = console.print # type: ignore[assignment]
|
||||
success = console.print # type: ignore[assignment]
|
||||
|
||||
from aipass.commons.apps.handlers.central.central_writer import update_central
|
||||
from aipass.commons.apps.handlers.json import json_handler
|
||||
@@ -74,10 +76,10 @@ def handle_command(command: str, args: List[str]) -> bool:
|
||||
try:
|
||||
stats = update_central()
|
||||
branch_count = len(stats.get("branch_stats", {}))
|
||||
console.print(f"[green]Central file updated:[/green] {branch_count} branches")
|
||||
success(f"Central file updated: {branch_count} branches")
|
||||
json_handler.log_operation("push-central_executed", {"command": "push-central", "success": True})
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"[commons] push-central failed: {e}")
|
||||
console.print(f"[red]Error:[/red] {e}")
|
||||
error(f"Error: {e}")
|
||||
return True
|
||||
|
||||
@@ -20,12 +20,15 @@ from typing import List
|
||||
from aipass.prax.apps.modules.logger import system_logger as logger
|
||||
|
||||
try:
|
||||
from aipass.cli.apps.modules import console
|
||||
from aipass.cli.apps.modules import console, error, success
|
||||
except ImportError:
|
||||
logger.warning("[comment] CLI console unavailable, using fallback")
|
||||
from rich.console import Console
|
||||
|
||||
console = Console()
|
||||
error = console.print # type: ignore[assignment]
|
||||
success = console.print # type: ignore[assignment]
|
||||
|
||||
|
||||
from aipass.commons.apps.handlers.comments.comment_ops import add_comment, vote_on_content
|
||||
from aipass.commons.apps.handlers.identity.identity_ops import resolve_display_name
|
||||
@@ -85,12 +88,12 @@ def _handle_comment(args: List[str]) -> bool:
|
||||
result = add_comment(args)
|
||||
|
||||
if not result["success"]:
|
||||
console.print(f"[red]{result['error']}[/red]")
|
||||
error(result["error"])
|
||||
return True
|
||||
|
||||
parent_note = f" (reply to comment {result['parent_id']})" if result.get("parent_id") else ""
|
||||
console.print()
|
||||
console.print(f"[green]Comment added to post {result['post_id']}{parent_note}[/green]")
|
||||
success(f"Comment added to post {result['post_id']}{parent_note}")
|
||||
console.print(f" [dim]Comment ID:[/dim] {result['comment_id']}")
|
||||
console.print(f" [dim]Author:[/dim] {resolve_display_name(result['author'])}")
|
||||
if result.get("mentions"):
|
||||
@@ -105,7 +108,7 @@ def _handle_vote(args: List[str]) -> bool:
|
||||
result = vote_on_content(args)
|
||||
|
||||
if not result["success"]:
|
||||
console.print(f"[red]{result['error']}[/red]")
|
||||
error(result["error"])
|
||||
return True
|
||||
|
||||
action_msg = {
|
||||
@@ -117,10 +120,7 @@ def _handle_vote(args: List[str]) -> bool:
|
||||
arrow = "^" if result["direction"] == "up" else "v"
|
||||
|
||||
console.print()
|
||||
console.print(
|
||||
f"[green]{arrow} {action_msg} on {result['target_type']} "
|
||||
f"{result['target_id']}[/green] [dim](score: {result['new_score']})[/dim]"
|
||||
)
|
||||
success(f"{arrow} {action_msg} on {result['target_type']} {result['target_id']} (score: {result['new_score']})")
|
||||
console.print()
|
||||
|
||||
return True
|
||||
|
||||
@@ -23,22 +23,14 @@ from typing import List
|
||||
from aipass.prax.apps.modules.logger import system_logger as logger
|
||||
|
||||
try:
|
||||
from aipass.cli.apps.modules import console
|
||||
from aipass.cli.apps.modules.display import error, warning
|
||||
from aipass.cli.apps.modules import console, error, warning
|
||||
except ImportError:
|
||||
logger.warning("[commons_identity] CLI console unavailable, using fallback")
|
||||
from rich.console import Console
|
||||
|
||||
console = Console()
|
||||
|
||||
def error(message: str, suggestion: str | None = None) -> None:
|
||||
"""Display error message in red."""
|
||||
console.print(f"[red]{message}[/red]")
|
||||
|
||||
def warning(message: str, details: str | None = None) -> None:
|
||||
"""Display warning message in yellow."""
|
||||
console.print(f"[yellow]{message}[/yellow]")
|
||||
|
||||
error = console.print # type: ignore[assignment]
|
||||
warning = console.print # type: ignore[assignment]
|
||||
|
||||
# Re-export all public functions for backward compatibility
|
||||
from aipass.commons.apps.handlers.identity.identity_ops import (
|
||||
@@ -133,5 +125,5 @@ def _handle_whoami(args: List[str]) -> bool:
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[commons.identity] whoami failed: {e}")
|
||||
console.print(f"[red]Error detecting identity:[/red] {e}")
|
||||
error(f"Error detecting identity: {e}")
|
||||
return True
|
||||
|
||||
@@ -20,12 +20,13 @@ from typing import List
|
||||
from aipass.prax.apps.modules.logger import system_logger as logger
|
||||
|
||||
try:
|
||||
from aipass.cli.apps.modules import console
|
||||
from aipass.cli.apps.modules import console, error
|
||||
except ImportError:
|
||||
logger.warning("[digest] CLI console unavailable, using fallback")
|
||||
from rich.console import Console
|
||||
|
||||
console = Console()
|
||||
error = console.print # type: ignore[assignment]
|
||||
|
||||
from rich.panel import Panel
|
||||
|
||||
@@ -79,7 +80,7 @@ def _handle_digest(args: List[str]) -> bool:
|
||||
result = show_digest(args)
|
||||
|
||||
if not result["success"]:
|
||||
console.print(f"[red]Failed to generate digest: {result['error']}[/red]")
|
||||
error(f"Failed to generate digest: {result['error']}")
|
||||
return True
|
||||
|
||||
top_posts = result["top_posts"]
|
||||
|
||||
@@ -21,12 +21,14 @@ from typing import List
|
||||
from aipass.prax.apps.modules.logger import system_logger as logger
|
||||
|
||||
try:
|
||||
from aipass.cli.apps.modules import console
|
||||
from aipass.cli.apps.modules import console, error, success
|
||||
except ImportError:
|
||||
logger.warning("[engagement] CLI console unavailable, using fallback")
|
||||
from rich.console import Console
|
||||
|
||||
console = Console()
|
||||
error = console.print # type: ignore[assignment]
|
||||
success = console.print # type: ignore[assignment]
|
||||
|
||||
from aipass.commons.apps.handlers.engagement.engagement_ops import generate_prompt, create_event
|
||||
from aipass.commons.apps.handlers.json import json_handler
|
||||
@@ -88,7 +90,7 @@ def _handle_prompt(args: List[str]) -> bool:
|
||||
result = generate_prompt(args)
|
||||
|
||||
if not result["success"]:
|
||||
console.print(f"[red]{result['error']}[/red]")
|
||||
error(result["error"])
|
||||
return True
|
||||
|
||||
if result.get("dry_run"):
|
||||
@@ -100,7 +102,7 @@ def _handle_prompt(args: List[str]) -> bool:
|
||||
return True
|
||||
|
||||
console.print()
|
||||
console.print("[green]Daily prompt posted![/green]")
|
||||
success("Daily prompt posted!")
|
||||
console.print(f" [dim]ID:[/dim] {result['post_id']}")
|
||||
console.print(f" [dim]Room:[/dim] r/{result['room']}")
|
||||
console.print(f" [dim]Theme:[/dim] {result['theme']}")
|
||||
@@ -115,7 +117,7 @@ def _handle_event(args: List[str]) -> bool:
|
||||
result = create_event(args)
|
||||
|
||||
if not result["success"]:
|
||||
console.print(f"[red]{result['error']}[/red]")
|
||||
error(result["error"])
|
||||
return True
|
||||
|
||||
if result.get("dry_run"):
|
||||
@@ -127,7 +129,7 @@ def _handle_event(args: List[str]) -> bool:
|
||||
return True
|
||||
|
||||
console.print()
|
||||
console.print("[green]Event created![/green]")
|
||||
success("Event created!")
|
||||
console.print(f" [dim]ID:[/dim] {result['post_id']}")
|
||||
console.print(f" [dim]Room:[/dim] r/{result['room']}")
|
||||
console.print(f" [dim]Title:[/dim] {result['title']}")
|
||||
|
||||
@@ -20,12 +20,14 @@ from typing import List
|
||||
from aipass.prax.apps.modules.logger import system_logger as logger
|
||||
|
||||
try:
|
||||
from aipass.cli.apps.modules import console
|
||||
from aipass.cli.apps.modules import console, error, success
|
||||
except ImportError:
|
||||
logger.warning("[explore] CLI console unavailable, using fallback")
|
||||
from rich.console import Console
|
||||
|
||||
console = Console()
|
||||
error = console.print # type: ignore[assignment]
|
||||
success = console.print # type: ignore[assignment]
|
||||
|
||||
from rich.panel import Panel
|
||||
from rich.table import Table
|
||||
@@ -77,7 +79,7 @@ def handle_command(command: str, args: List[str]) -> bool:
|
||||
def _handle_explore(args: List[str]) -> bool:
|
||||
result = explore_rooms(args)
|
||||
if not result["success"]:
|
||||
console.print(f"[red]{result['error']}[/red]")
|
||||
error(result["error"])
|
||||
return True
|
||||
|
||||
hidden_rooms = result["hidden_rooms"]
|
||||
@@ -108,8 +110,8 @@ def _handle_explore(args: List[str]) -> bool:
|
||||
|
||||
revealed = result.get("revealed")
|
||||
if revealed:
|
||||
console.print(f"[green]Your exploration has paid off! You've visited {rooms_visited} rooms.[/green]")
|
||||
console.print(f"[green]A secret room reveals itself:[/green] [bold magenta]r/{revealed['name']}[/bold magenta]")
|
||||
success(f"Your exploration has paid off! You've visited {rooms_visited} rooms.")
|
||||
success(f"A secret room reveals itself: r/{revealed['name']}")
|
||||
console.print(f" [dim]{revealed['description']}[/dim]")
|
||||
console.print()
|
||||
console.print(f"[dim]Try: commons enter {revealed['name']}[/dim]")
|
||||
@@ -126,7 +128,7 @@ def _handle_explore(args: List[str]) -> bool:
|
||||
def _handle_secrets(args: List[str]) -> bool:
|
||||
result = list_secrets(args)
|
||||
if not result["success"]:
|
||||
console.print(f"[red]{result['error']}[/red]")
|
||||
error(result["error"])
|
||||
return True
|
||||
|
||||
discovered = result["discovered"]
|
||||
|
||||
@@ -20,12 +20,13 @@ from typing import List
|
||||
from aipass.prax.apps.modules.logger import system_logger as logger
|
||||
|
||||
try:
|
||||
from aipass.cli.apps.modules import console
|
||||
from aipass.cli.apps.modules import console, error
|
||||
except ImportError:
|
||||
logger.warning("[feed] CLI console unavailable, using fallback")
|
||||
from rich.console import Console
|
||||
|
||||
console = Console()
|
||||
error = console.print # type: ignore[assignment]
|
||||
|
||||
from rich.table import Table
|
||||
|
||||
@@ -81,7 +82,7 @@ def _handle_feed(args: List[str]) -> bool:
|
||||
result = display_feed(args)
|
||||
|
||||
if not result["success"]:
|
||||
console.print(f"[red]Feed error: {result['error']}[/red]")
|
||||
error(f"Feed error: {result['error']}")
|
||||
return True
|
||||
|
||||
posts = result["posts"]
|
||||
|
||||
@@ -20,12 +20,13 @@ from typing import List
|
||||
from aipass.prax.apps.modules.logger import system_logger as logger
|
||||
|
||||
try:
|
||||
from aipass.cli.apps.modules import console
|
||||
from aipass.cli.apps.modules import console, error
|
||||
except ImportError:
|
||||
logger.warning("[leaderboard] CLI console unavailable, using fallback")
|
||||
from rich.console import Console
|
||||
|
||||
console = Console()
|
||||
error = console.print # type: ignore[assignment]
|
||||
|
||||
from rich.table import Table
|
||||
|
||||
@@ -94,7 +95,7 @@ def _handle_leaderboard(args: List[str]) -> bool:
|
||||
result = show_leaderboard(args)
|
||||
|
||||
if not result["success"]:
|
||||
console.print(f"[red]{result['error']}[/red]")
|
||||
error(result["error"])
|
||||
return True
|
||||
|
||||
boards = result["boards"]
|
||||
|
||||
@@ -21,12 +21,13 @@ from typing import List
|
||||
from aipass.prax.apps.modules.logger import system_logger as logger
|
||||
|
||||
try:
|
||||
from aipass.cli.apps.modules import console
|
||||
from aipass.cli.apps.modules import console, error
|
||||
except ImportError:
|
||||
logger.warning("[notification] CLI console unavailable, using fallback")
|
||||
from rich.console import Console
|
||||
|
||||
console = Console()
|
||||
error = console.print # type: ignore[assignment]
|
||||
|
||||
from aipass.commons.apps.handlers.notifications.notification_ops import (
|
||||
set_watch,
|
||||
@@ -110,7 +111,7 @@ LEVEL_LABELS = {
|
||||
def _handle_level(result: dict, level: str) -> bool:
|
||||
"""Display the result of setting a notification level."""
|
||||
if not result["success"]:
|
||||
console.print(f"[red]{result['error']}[/red]")
|
||||
error(result["error"])
|
||||
return True
|
||||
|
||||
label, color, description = LEVEL_LABELS[level]
|
||||
@@ -127,7 +128,7 @@ def _handle_preferences(args: List[str]) -> bool:
|
||||
result = show_preferences(args)
|
||||
|
||||
if not result["success"]:
|
||||
console.print(f"[red]{result['error']}[/red]")
|
||||
error(result["error"])
|
||||
return True
|
||||
|
||||
prefs = result["preferences"]
|
||||
|
||||
@@ -20,12 +20,14 @@ from typing import List
|
||||
from aipass.prax.apps.modules.logger import system_logger as logger
|
||||
|
||||
try:
|
||||
from aipass.cli.apps.modules import console
|
||||
from aipass.cli.apps.modules import console, error, success
|
||||
except ImportError:
|
||||
logger.warning("[post] CLI console unavailable, using fallback")
|
||||
from rich.console import Console
|
||||
|
||||
console = Console()
|
||||
error = console.print # type: ignore[assignment]
|
||||
success = console.print # type: ignore[assignment]
|
||||
|
||||
from rich.panel import Panel
|
||||
from rich.text import Text
|
||||
@@ -91,11 +93,11 @@ def _handle_create_post(args: List[str]) -> bool:
|
||||
result = create_post(args)
|
||||
|
||||
if not result["success"]:
|
||||
console.print(f"[red]{result['error']}[/red]")
|
||||
error(result["error"])
|
||||
return True
|
||||
|
||||
console.print()
|
||||
console.print(f"[green]Post created in r/{result['room']}[/green]")
|
||||
success(f"Post created in r/{result['room']}")
|
||||
console.print(f" [dim]ID:[/dim] {result['post_id']}")
|
||||
console.print(f" [dim]Title:[/dim] {result['title']}")
|
||||
console.print(f" [dim]Type:[/dim] {result['post_type']}")
|
||||
@@ -112,7 +114,7 @@ def _handle_view_thread(args: List[str]) -> bool:
|
||||
result = view_thread(args)
|
||||
|
||||
if not result["success"]:
|
||||
console.print(f"[red]{result['error']}[/red]")
|
||||
error(result["error"])
|
||||
return True
|
||||
|
||||
post = result["post"]
|
||||
@@ -187,8 +189,8 @@ def _handle_delete_post(args: List[str]) -> bool:
|
||||
result = delete_post(args)
|
||||
|
||||
if not result["success"]:
|
||||
console.print(f"[red]{result['error']}[/red]")
|
||||
error(result["error"])
|
||||
return True
|
||||
|
||||
console.print(f"[green]Post {result['post_id']} deleted.[/green]")
|
||||
success(f"Post {result['post_id']} deleted.")
|
||||
return True
|
||||
|
||||
@@ -21,12 +21,14 @@ from typing import List
|
||||
from aipass.prax.apps.modules.logger import system_logger as logger
|
||||
|
||||
try:
|
||||
from aipass.cli.apps.modules import console
|
||||
from aipass.cli.apps.modules import console, error, success
|
||||
except ImportError:
|
||||
logger.warning("[profile] CLI console unavailable, using fallback")
|
||||
from rich.console import Console
|
||||
|
||||
console = Console()
|
||||
error = console.print # type: ignore[assignment]
|
||||
success = console.print # type: ignore[assignment]
|
||||
|
||||
from rich.panel import Panel
|
||||
|
||||
@@ -85,11 +87,11 @@ def _handle_profile(args: List[str]) -> bool:
|
||||
result = show_profile(args)
|
||||
|
||||
if not result["success"]:
|
||||
console.print(f"[red]{result['error']}[/red]")
|
||||
error(result["error"])
|
||||
return True
|
||||
|
||||
if result["action"] == "set":
|
||||
console.print(f"[green]Updated {result['field']} for {result['branch']}[/green]")
|
||||
success(f"Updated {result['field']} for {result['branch']}")
|
||||
return True
|
||||
|
||||
# View profile
|
||||
@@ -128,7 +130,7 @@ def _handle_who(args: List[str]) -> bool:
|
||||
result = list_members(args)
|
||||
|
||||
if not result["success"]:
|
||||
console.print(f"[red]{result['error']}[/red]")
|
||||
error(result["error"])
|
||||
return True
|
||||
|
||||
agents = result["agents"]
|
||||
|
||||
@@ -21,12 +21,14 @@ from typing import List
|
||||
from aipass.prax.apps.modules.logger import system_logger as logger
|
||||
|
||||
try:
|
||||
from aipass.cli.apps.modules import console
|
||||
from aipass.cli.apps.modules import console, error, success
|
||||
except ImportError:
|
||||
logger.warning("[reaction] CLI console unavailable, using fallback")
|
||||
from rich.console import Console
|
||||
|
||||
console = Console()
|
||||
error = console.print # type: ignore[assignment]
|
||||
success = console.print # type: ignore[assignment]
|
||||
|
||||
from aipass.commons.apps.handlers.curation.curation_ops import (
|
||||
add_react,
|
||||
@@ -127,7 +129,7 @@ def _handle_react(args: List[str]) -> bool:
|
||||
result = add_react(args)
|
||||
|
||||
if not result["success"]:
|
||||
console.print(f"[red]{result['error']}[/red]")
|
||||
error(result["error"])
|
||||
return True
|
||||
|
||||
emoji = result["emoji"]
|
||||
@@ -151,7 +153,7 @@ def _handle_unreact(args: List[str]) -> bool:
|
||||
result = remove_react(args)
|
||||
|
||||
if not result["success"]:
|
||||
console.print(f"[red]{result['error']}[/red]")
|
||||
error(result["error"])
|
||||
return True
|
||||
|
||||
emoji = result["emoji"]
|
||||
@@ -174,7 +176,7 @@ def _handle_reactions(args: List[str]) -> bool:
|
||||
result = show_reactions(args)
|
||||
|
||||
if not result["success"]:
|
||||
console.print(f"[red]{result['error']}[/red]")
|
||||
error(result["error"])
|
||||
return True
|
||||
|
||||
detailed = result["reactions"]
|
||||
@@ -200,11 +202,11 @@ def _handle_pin(args: List[str]) -> bool:
|
||||
result = pin_post_cmd(args)
|
||||
|
||||
if not result["success"]:
|
||||
console.print(f"[red]{result['error']}[/red]")
|
||||
error(result["error"])
|
||||
return True
|
||||
|
||||
console.print()
|
||||
console.print(f'[green]Pinned post #{result["post_id"]} "{result["title"]}"[/green]')
|
||||
success(f'Pinned post #{result["post_id"]} "{result["title"]}"')
|
||||
console.print()
|
||||
|
||||
return True
|
||||
@@ -215,11 +217,11 @@ def _handle_unpin(args: List[str]) -> bool:
|
||||
result = unpin_post_cmd(args)
|
||||
|
||||
if not result["success"]:
|
||||
console.print(f"[red]{result['error']}[/red]")
|
||||
error(result["error"])
|
||||
return True
|
||||
|
||||
console.print()
|
||||
console.print(f'[green]Unpinned post #{result["post_id"]} "{result["title"]}"[/green]')
|
||||
success(f'Unpinned post #{result["post_id"]} "{result["title"]}"')
|
||||
console.print()
|
||||
|
||||
return True
|
||||
@@ -230,7 +232,7 @@ def _handle_pinned(args: List[str]) -> bool:
|
||||
result = show_pinned(args)
|
||||
|
||||
if not result["success"]:
|
||||
console.print(f"[red]{result['error']}[/red]")
|
||||
error(result["error"])
|
||||
return True
|
||||
|
||||
posts = result["posts"]
|
||||
@@ -260,7 +262,7 @@ def _handle_trending(args: List[str]) -> bool:
|
||||
result = show_trending(args)
|
||||
|
||||
if not result["success"]:
|
||||
console.print(f"[red]{result['error']}[/red]")
|
||||
error(result["error"])
|
||||
return True
|
||||
|
||||
posts = result["posts"]
|
||||
|
||||
@@ -20,12 +20,14 @@ from typing import List
|
||||
from aipass.prax.apps.modules.logger import system_logger as logger
|
||||
|
||||
try:
|
||||
from aipass.cli.apps.modules import console
|
||||
from aipass.cli.apps.modules import console, error, success
|
||||
except ImportError:
|
||||
logger.warning("[room] CLI console unavailable, using fallback")
|
||||
from rich.console import Console
|
||||
|
||||
console = Console()
|
||||
error = console.print # type: ignore[assignment]
|
||||
success = console.print # type: ignore[assignment]
|
||||
|
||||
from rich.table import Table
|
||||
|
||||
@@ -82,7 +84,7 @@ def handle_command(command: str, args: List[str]) -> bool:
|
||||
elif subcommand == "leave":
|
||||
result = _handle_leave_room(sub_args)
|
||||
else:
|
||||
console.print(f"[red]Unknown room subcommand: {subcommand}[/red]")
|
||||
error(f"Unknown room subcommand: {subcommand}")
|
||||
console.print("[dim]Available: create, list, join, leave[/dim]")
|
||||
return True
|
||||
|
||||
@@ -101,11 +103,11 @@ def _handle_create_room(args: List[str]) -> bool:
|
||||
result = create_room(args)
|
||||
|
||||
if not result["success"]:
|
||||
console.print(f"[red]{result['error']}[/red]")
|
||||
error(result["error"])
|
||||
return True
|
||||
|
||||
console.print()
|
||||
console.print(f"[green]Room '{result['name']}' created![/green]")
|
||||
success(f"Room '{result['name']}' created!")
|
||||
if result.get("description"):
|
||||
console.print(f" [dim]Description:[/dim] {result['description']}")
|
||||
console.print(f" [dim]Created by:[/dim] {result['created_by']}")
|
||||
@@ -119,7 +121,7 @@ def _handle_list_rooms(args: List[str]) -> bool:
|
||||
result = list_rooms(args)
|
||||
|
||||
if not result["success"]:
|
||||
console.print(f"[red]{result['error']}[/red]")
|
||||
error(result["error"])
|
||||
return True
|
||||
|
||||
rooms = result["rooms"]
|
||||
@@ -158,11 +160,11 @@ def _handle_join_room(args: List[str]) -> bool:
|
||||
result = join_room(args)
|
||||
|
||||
if not result["success"]:
|
||||
console.print(f"[red]{result['error']}[/red]")
|
||||
error(result["error"])
|
||||
return True
|
||||
|
||||
console.print()
|
||||
console.print(f"[green]{result['agent']} joined room '{result['room']}'![/green]")
|
||||
success(f"{result['agent']} joined room '{result['room']}'!")
|
||||
console.print()
|
||||
|
||||
return True
|
||||
@@ -173,11 +175,11 @@ def _handle_leave_room(args: List[str]) -> bool:
|
||||
result = leave_room(args)
|
||||
|
||||
if not result["success"]:
|
||||
console.print(f"[red]{result['error']}[/red]")
|
||||
error(result["error"])
|
||||
return True
|
||||
|
||||
console.print()
|
||||
console.print(f"[green]{result['agent']} left room '{result['room']}'.[/green]")
|
||||
success(f"{result['agent']} left room '{result['room']}'.")
|
||||
console.print()
|
||||
|
||||
return True
|
||||
|
||||
@@ -20,12 +20,13 @@ from typing import List
|
||||
from aipass.prax.apps.modules.logger import system_logger as logger
|
||||
|
||||
try:
|
||||
from aipass.cli.apps.modules import console
|
||||
from aipass.cli.apps.modules import console, error
|
||||
except ImportError:
|
||||
logger.warning("[search] CLI console unavailable, using fallback")
|
||||
from rich.console import Console
|
||||
|
||||
console = Console()
|
||||
error = console.print # type: ignore[assignment]
|
||||
|
||||
from aipass.commons.apps.handlers.search.search_ops import run_search, run_log_export
|
||||
from aipass.commons.apps.handlers.json import json_handler
|
||||
@@ -82,7 +83,7 @@ def _handle_search(args: List[str]) -> bool:
|
||||
result = run_search(args)
|
||||
|
||||
if not result["success"]:
|
||||
console.print(f"[red]{result['error']}[/red]")
|
||||
error(result["error"])
|
||||
return True
|
||||
|
||||
posts = result["posts"]
|
||||
@@ -137,7 +138,7 @@ def _handle_log(args: List[str]) -> bool:
|
||||
result = run_log_export(args)
|
||||
|
||||
if not result["success"]:
|
||||
console.print(f"[red]{result['error']}[/red]")
|
||||
error(result["error"])
|
||||
return True
|
||||
|
||||
console.print()
|
||||
|
||||
@@ -20,22 +20,14 @@ from typing import List
|
||||
from aipass.prax.apps.modules.logger import system_logger as logger
|
||||
|
||||
try:
|
||||
from aipass.cli.apps.modules import console
|
||||
from aipass.cli.apps.modules import console, error, success
|
||||
except ImportError:
|
||||
logger.warning("[space] CLI console unavailable, using fallback")
|
||||
from rich.console import Console
|
||||
|
||||
console = Console()
|
||||
|
||||
try:
|
||||
from aipass.cli.apps.modules.display import error
|
||||
except ImportError:
|
||||
logger.warning("[space] CLI error function unavailable, using fallback")
|
||||
|
||||
def error(message, suggestion=None):
|
||||
"""Display error message in red."""
|
||||
console.print(f"[red]{message}[/red]") # type: ignore[assignment]
|
||||
|
||||
error = console.print # type: ignore[assignment]
|
||||
success = console.print # type: ignore[assignment]
|
||||
|
||||
from rich.panel import Panel
|
||||
|
||||
@@ -146,18 +138,18 @@ def handle_command(command: str, args: List[str]) -> bool:
|
||||
def _cmd_enter(args: List[str]) -> bool:
|
||||
"""Enter a room -- render entrance panel with mood, flavor, decorations."""
|
||||
if not args:
|
||||
console.print("[red]Usage: commons enter <room>[/red]")
|
||||
error("Usage: commons enter <room>")
|
||||
return True
|
||||
|
||||
room_name = args[0].lower()
|
||||
data = get_room_enter_data(room_name)
|
||||
|
||||
if data.get("error"):
|
||||
console.print(f"[red]{data['error']}[/red]")
|
||||
error(data["error"])
|
||||
return True
|
||||
|
||||
if not data["found"]:
|
||||
console.print(f"[red]Room '{room_name}' not found[/red]")
|
||||
error(f"Room '{room_name}' not found")
|
||||
return True
|
||||
|
||||
room = data["room"]
|
||||
@@ -220,11 +212,11 @@ def _cmd_look(args: List[str]) -> bool:
|
||||
data = get_room_look_data(room_name)
|
||||
|
||||
if data.get("error"):
|
||||
console.print(f"[red]{data['error']}[/red]")
|
||||
error(data["error"])
|
||||
return True
|
||||
|
||||
if not data["found"]:
|
||||
console.print(f"[red]Room '{room_name}' not found[/red]")
|
||||
error(f"Room '{room_name}' not found")
|
||||
return True
|
||||
|
||||
room = data["room"]
|
||||
@@ -294,11 +286,11 @@ def _cmd_decorate(args: List[str]) -> bool:
|
||||
|
||||
if result["success"]:
|
||||
console.print()
|
||||
console.print(f"[green]Placed '{result['display_name']}' in r/{room_name}[/green]")
|
||||
success(f"Placed '{result['display_name']}' in r/{room_name}")
|
||||
console.print(f" [dim]{description}[/dim]")
|
||||
console.print()
|
||||
else:
|
||||
console.print("[red]Failed to place decoration[/red]")
|
||||
error("Failed to place decoration")
|
||||
|
||||
return True
|
||||
|
||||
@@ -311,18 +303,18 @@ def _cmd_decorate(args: List[str]) -> bool:
|
||||
def _cmd_visitors(args: List[str]) -> bool:
|
||||
"""Show recent visitors in a room (last 48h)."""
|
||||
if not args:
|
||||
console.print("[red]Usage: commons visitors <room>[/red]")
|
||||
error("Usage: commons visitors <room>")
|
||||
return True
|
||||
|
||||
room_name = args[0].lower()
|
||||
data = get_visitors_data(room_name)
|
||||
|
||||
if data.get("error"):
|
||||
console.print(f"[red]{data['error']}[/red]")
|
||||
error(data["error"])
|
||||
return True
|
||||
|
||||
if not data["found"]:
|
||||
console.print(f"[red]Room '{room_name}' not found[/red]")
|
||||
error(f"Room '{room_name}' not found")
|
||||
return True
|
||||
|
||||
visitors = data["visitors"]
|
||||
|
||||
@@ -20,12 +20,14 @@ from typing import List
|
||||
from aipass.prax.apps.modules.logger import system_logger as logger
|
||||
|
||||
try:
|
||||
from aipass.cli.apps.modules import console
|
||||
from aipass.cli.apps.modules import console, error, warning
|
||||
except ImportError:
|
||||
logger.warning("[trade] CLI console unavailable, using fallback")
|
||||
from rich.console import Console
|
||||
|
||||
console = Console()
|
||||
error = console.print # type: ignore[assignment]
|
||||
warning = console.print # type: ignore[assignment]
|
||||
|
||||
from rich.panel import Panel
|
||||
|
||||
@@ -97,7 +99,7 @@ def handle_command(command: str, args: List[str]) -> bool:
|
||||
def _handle_gift(args: List[str]) -> bool:
|
||||
result = gift_artifact(args)
|
||||
if not result["success"]:
|
||||
console.print(f"[red]{result['error']}[/red]")
|
||||
error(result["error"])
|
||||
return True
|
||||
|
||||
rarity_color = RARITY_COLORS.get(result["rarity"], "white")
|
||||
@@ -119,7 +121,7 @@ def _handle_gift(args: List[str]) -> bool:
|
||||
def _handle_trade(args: List[str]) -> bool:
|
||||
result = trade_artifact(args)
|
||||
if not result["success"]:
|
||||
console.print(f"[red]{result['error']}[/red]")
|
||||
error(result["error"])
|
||||
return True
|
||||
|
||||
yours = result["your_artifact"]
|
||||
@@ -147,7 +149,7 @@ def _handle_trade(args: List[str]) -> bool:
|
||||
def _handle_drop(args: List[str]) -> bool:
|
||||
result = drop_item(args)
|
||||
if not result["success"]:
|
||||
console.print(f"[red]{result['error']}[/red]")
|
||||
error(result["error"])
|
||||
return True
|
||||
|
||||
console.print()
|
||||
@@ -170,7 +172,7 @@ def _handle_drop(args: List[str]) -> bool:
|
||||
def _handle_find(args: List[str]) -> bool:
|
||||
result = find_item(args)
|
||||
if not result["success"]:
|
||||
console.print(f"[red]{result['error']}[/red]")
|
||||
error(result["error"])
|
||||
return True
|
||||
|
||||
rarity_color = RARITY_COLORS.get(result["rarity"], "white")
|
||||
@@ -194,11 +196,11 @@ def _handle_find(args: List[str]) -> bool:
|
||||
def _handle_mint(args: List[str]) -> bool:
|
||||
result = mint_event_artifact(args)
|
||||
if not result["success"]:
|
||||
console.print(f"[red]{result['error']}[/red]")
|
||||
error(result["error"])
|
||||
return True
|
||||
|
||||
for warning in result.get("warnings", []):
|
||||
console.print(f"[yellow]Warning: {warning}[/yellow]")
|
||||
for warn_msg in result.get("warnings", []):
|
||||
warning(f"Warning: {warn_msg}")
|
||||
|
||||
minted = result["minted"]
|
||||
lines = [f"[bold]Event:[/bold] {result['event_name']}\n"]
|
||||
|
||||
@@ -20,12 +20,13 @@ from typing import List
|
||||
from aipass.prax.apps.modules.logger import system_logger as logger
|
||||
|
||||
try:
|
||||
from aipass.cli.apps.modules import console
|
||||
from aipass.cli.apps.modules import console, error
|
||||
except ImportError:
|
||||
logger.warning("[welcome] CLI console unavailable, using fallback")
|
||||
from rich.console import Console
|
||||
|
||||
console = Console()
|
||||
error = console.print # type: ignore[assignment]
|
||||
|
||||
from aipass.commons.apps.handlers.welcome.welcome_ops import run_welcome
|
||||
from aipass.commons.apps.handlers.json import json_handler
|
||||
@@ -75,7 +76,7 @@ def _handle_welcome(args: List[str]) -> bool:
|
||||
result = run_welcome(args)
|
||||
|
||||
if not result["success"]:
|
||||
console.print(f"[red]{result['error']}[/red]")
|
||||
error(result["error"])
|
||||
return True
|
||||
|
||||
if result.get("dry_run"):
|
||||
|
||||
@@ -110,7 +110,7 @@ def print_introspection(modules: List[Any]):
|
||||
console.print(" [dim]No modules discovered[/dim]")
|
||||
|
||||
console.print()
|
||||
console.print("[dim]Run 'daemon --help' for usage information[/dim]")
|
||||
console.print("[dim]Run 'drone @daemon --help' for usage information[/dim]")
|
||||
console.print()
|
||||
|
||||
|
||||
@@ -132,8 +132,8 @@ def print_help(modules: List[Any]):
|
||||
|
||||
console.print("[bold cyan]USAGE:[/bold cyan]")
|
||||
console.print()
|
||||
console.print(" [dim]daemon <command> [args...][/dim]")
|
||||
console.print(" [dim]daemon --help[/dim]")
|
||||
console.print(" [dim]drone @daemon <command> [args...][/dim]")
|
||||
console.print(" [dim]drone @daemon --help[/dim]")
|
||||
console.print()
|
||||
console.print("-" * 70)
|
||||
console.print()
|
||||
@@ -200,6 +200,14 @@ def main():
|
||||
command = args[0]
|
||||
remaining_args = args[1:] if len(args) > 1 else []
|
||||
|
||||
# Subcommand --help guard — intercept before dispatch
|
||||
if remaining_args and remaining_args[0] in ["--help", "-h"]:
|
||||
for module in modules:
|
||||
if module.handle_command(command, ["--help"]):
|
||||
return 0
|
||||
print_help(modules)
|
||||
return 0
|
||||
|
||||
json_handler.log_operation("daemon_command", {"command": command})
|
||||
|
||||
# Route to modules
|
||||
@@ -208,7 +216,7 @@ def main():
|
||||
return 0
|
||||
else:
|
||||
console.print()
|
||||
error(f"Unknown command: {command}", suggestion="Run 'daemon --help' for available commands")
|
||||
error(f"Unknown command: {command}", suggestion="Run 'drone @daemon --help' for available commands")
|
||||
console.print()
|
||||
return 1
|
||||
|
||||
|
||||
@@ -68,7 +68,7 @@ def _run_systemctl(*args: str) -> bool:
|
||||
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)}")
|
||||
error(f"FAIL: systemctl --user {' '.join(args)}")
|
||||
if result.stderr.strip():
|
||||
console.print(f" [dim]{result.stderr.strip()}[/dim]")
|
||||
return False
|
||||
@@ -79,7 +79,7 @@ def _run_systemctl(*args: str) -> bool:
|
||||
return False
|
||||
except subprocess.TimeoutExpired:
|
||||
logger.error("[timer_install] systemctl --user %s timed out", " ".join(args))
|
||||
console.print(" [red]systemctl timed out[/red]")
|
||||
error("systemctl timed out")
|
||||
return False
|
||||
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@ if sys.platform == "win32":
|
||||
|
||||
from aipass.prax import logger
|
||||
|
||||
from aipass.cli.apps.modules import console, error
|
||||
from aipass.cli.apps.modules import console, error, warning
|
||||
from aipass.daemon.apps.handlers.json import json_handler
|
||||
from aipass.daemon.apps.handlers.update.data_loader import (
|
||||
load_inbox,
|
||||
@@ -117,7 +117,7 @@ def _print_digest(inbox_data: Dict[str, Any], local_data: Dict[str, Any]) -> Non
|
||||
console.print(" Recently completed: [dim]None[/dim]")
|
||||
console.print()
|
||||
|
||||
console.print("[bold red]ESCALATIONS NEEDED[/bold red]")
|
||||
warning("ESCALATIONS NEEDED")
|
||||
escalations = get_escalations(messages)
|
||||
if escalations:
|
||||
for msg in escalations:
|
||||
|
||||
@@ -79,9 +79,14 @@ drone @flow create . "Subject" aplan # APLAN (FPLAN/DPLAN
|
||||
drone @flow list open # active plans
|
||||
```
|
||||
|
||||
# Dispatch — in-flight comms
|
||||
|
||||
- **Steer a working agent with `email` (no wake), NOT `dispatch`.** `dispatch` = send **+ wake** (hand NEW work to a sleeping agent). An agent already running is awake, so `drone @ai_mail email @target "Subject" "Msg"` reaches it mid-task via its hook — no re-wake, no interrupt. Forgot something / need to correct a brief / add context → **email it in-flight**, don't re-dispatch. (`drone @ai_mail --help`)
|
||||
- **No backticks in dispatch/email body strings** — bash runs `` `word` `` as command-substitution and silently eats it. Use single quotes or plain text (hit this live: a backtick'd word vanished from a brief).
|
||||
|
||||
# Watchdog
|
||||
|
||||
Devpulse module. After dispatch, arm as a background task — it polls the dispatch lock and exits when the agent finishes. Resolves @target → branch path → `.ai_mail.local/.dispatch.lock`. Default timeout 1800s; `drone @devpulse watchdog --help` for the full reference.
|
||||
Devpulse module. After dispatch, arm as a background task — it polls the dispatch lock and exits when the agent finishes. Resolves @target → branch path → `.ai_mail.local/.dispatch.lock`. Default timeout **600s** — pass `--timeout <s>` for longer builds (verified live S300; `drone @devpulse watchdog --help` for the full reference).
|
||||
|
||||
```
|
||||
drone @ai_mail dispatch @target "Subject" "Body"
|
||||
|
||||
@@ -5,6 +5,21 @@
|
||||
"description": "Standards bypass configuration for this branch"
|
||||
},
|
||||
"bypass": [
|
||||
{
|
||||
"standard": "json_structure",
|
||||
"file": "devpulse_json/compass",
|
||||
"reason": "compass/ is the devpulse-owned Compass decision store (SQLite/FTS5 — db + wal + shm) which needs its own directory. Legitimate data subdir, not operator-config (custom_config/) nor auto-gen root data. Sanctioned exception per #643."
|
||||
},
|
||||
{
|
||||
"standard": "handlers",
|
||||
"file": "apps/handlers/owner/guard.py",
|
||||
"reason": "Lazy-imports is_owner/get_owner from spawn.apps.handlers.registry inside _owner_decision() — is_owner is the frozen shared owner-capability contract (#191, TDPLAN-0012); spawn is its sole home, no modules re-export. Same authorized cross-branch primitive pattern as ai_mail dispatch_monitor. Import failure falls back to the legacy heuristic. #681."
|
||||
},
|
||||
{
|
||||
"standard": "encapsulation",
|
||||
"file": "apps/handlers/owner/guard.py",
|
||||
"reason": "Lazy cross-branch import of the is_owner/get_owner resolver from spawn.apps.handlers.registry — the frozen owner-capability contract consumed identically by hooks + ai_mail. No spawn modules-level re-export exists; this is the sanctioned entry point. #681."
|
||||
},
|
||||
{
|
||||
"standard": "architecture",
|
||||
"reason": "No 'manager' citizen_class template in spawn. Devpulse is the only manager branch."
|
||||
@@ -45,6 +60,16 @@
|
||||
"file": "tests/test_git_gate.py",
|
||||
"reason": "Test imports git_gate.py from ~/.claude/hooks/ via importlib — external hook, not a branch module."
|
||||
},
|
||||
{
|
||||
"standard": "encapsulation",
|
||||
"file": "tests/test_feedback_module.py",
|
||||
"reason": "Router test imports the feedback storage handler directly to arrange/assert inbox state (save_inbox/load_inbox) — standard test-fixture access, same pattern as test_feedback_storage.py / test_compass_store.py. #681."
|
||||
},
|
||||
{
|
||||
"standard": "encapsulation",
|
||||
"file": "tests/test_owner_guard.py",
|
||||
"reason": "Unit test imports the owner guard handler (its SUT) and patches spawn.registry's get_owner/is_owner — the guard is a shared handler primitive with no apps/modules/ command entry point. Same direct-SUT pattern as test_compass_store.py. #681."
|
||||
},
|
||||
{
|
||||
"standard": "encapsulation",
|
||||
"file": "tools/spot_check.py",
|
||||
|
||||
@@ -44,7 +44,7 @@ src/aipass/devpulse/
|
||||
│ │ └── watchdog/ # Agent, timer, schedule, registry
|
||||
│ └── plugins/ # Plugin extension point
|
||||
├── devpulse_json/ # JSON handler storage (config, data, logs per module)
|
||||
├── tests/ # 282 tests
|
||||
├── tests/ # 309 tests
|
||||
├── artifacts/ # Birth certificate, reports
|
||||
├── dropbox/ # Received files, archived plans, install audit
|
||||
├── docs/ # Transition notes
|
||||
@@ -55,11 +55,38 @@ src/aipass/devpulse/
|
||||
|
||||
All commands via `drone @devpulse <command>`:
|
||||
|
||||
### Watchdog — directed wake system
|
||||
### Watchdog — directed wake system (owner-only)
|
||||
|
||||
**Who may call it:** the project OWNER only — the first agent, seated as `owner: true`
|
||||
in the project's sealed `*_REGISTRY.json`. Portable: `@devpulse` in AIPass, `@vera` in
|
||||
Vera Studio, whoever owns elsewhere. A refusal means your project's owner isn't seated —
|
||||
run `aipass doctor` to see why and `aipass doctor --fix` to repair (DPLAN-0239).
|
||||
|
||||
**How the wake works (read this once, save a debugging session):**
|
||||
|
||||
1. `drone @ai_mail dispatch @target "Subject" "Body"` — hand off the work.
|
||||
2. **Immediately arm the watchdog via the harness Monitor TOOL** — never Bash
|
||||
`run_in_background` (its output goes nowhere and cannot wake you):
|
||||
`drone @devpulse watchdog agent @target --timeout 600`
|
||||
3. The status line shows **"1 monitor"** the moment it's armed — that IS the
|
||||
active-dispatch indicator. When `@target` finishes, the watchdog exits, the
|
||||
Monitor completes, and **your session is re-invoked with the result — that IS
|
||||
the wake.**
|
||||
|
||||
There is no passive wake: ai_mail's wake-back spawns a new headless process and can
|
||||
never inject into a live interactive session (`BLOCKED — interactive session` in the
|
||||
logs is that guard working as designed; it only serves senders whose session closed).
|
||||
If you dispatched and idle without arming, nothing will ever wake you.
|
||||
|
||||
`@target` resolves in the **caller's own project** (then falls back to scanning
|
||||
`~/Projects` registries) — external-project owners monitor their own agents with it.
|
||||
Default timeout is **600 s**; pass `--timeout <s>` for longer builds. Mid-watch it
|
||||
also emits `[watchdog.stall]` / `[watchdog.resumed]` events (no JSONL activity 120 s
|
||||
with no in-flight tool = probable stuck agent).
|
||||
|
||||
| Command | What it does |
|
||||
|---|---|
|
||||
| `watchdog agent @target` | Monitor dispatched agent until it finishes |
|
||||
| `watchdog agent @target [--timeout s]` | Wake when the dispatched agent exits (default 600 s) |
|
||||
| `watchdog timer <duration>` | Wake after duration (5m, 30s, 2h, 1h30m) |
|
||||
| `watchdog timer start/stop <name>` | Named duration tracking |
|
||||
| `watchdog schedule <HH:MM>` | Wait until a specific time |
|
||||
@@ -67,7 +94,14 @@ All commands via `drone @devpulse <command>`:
|
||||
| `watchdog cancel <id>` | Cancel a running watchdog |
|
||||
| `watchdog list` | List all watchdog entries |
|
||||
|
||||
### Feedback — personal cross-branch mailbox
|
||||
### Feedback — the owner-to-owner channel (owner-only)
|
||||
|
||||
ai_mail and dispatch stop at the project boundary — **cross-project comms is
|
||||
impossible by design, except feedback.** Project owners (managers) talk owner-to-owner
|
||||
through it: an external project's owner runs `drone @devpulse feedback send ...` from
|
||||
their project and it lands in devpulse's feedback mailbox; devpulse answers with
|
||||
`feedback reply`. Same owner gate as watchdog — unseated projects are refused until
|
||||
`aipass doctor --fix` seats them.
|
||||
|
||||
| Command | What it does |
|
||||
|---|---|
|
||||
@@ -75,7 +109,7 @@ All commands via `drone @devpulse <command>`:
|
||||
| `feedback inbox` | List all messages |
|
||||
| `feedback view <id>` | Read a message |
|
||||
| `feedback reply <id> "msg"` | Reply to sender |
|
||||
| `feedback send "subject" "body"` | Receive feedback from another agent |
|
||||
| `feedback send "subject" "body"` | Send feedback to devpulse (any project's owner may call) |
|
||||
|
||||
### Compass — rated decision store
|
||||
|
||||
@@ -122,7 +156,7 @@ drone @git log # Recent commits
|
||||
|
||||
All branches via dispatch orchestration. Watchdog monitoring for any dispatched agent. Feedback channel for cross-branch communication. Git operations (commit, PR, merge) for the entire project.
|
||||
|
||||
*Last Updated: 2026-06-23*
|
||||
*Last Updated: 2026-07-11*
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -35,7 +35,7 @@ if sys.platform == "win32":
|
||||
_reconfigure(encoding="utf-8", errors="replace")
|
||||
|
||||
from aipass.prax import logger
|
||||
from aipass.cli.apps.modules import err_console
|
||||
from aipass.cli.apps.modules import err_console, resolve_exit, reset_command_state
|
||||
|
||||
console = err_console
|
||||
|
||||
@@ -161,13 +161,14 @@ def _handle_command(command: str, args: list) -> bool:
|
||||
|
||||
def main():
|
||||
"""Main entry point - routes commands or shows help."""
|
||||
reset_command_state()
|
||||
args = sys.argv[1:]
|
||||
|
||||
if len(args) == 0:
|
||||
print_introspection()
|
||||
return 0
|
||||
|
||||
return 0 if _handle_command(args[0], args[1:]) else 1
|
||||
return resolve_exit(_handle_command(args[0], args[1:]))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -25,7 +25,7 @@ from aipass.devpulse.apps.handlers.feedback.storage import (
|
||||
generate_id,
|
||||
)
|
||||
|
||||
from aipass.cli.apps.modules import err_console
|
||||
from aipass.cli.apps.modules import err_console, error
|
||||
from aipass.devpulse.apps.handlers.json import json_handler
|
||||
|
||||
console = err_console
|
||||
@@ -133,7 +133,7 @@ def reply_to(msg_id: str, body: str) -> bool:
|
||||
break
|
||||
|
||||
if msg is None:
|
||||
console.print(f"[red]Message {msg_id} not found.[/red]")
|
||||
error(f"Message {msg_id} not found.")
|
||||
return False
|
||||
|
||||
now = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S")
|
||||
|
||||
@@ -17,7 +17,7 @@ from rich.table import Table
|
||||
|
||||
from aipass.devpulse.apps.handlers.feedback.storage import load_inbox, save_inbox
|
||||
|
||||
from aipass.cli.apps.modules import err_console
|
||||
from aipass.cli.apps.modules import err_console, error
|
||||
from aipass.devpulse.apps.handlers.json import json_handler
|
||||
|
||||
console = err_console
|
||||
@@ -67,7 +67,7 @@ def view_message(msg_id: str) -> None:
|
||||
|
||||
msg = _find_message(messages, msg_id)
|
||||
if msg is None:
|
||||
console.print(f"[red]Message {msg_id} not found.[/red]")
|
||||
error(f"Message {msg_id} not found.")
|
||||
return
|
||||
|
||||
# Mark as read
|
||||
@@ -105,7 +105,7 @@ def clear_message(msg_id: str) -> None:
|
||||
|
||||
msg = _find_message(messages, msg_id)
|
||||
if msg is None:
|
||||
console.print(f"[red]Message {msg_id} not found.[/red]")
|
||||
error(f"Message {msg_id} not found.")
|
||||
return
|
||||
|
||||
was_unread = not msg.get("read")
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
# =================== AIPass ====================
|
||||
# Name: __init__.py
|
||||
# Description: Owner-capability guard handlers package
|
||||
# Version: 1.0.0
|
||||
# Created: 2026-07-10
|
||||
# Modified: 2026-07-10
|
||||
# =============================================
|
||||
@@ -0,0 +1,115 @@
|
||||
# =================== AIPass ====================
|
||||
# Name: guard.py
|
||||
# Description: Owner-capability caller guard for devpulse owner-only tools
|
||||
# Version: 1.0.0
|
||||
# Created: 2026-07-10
|
||||
# Modified: 2026-07-10
|
||||
# =============================================
|
||||
|
||||
"""
|
||||
Owner-capability guard for devpulse's owner-only tools.
|
||||
|
||||
watchdog and feedback's mailbox-management verbs are the project OWNER's
|
||||
tools. The catch: drone runs a routed module with ``cwd=<branch_path>`` (see
|
||||
drone router_handler), so the module's own ``Path.cwd()`` is ALWAYS the
|
||||
devpulse tree and can't identify who called. The real caller lives in the env
|
||||
drone sets — ``AIPASS_CALLER_BRANCH`` / ``AIPASS_CALLER_CWD``. This resolves
|
||||
that caller and checks it against the sealed-registry owner via ``is_owner``.
|
||||
|
||||
Portable by construction: ``is_owner`` reads each project's OWN sealed
|
||||
registry, so "owner" is devpulse in AIPass and whoever owns elsewhere (e.g.
|
||||
@vera in Vera Studio) — no hardcoded name, no per-project scaffolding.
|
||||
|
||||
Fail-safe: if the owner resolver is unavailable (import fails, or a project
|
||||
has no sealed owner yet — an old/partial install), it falls back to the legacy
|
||||
devpulse-path heuristic so existing installs never hard-break. Concretely #681.
|
||||
|
||||
Returns a plain bool — the calling MODULE owns user-facing output (handlers
|
||||
don't print). Denials are audit-logged here.
|
||||
"""
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from aipass.prax import logger
|
||||
from aipass.devpulse.apps.handlers.json import json_handler
|
||||
|
||||
|
||||
def _resolve_caller() -> tuple[str, Path]:
|
||||
"""Resolve ``(caller_email, caller_cwd)`` from the env drone sets.
|
||||
|
||||
``caller_email`` is ``@<branch>`` — a branch's address is ``@`` + its
|
||||
directory name (matches ai_mail's identity resolution). Prefers the
|
||||
``AIPASS_CALLER_BRANCH`` env var; otherwise walks up ``AIPASS_CALLER_CWD``
|
||||
for a ``.trinity/passport.json`` and uses that directory's name. Falls back
|
||||
to the process cwd when no caller env is set (direct, non-drone invocation).
|
||||
|
||||
Returns:
|
||||
tuple: (caller_email_or_empty, caller_cwd_path)
|
||||
"""
|
||||
caller_cwd_env = os.environ.get("AIPASS_CALLER_CWD", "")
|
||||
caller_cwd = Path(caller_cwd_env) if caller_cwd_env else Path.cwd()
|
||||
|
||||
branch = os.environ.get("AIPASS_CALLER_BRANCH", "")
|
||||
if not branch:
|
||||
for candidate in [caller_cwd, *caller_cwd.parents]:
|
||||
if (candidate / ".trinity" / "passport.json").exists():
|
||||
branch = candidate.name
|
||||
break
|
||||
|
||||
email = f"@{branch.lstrip('@').lower()}" if branch else ""
|
||||
return email, caller_cwd
|
||||
|
||||
|
||||
def _legacy_devpulse_heuristic(caller_cwd: Path) -> bool:
|
||||
"""Pre-owner behavior: allow only a caller standing in the devpulse tree.
|
||||
|
||||
Used solely as the fail-safe when the owner resolver can't decide, so
|
||||
existing AIPass installs keep working before the sealed owner is present.
|
||||
"""
|
||||
return caller_cwd.name == "devpulse" or any(p.name == "devpulse" for p in caller_cwd.parents)
|
||||
|
||||
|
||||
def _owner_decision(email: str, caller_cwd: Path) -> bool:
|
||||
"""Decide whether ``email`` is the owner of the project at ``caller_cwd``.
|
||||
|
||||
Owner check runs against the caller's OWN project registry (start_path =
|
||||
caller_cwd), so cross-project calls resolve the caller's owner, not
|
||||
AIPass's. Falls back to the legacy heuristic when the resolver is
|
||||
unavailable or the project has no sealed owner yet.
|
||||
"""
|
||||
try:
|
||||
# is_owner is the frozen shared owner-capability contract (spawn is its
|
||||
# sole home; no modules re-export). Lazy import keeps cold start fast and
|
||||
# enables the fail-safe fallback below.
|
||||
from aipass.spawn.apps.handlers.registry import get_owner, is_owner
|
||||
except ImportError as exc:
|
||||
logger.warning("[owner_guard] owner resolver unavailable (%s) — legacy heuristic", exc)
|
||||
return _legacy_devpulse_heuristic(caller_cwd)
|
||||
|
||||
if get_owner(start_path=caller_cwd) is None:
|
||||
# No sealed owner in this project -> resolver can't decide -> legacy path check.
|
||||
logger.info("[owner_guard] no sealed owner at %s — legacy heuristic", caller_cwd)
|
||||
return _legacy_devpulse_heuristic(caller_cwd)
|
||||
|
||||
return bool(email) and is_owner(email, start_path=caller_cwd)
|
||||
|
||||
|
||||
def guard_owner_caller(tool: str) -> bool:
|
||||
"""Gate an owner-only tool.
|
||||
|
||||
Args:
|
||||
tool: Name of the calling tool (e.g. 'watchdog', 'feedback') for the
|
||||
audit line. The caller is responsible for any user-facing message.
|
||||
|
||||
Returns:
|
||||
bool: True to allow the call; False (after audit-logging the denial) to
|
||||
reject a non-owner caller.
|
||||
"""
|
||||
email, caller_cwd = _resolve_caller()
|
||||
if _owner_decision(email, caller_cwd):
|
||||
return True
|
||||
|
||||
json_handler.log_operation("owner_guard_denied", {"tool": tool, "caller": email or "unknown"})
|
||||
logger.info("[owner_guard] %s denied non-owner caller=%s", tool, email or "unknown")
|
||||
return False
|
||||
@@ -1,9 +1,9 @@
|
||||
# =================== AIPass ====================
|
||||
# Name: agent.py
|
||||
# Description: Watchdog Agent Handler — block until dispatched agent exits
|
||||
# Version: 1.0.0
|
||||
# Version: 1.1.0
|
||||
# Created: 2026-04-14
|
||||
# Modified: 2026-04-14
|
||||
# Modified: 2026-07-10
|
||||
# =============================================
|
||||
|
||||
# Signal choice: ai_mail dispatch lock file polling.
|
||||
@@ -44,6 +44,19 @@ def _stderr(msg: str) -> None:
|
||||
sys.stderr.flush()
|
||||
|
||||
|
||||
def _stdout_event(msg: str) -> None:
|
||||
"""Write one flushed event line to stdout so a Monitor-tool wrapper turns it
|
||||
into a live notification to devpulse.
|
||||
|
||||
The Monitor tool treats each stdout line as an event but only captures stderr
|
||||
to a file (never surfaced). So mid-watch signals a caller must ACT on — a stall
|
||||
or a possibly-hung tool — go here, while the verbose debug trail stays on
|
||||
_stderr + logger. (#634 part 2.)
|
||||
"""
|
||||
sys.stdout.write(msg + "\n")
|
||||
sys.stdout.flush()
|
||||
|
||||
|
||||
def _find_repo_root(start: Path | None = None) -> Path | None:
|
||||
"""Walk upward looking for AIPASS_REGISTRY.json. Returns None if not found."""
|
||||
cur = (start or Path.cwd()).resolve()
|
||||
@@ -231,6 +244,87 @@ def _has_jsonl_activity(projects_dir: Path, baseline: dict) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def _newest_jsonl(projects_dir: Path) -> Path | None:
|
||||
"""Return the most-recently-modified .jsonl in projects_dir, or None."""
|
||||
if not projects_dir.exists():
|
||||
return None
|
||||
try:
|
||||
files = list(projects_dir.glob("*.jsonl"))
|
||||
except OSError as exc:
|
||||
logger.info("[watchdog.agent] newest jsonl glob failed: %s", exc)
|
||||
return None
|
||||
newest: Path | None = None
|
||||
newest_mtime = -1.0
|
||||
for f in files:
|
||||
try:
|
||||
mtime = f.stat().st_mtime
|
||||
except OSError as exc:
|
||||
logger.info("[watchdog.agent] newest jsonl stat failed for %s: %s", f.name, exc)
|
||||
continue
|
||||
if mtime > newest_mtime:
|
||||
newest_mtime = mtime
|
||||
newest = f
|
||||
return newest
|
||||
|
||||
|
||||
def _tail_last_line(path: Path, max_bytes: int = 1_000_000) -> str | None:
|
||||
"""Read the last non-blank newline-delimited line of a file via a bounded tail
|
||||
read. Reads at most ``max_bytes`` from the end so a multi-MB transcript stays
|
||||
cheap; a single line longer than that decodes partially and simply fails to
|
||||
parse downstream (→ treated as no in-flight tool)."""
|
||||
try:
|
||||
size = path.stat().st_size
|
||||
with path.open("rb") as fh:
|
||||
if size > max_bytes:
|
||||
fh.seek(size - max_bytes)
|
||||
chunk = fh.read()
|
||||
except OSError as exc:
|
||||
logger.info("[watchdog.agent] tail read failed for %s: %s", path.name, exc)
|
||||
return None
|
||||
if not chunk:
|
||||
return None
|
||||
text = chunk.decode("utf-8", errors="replace")
|
||||
lines = [ln for ln in text.splitlines() if ln.strip()]
|
||||
return lines[-1] if lines else None
|
||||
|
||||
|
||||
def _last_entry_is_inflight_tool(projects_dir: Path) -> bool:
|
||||
"""True if the newest JSONL's last event is an assistant message dispatching a
|
||||
tool call — an in-flight ``tool_use`` awaiting its result.
|
||||
|
||||
While a tool runs (a big Read, a long Bash, heavy compute) the agent writes NO
|
||||
new JSONL lines, so size-growth alone misreads that span as idle and false-fires
|
||||
STALLED (#634 part 1). The last line being an assistant ``tool_use`` is the
|
||||
precise signal that the agent is actively working, not stuck.
|
||||
|
||||
Best-effort: any read/parse/shape surprise returns False, degrading to the
|
||||
size-based liveness check so the stall detector never crashes on a format drift.
|
||||
"""
|
||||
newest = _newest_jsonl(projects_dir)
|
||||
if newest is None:
|
||||
return False
|
||||
last_line = _tail_last_line(newest)
|
||||
if not last_line:
|
||||
return False
|
||||
try:
|
||||
entry = json.loads(last_line)
|
||||
except (json.JSONDecodeError, ValueError) as exc:
|
||||
logger.info("[watchdog.agent] last jsonl entry unparseable in %s: %s", newest.name, exc)
|
||||
return False
|
||||
if not isinstance(entry, dict):
|
||||
return False
|
||||
# Schemas vary: role/content may sit under "message" or at the top level.
|
||||
message = entry.get("message")
|
||||
if not isinstance(message, dict):
|
||||
message = entry
|
||||
if message.get("role") != "assistant":
|
||||
return False
|
||||
content = message.get("content")
|
||||
if not isinstance(content, list):
|
||||
return False
|
||||
return any(isinstance(block, dict) and block.get("type") == "tool_use" for block in content)
|
||||
|
||||
|
||||
def _read_lock(lock_file: Path) -> dict | None:
|
||||
"""Read lock file, return dict or None on miss/error."""
|
||||
if not lock_file.exists():
|
||||
@@ -316,6 +410,98 @@ def _classify_exit(
|
||||
return ("completed_silent", reason, 0)
|
||||
|
||||
|
||||
class StallTracker:
|
||||
"""Per-watch JSONL liveness/stall state — one ``observe()`` call per poll tick.
|
||||
|
||||
Liveness signal = new JSONL lines (size growth) OR an in-flight tool call. A
|
||||
long single tool call (big Read, long Bash, heavy compute) writes no new JSONL
|
||||
lines while it runs but leaves an assistant ``tool_use`` as the last entry, so
|
||||
it counts as activity instead of false-firing STALLED (#634 part 1).
|
||||
|
||||
Actionable signals — stall, long-running tool, resumed — go to stdout via
|
||||
``_stdout_event`` so a Monitor-tool wrapper surfaces them to devpulse live
|
||||
(#634 part 2). The verbose trail stays on ``_stderr`` + logger.
|
||||
"""
|
||||
|
||||
STALL_THRESHOLD = 120.0
|
||||
# A single tool call held in-flight this long is surfaced as a soft advisory
|
||||
# (heavy op or a hung tool). Below the 600s default timeout so long watches
|
||||
# get a mid-flight heads-up instead of waiting on the timeout.
|
||||
LONG_TOOL_THRESHOLD = 300.0
|
||||
|
||||
def __init__(self, agent_id: str, jsonl_dir: Path, baseline: dict, now: float, pid: object) -> None:
|
||||
self.agent_id = agent_id
|
||||
self.jsonl_dir = jsonl_dir
|
||||
self.baseline = baseline
|
||||
self.pid = pid
|
||||
self.last_activity_at = now
|
||||
self.in_flight_since: float | None = None
|
||||
self.stall_reported = False
|
||||
self.long_tool_reported = False
|
||||
|
||||
def observe(self, now: float) -> None:
|
||||
"""Evaluate one poll tick: reset on activity, else report a stall past threshold."""
|
||||
size_grew = _has_jsonl_activity(self.jsonl_dir, self.baseline)
|
||||
inflight = False if size_grew else _last_entry_is_inflight_tool(self.jsonl_dir)
|
||||
if size_grew or inflight:
|
||||
self._mark_active(now, size_grew, inflight)
|
||||
elif not self.stall_reported and (now - self.last_activity_at) >= self.STALL_THRESHOLD:
|
||||
self._report_stall(now)
|
||||
|
||||
def _mark_active(self, now: float, size_grew: bool, inflight: bool) -> None:
|
||||
if size_grew:
|
||||
self.baseline = _snapshot_jsonl_sizes(self.jsonl_dir)
|
||||
self.last_activity_at = now
|
||||
if self.stall_reported:
|
||||
_stdout_event(f"[watchdog.resumed] {self.agent_id}: JSONL activity resumed — stall cleared")
|
||||
_stderr(f"[watchdog.agent] {self.agent_id}: activity resumed")
|
||||
logger.info("[watchdog.agent] activity resumed agent_id=%s", self.agent_id)
|
||||
self.stall_reported = False
|
||||
if inflight:
|
||||
self._track_inflight(now)
|
||||
else:
|
||||
self.in_flight_since = None
|
||||
self.long_tool_reported = False
|
||||
|
||||
def _track_inflight(self, now: float) -> None:
|
||||
if self.in_flight_since is None:
|
||||
self.in_flight_since = now
|
||||
inflight_secs = int(now - self.in_flight_since)
|
||||
if self.long_tool_reported or inflight_secs < self.LONG_TOOL_THRESHOLD:
|
||||
return
|
||||
_stdout_event(
|
||||
f"[watchdog.longtool] {self.agent_id}: one tool call running {inflight_secs}s "
|
||||
f"(PID {self.pid} alive) — likely a heavy op, but may be a hung tool. "
|
||||
f"Check, or kill+resume if stuck."
|
||||
)
|
||||
logger.info(
|
||||
"[watchdog.agent] long-running tool agent_id=%s inflight=%ss pid=%s",
|
||||
self.agent_id,
|
||||
inflight_secs,
|
||||
self.pid,
|
||||
)
|
||||
self.long_tool_reported = True
|
||||
|
||||
def _report_stall(self, now: float) -> None:
|
||||
idle_secs = int(now - self.last_activity_at)
|
||||
_stdout_event(
|
||||
f"[watchdog.stall] {self.agent_id}: STALLED — no JSONL activity for {idle_secs}s "
|
||||
f"(PID {self.pid} alive, no in-flight tool call). Agent may be stuck — "
|
||||
f"check, or kill+resume."
|
||||
)
|
||||
_stderr(
|
||||
f"[watchdog.agent] {self.agent_id}: STALLED — no JSONL activity for {idle_secs}s "
|
||||
f"(PID {self.pid} still alive)"
|
||||
)
|
||||
logger.info(
|
||||
"[watchdog.agent] stall detected agent_id=%s idle=%ss pid=%s",
|
||||
self.agent_id,
|
||||
idle_secs,
|
||||
self.pid,
|
||||
)
|
||||
self.stall_reported = True
|
||||
|
||||
|
||||
def watch_agent(
|
||||
agent_id: str,
|
||||
timeout_seconds: int = 600,
|
||||
@@ -383,10 +569,7 @@ def watch_agent(
|
||||
_stderr(f"[watchdog.agent] {agent_id}: lock present, monitor PID={initial_pid}")
|
||||
|
||||
jsonl_dir = _get_jsonl_projects_dir(branch_path)
|
||||
jsonl_baseline = _snapshot_jsonl_sizes(jsonl_dir)
|
||||
last_activity_at = time.monotonic()
|
||||
stall_reported = False
|
||||
stall_threshold = 120.0
|
||||
tracker = StallTracker(agent_id, jsonl_dir, _snapshot_jsonl_sizes(jsonl_dir), time.monotonic(), initial_pid)
|
||||
|
||||
while True:
|
||||
elapsed = time.monotonic() - started_at
|
||||
@@ -441,26 +624,7 @@ def watch_agent(
|
||||
"handle": handle,
|
||||
}
|
||||
|
||||
if _has_jsonl_activity(jsonl_dir, jsonl_baseline):
|
||||
jsonl_baseline = _snapshot_jsonl_sizes(jsonl_dir)
|
||||
last_activity_at = time.monotonic()
|
||||
if stall_reported:
|
||||
_stderr(f"[watchdog.agent] {agent_id}: activity resumed")
|
||||
logger.info("[watchdog.agent] activity resumed agent_id=%s", agent_id)
|
||||
stall_reported = False
|
||||
elif not stall_reported and (time.monotonic() - last_activity_at) >= stall_threshold:
|
||||
idle_secs = int(time.monotonic() - last_activity_at)
|
||||
_stderr(
|
||||
f"[watchdog.agent] {agent_id}: STALLED — no JSONL activity for {idle_secs}s "
|
||||
f"(PID {initial_pid} still alive)"
|
||||
)
|
||||
logger.info(
|
||||
"[watchdog.agent] stall detected agent_id=%s idle=%ss pid=%s",
|
||||
agent_id,
|
||||
idle_secs,
|
||||
initial_pid,
|
||||
)
|
||||
stall_reported = True
|
||||
tracker.observe(time.monotonic())
|
||||
|
||||
time.sleep(poll_interval)
|
||||
finally:
|
||||
|
||||
@@ -173,10 +173,45 @@ def _is_zombie_linux(pid: int) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def _pid_alive_windows(pid: int) -> bool:
|
||||
"""Windows-safe liveness via OpenProcess + GetExitCodeProcess (mirrors agent.py)."""
|
||||
import ctypes
|
||||
from ctypes import wintypes
|
||||
|
||||
PROCESS_QUERY_LIMITED_INFORMATION = 0x1000
|
||||
STILL_ACTIVE = 259
|
||||
|
||||
kernel32 = ctypes.windll.kernel32 # type: ignore[attr-defined] # Windows-only
|
||||
kernel32.OpenProcess.argtypes = [wintypes.DWORD, wintypes.BOOL, wintypes.DWORD]
|
||||
kernel32.OpenProcess.restype = wintypes.HANDLE
|
||||
kernel32.GetExitCodeProcess.argtypes = [wintypes.HANDLE, ctypes.POINTER(wintypes.DWORD)]
|
||||
kernel32.GetExitCodeProcess.restype = wintypes.BOOL
|
||||
kernel32.CloseHandle.argtypes = [wintypes.HANDLE]
|
||||
kernel32.CloseHandle.restype = wintypes.BOOL
|
||||
|
||||
handle = kernel32.OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, False, pid)
|
||||
if not handle:
|
||||
return False
|
||||
try:
|
||||
exit_code = wintypes.DWORD()
|
||||
if not kernel32.GetExitCodeProcess(handle, ctypes.byref(exit_code)):
|
||||
return False
|
||||
return exit_code.value == STILL_ACTIVE
|
||||
finally:
|
||||
kernel32.CloseHandle(handle)
|
||||
|
||||
|
||||
def is_pid_alive(pid: int) -> bool:
|
||||
"""Return True if the process exists and is not a zombie."""
|
||||
"""Return True if the process exists and is not a zombie. Windows-safe."""
|
||||
if not isinstance(pid, int) or pid <= 0:
|
||||
return False
|
||||
if sys.platform == "win32":
|
||||
# os.kill(pid, 0) TERMINATES the target on Windows — use OpenProcess.
|
||||
try:
|
||||
return _pid_alive_windows(pid)
|
||||
except Exception as exc:
|
||||
logger.info("[watchdog.registry] PID %s Windows check failed (assuming alive): %s", pid, exc)
|
||||
return True
|
||||
try:
|
||||
os.kill(pid, 0)
|
||||
except ProcessLookupError as exc:
|
||||
|
||||
@@ -27,7 +27,7 @@ from aipass.devpulse.apps.handlers.feedback.compose import (
|
||||
)
|
||||
|
||||
from aipass.prax import logger
|
||||
from aipass.cli.apps.modules import err_console
|
||||
from aipass.cli.apps.modules import err_console, error, warning
|
||||
from aipass.devpulse.apps.handlers.json import json_handler
|
||||
|
||||
console = err_console
|
||||
@@ -47,6 +47,21 @@ HELP_TEXT = """\
|
||||
"""
|
||||
|
||||
|
||||
def _guard_caller() -> bool:
|
||||
"""Owner-only gate for mailbox reads/management (see handlers.owner.guard).
|
||||
|
||||
The mailbox belongs to the project owner. `send` and `--help` stay open
|
||||
(send is the inbound channel any agent uses to drop feedback here); every
|
||||
other verb reads or mutates the owner's mail and is owner-gated. #681.
|
||||
"""
|
||||
from aipass.devpulse.apps.handlers.owner.guard import guard_owner_caller
|
||||
|
||||
if guard_owner_caller("feedback"):
|
||||
return True
|
||||
warning("feedback mailbox management is owner-only — refusing non-owner call")
|
||||
return False
|
||||
|
||||
|
||||
def print_introspection() -> None:
|
||||
"""Display module introspection info."""
|
||||
console.print()
|
||||
@@ -73,6 +88,20 @@ def handle_command(command: str, args: list[str]) -> bool:
|
||||
if command != "feedback":
|
||||
return False
|
||||
|
||||
# Open verbs (no owner gate): help + `send`. `send` is the inbound channel
|
||||
# any agent uses to drop feedback into the owner's mailbox. Everything else
|
||||
# reads or manages that mailbox -> owner-only (#681).
|
||||
if args and args[0] in ("--help", "-h", "help"):
|
||||
console.print(HELP_TEXT)
|
||||
return True
|
||||
|
||||
if args and args[0] == "send":
|
||||
json_handler.log_operation("feedback_command", {"subcommand": "send"})
|
||||
return _handle_send(args[1:])
|
||||
|
||||
if not _guard_caller():
|
||||
return True
|
||||
|
||||
if not args:
|
||||
print_introspection()
|
||||
summary = get_summary()
|
||||
@@ -83,36 +112,29 @@ def handle_command(command: str, args: list[str]) -> bool:
|
||||
sub_args = args[1:]
|
||||
json_handler.log_operation("feedback_command", {"subcommand": subcommand})
|
||||
|
||||
if subcommand in ("--help", "-h", "help"):
|
||||
console.print(HELP_TEXT)
|
||||
return True
|
||||
|
||||
if subcommand == "inbox":
|
||||
list_messages()
|
||||
return True
|
||||
|
||||
if subcommand == "view":
|
||||
if not sub_args:
|
||||
console.print("[red]Usage: feedback view <id>[/red]")
|
||||
error("Usage: feedback view <id>")
|
||||
return True
|
||||
view_message(sub_args[0])
|
||||
return True
|
||||
|
||||
if subcommand == "reply":
|
||||
if len(sub_args) < 2:
|
||||
console.print('[red]Usage: feedback reply <id> "message"[/red]')
|
||||
error('Usage: feedback reply <id> "message"')
|
||||
return True
|
||||
msg_id = sub_args[0]
|
||||
body = " ".join(sub_args[1:])
|
||||
reply_to(msg_id, body)
|
||||
return True
|
||||
|
||||
if subcommand == "send":
|
||||
return _handle_send(sub_args)
|
||||
|
||||
if subcommand == "clear":
|
||||
if not sub_args:
|
||||
logger.error("Usage: feedback clear <id> | feedback clear --all")
|
||||
error("Usage: feedback clear <id> | feedback clear --all")
|
||||
return True
|
||||
if sub_args[0] == "--all":
|
||||
clear_all_read()
|
||||
@@ -120,8 +142,8 @@ def handle_command(command: str, args: list[str]) -> bool:
|
||||
clear_message(sub_args[0])
|
||||
return True
|
||||
|
||||
console.print(f"[red]Unknown feedback subcommand: {subcommand}[/red]")
|
||||
console.print("Use [bold]feedback --help[/bold] for usage.")
|
||||
logger.warning("[feedback] unknown subcommand: %s", subcommand)
|
||||
error(f"Unknown feedback subcommand: {subcommand}", suggestion="Use 'feedback --help' for usage")
|
||||
return True
|
||||
|
||||
|
||||
@@ -138,7 +160,7 @@ def _handle_send(args: list[str]) -> bool:
|
||||
bool: Always True (command was handled).
|
||||
"""
|
||||
if len(args) < 2:
|
||||
console.print('[red]Usage: feedback send "subject" "body"[/red]')
|
||||
error('Usage: feedback send "subject" "body"')
|
||||
console.print("[dim]Tip: from_branch is auto-detected or pass as first arg.[/dim]")
|
||||
return True
|
||||
|
||||
|
||||
@@ -25,11 +25,10 @@ See FPLAN-0186 for the build plan and DPLAN-0130 for the design record.
|
||||
"""
|
||||
|
||||
import importlib
|
||||
from pathlib import Path
|
||||
from typing import List
|
||||
|
||||
from aipass.prax.apps.modules.logger import system_logger as logger
|
||||
from aipass.cli.apps.modules import console, error, warning
|
||||
from aipass.cli.apps.modules import console, err_console, error, warning
|
||||
from aipass.devpulse.apps.handlers.json import json_handler
|
||||
|
||||
_VALID_SUBCOMMANDS = ["agent", "timer", "schedule", "status", "cancel", "list"]
|
||||
@@ -113,11 +112,18 @@ def print_introspection() -> None:
|
||||
|
||||
|
||||
def _guard_caller() -> bool:
|
||||
"""Reject cross-branch invocation. Devpulse-only tool."""
|
||||
cwd = Path.cwd()
|
||||
if cwd.name == "devpulse" or any(p.name == "devpulse" for p in cwd.parents):
|
||||
"""Reject non-owner invocation. Owner-only tool.
|
||||
|
||||
Gates on the PROJECT OWNER (sealed registry) via the shared owner guard, so
|
||||
it works across projects — not a hardcoded 'devpulse' name. (Drone runs a
|
||||
routed module with cwd=<branch_path>, so Path.cwd() can't identify the real
|
||||
caller; the guard reads the AIPASS_CALLER_* env drone sets.) #681.
|
||||
"""
|
||||
from aipass.devpulse.apps.handlers.owner.guard import guard_owner_caller
|
||||
|
||||
if guard_owner_caller("watchdog"):
|
||||
return True
|
||||
warning("watchdog is a devpulse-only module — refusing cross-branch call")
|
||||
warning("watchdog is an owner-only module — refusing non-owner call")
|
||||
return False
|
||||
|
||||
|
||||
@@ -325,7 +331,14 @@ def _handle_agent(sub_args: List[str]) -> bool:
|
||||
error("Usage: watchdog agent <branch> [--timeout SECONDS]")
|
||||
return True
|
||||
|
||||
error("WATCHDOG: Must be invoked via Monitor tool, never run_in_background")
|
||||
# Reminder, not an error: this call blocks until the agent exits, so it must
|
||||
# run via the Monitor tool (not run_in_background) for the wake to fire on
|
||||
# completion. MUST go to stderr: the Monitor tool treats every STDOUT line
|
||||
# as a wake event, so a stdout banner fires a spurious wake at arm time —
|
||||
# stdout carries completion/stall events only (#634 contract; VERA feedback
|
||||
# 315c005e). error() is also wrong — ❌ trips the exit-code fail-flag on an
|
||||
# otherwise-successful watch (#661 output_routing).
|
||||
err_console.print("[dim]watchdog agent: invoke via Monitor tool, not run_in_background[/dim]")
|
||||
|
||||
timeout = _DEFAULT_AGENT_TIMEOUT
|
||||
positional: List[str] = []
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
# META
|
||||
# module: devpulse.feedback
|
||||
# description: Tests for feedback module command routing
|
||||
# END META
|
||||
# =================== AIPass ====================
|
||||
# Name: test_feedback_module.py
|
||||
# Description: Tests for feedback module command routing
|
||||
# Version: 1.0.0
|
||||
# Created: 2026-04-11
|
||||
# Modified: 2026-07-10
|
||||
# =============================================
|
||||
|
||||
"""Tests for feedback module — command routing via handle_command()."""
|
||||
|
||||
@@ -13,6 +16,13 @@ from aipass.devpulse.apps.handlers.feedback import storage
|
||||
from aipass.devpulse.apps.modules import feedback as feedback_module
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _bypass_caller_guard():
|
||||
"""Force _guard_caller to pass so routing tests don't depend on owner env."""
|
||||
with patch.object(feedback_module, "_guard_caller", return_value=True):
|
||||
yield
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_feedback_dir(tmp_path):
|
||||
"""Patch FEEDBACK_DIR to use tmp_path for isolation."""
|
||||
@@ -183,6 +193,35 @@ class TestCommandRouting:
|
||||
assert result is True # Handled (shows error + hint)
|
||||
|
||||
|
||||
class TestOwnerGate:
|
||||
"""Owner gate wraps mailbox management; send + help stay open (#681)."""
|
||||
|
||||
def test_management_blocked_for_non_owner(self, populated_inbox):
|
||||
"""A denied guard blocks a management verb — view does not mark read."""
|
||||
with patch.object(feedback_module, "_guard_caller", return_value=False):
|
||||
result = feedback_module.handle_command("feedback", ["view", "aaa11111"])
|
||||
assert result is True # command still "handled" (clean refusal)
|
||||
|
||||
data = storage.load_inbox()
|
||||
msg = next(m for m in data["messages"] if m["id"] == "aaa11111")
|
||||
assert msg["read"] is False # action was gated out
|
||||
|
||||
def test_send_open_for_non_owner(self, empty_inbox):
|
||||
"""send bypasses the owner gate — any agent can drop feedback."""
|
||||
with patch.object(feedback_module, "_guard_caller", return_value=False):
|
||||
result = feedback_module.handle_command("feedback", ["send", "seedgo", "Bug report", "Found an issue"])
|
||||
assert result is True
|
||||
|
||||
data = storage.load_inbox()
|
||||
assert data["total_messages"] == 1 # send bypassed the gate
|
||||
|
||||
def test_help_open_for_non_owner(self, empty_inbox):
|
||||
"""--help bypasses the owner gate."""
|
||||
with patch.object(feedback_module, "_guard_caller", return_value=False):
|
||||
result = feedback_module.handle_command("feedback", ["--help"])
|
||||
assert result is True
|
||||
|
||||
|
||||
class TestHandleCommandHasCorrectSignature:
|
||||
"""Verify handle_command meets auto-discovery requirements."""
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user