Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
002d83da8c | ||
|
|
24abb7bbcc | ||
|
|
f4c696b3dc | ||
|
|
c0d2d77428 | ||
|
|
b3d1a4b552 | ||
|
|
b206832209 | ||
|
|
90048069d0 | ||
|
|
766d697e08 | ||
|
|
d511576fc0 | ||
|
|
380eca813b | ||
|
|
ca096295a3 | ||
|
|
7c9309cf88 | ||
|
|
74a08df800 | ||
|
|
8a606c8c2b | ||
|
|
deffa0c912 | ||
|
|
c244b7bf3f | ||
|
|
84177485ce | ||
|
|
497ab98abc | ||
|
|
2defe9d615 | ||
|
|
7fb65f0255 | ||
|
|
80badb784a | ||
|
|
90296b80f0 | ||
|
|
de45e1cd2f | ||
|
|
f7e2584304 | ||
|
|
bac3528e43 | ||
|
|
329e8015d4 | ||
|
|
dbeb8c3122 | ||
|
|
a54d21033e | ||
|
|
22b4577ec1 | ||
|
|
f72515e7bc | ||
|
|
98d52fa944 | ||
|
|
fdb0086d6c | ||
|
|
2112f458fb | ||
|
|
0886c9013c | ||
|
|
b4e2370ee8 | ||
|
|
c8b7250995 | ||
|
|
d8aa300d6b | ||
|
|
d229ee5df5 | ||
|
|
39d979302c | ||
|
|
a4d00e2068 | ||
|
|
663c801c05 | ||
|
|
d872d7101f | ||
|
|
cac79b4b1a | ||
|
|
4404b60305 | ||
|
|
dfd6732f5b | ||
|
|
948d2ed535 | ||
|
|
f4d067a3cc | ||
|
|
9dab0ca3f4 | ||
|
|
b75a8d272a | ||
|
|
43afe14017 | ||
|
|
01fe4fe6e3 | ||
|
|
4d9e691e04 | ||
|
|
e302df6ec0 | ||
|
|
a3a476f59d | ||
|
|
c970c5761f | ||
|
|
cded2993f5 | ||
|
|
0878afbc81 | ||
|
|
739dada015 | ||
|
|
10a8f738a0 | ||
|
|
550839003e | ||
|
|
874c7fed2e | ||
|
|
ae8f4a843a | ||
|
|
6a757a21f1 | ||
|
|
f5554158f9 | ||
|
|
bc0d403da9 | ||
|
|
26a5f3a2ee | ||
|
|
d2d1adca0a | ||
|
|
1edea552bf | ||
|
|
9a06a2fa47 | ||
|
|
f0fc282630 | ||
|
|
cdbd1dc821 | ||
|
|
5fea5bbf44 | ||
|
|
d0a31fd862 | ||
|
|
08d87d95e9 | ||
|
|
195b9f081c | ||
|
|
a20d191f87 | ||
|
|
5fd8c6a015 | ||
|
|
47b5b2d871 | ||
|
|
f4f6943ed6 | ||
|
|
ce1a138cdf | ||
|
|
cccfe5fb3a | ||
|
|
6200e01522 | ||
|
|
18dea21726 | ||
|
|
2ac499d9a3 | ||
|
|
c8e1f07fdb | ||
|
|
378ac1f98c | ||
|
|
5503b42806 | ||
|
|
4877eb57d2 | ||
|
|
e1fd28970b | ||
|
|
49700670b9 | ||
|
|
e912bda85f | ||
|
|
3071ea8eac | ||
|
|
12e54e45f6 | ||
|
|
4105a7e8a7 | ||
|
|
ba817e03fb | ||
|
|
a108bc8a06 | ||
|
|
8630cd90a3 | ||
|
|
776e53044c | ||
|
|
bf9ef340b6 | ||
|
|
26893fb66a | ||
|
|
5b04c2125c | ||
|
|
f42a98b887 | ||
|
|
708ed38229 | ||
|
|
ea2f7a49a7 | ||
|
|
f83a003a73 |
@@ -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)
|
||||
|
||||
@@ -22,3 +22,11 @@ updates:
|
||||
commit-message:
|
||||
prefix: "ci"
|
||||
include: "scope"
|
||||
# codeql-action is a monorepo (init/analyze/upload-sarif share one release).
|
||||
# Bumping them in separate PRs leaves mismatched versions in security.yml and
|
||||
# CodeQL hard-fails "init and analyze must be the same version". Group them so
|
||||
# every codeql-action bump lands as a single PR that moves all paths together.
|
||||
groups:
|
||||
codeql-action:
|
||||
patterns:
|
||||
- "github/codeql-action*"
|
||||
|
||||
@@ -41,6 +41,6 @@ jobs:
|
||||
retention-days: 5
|
||||
|
||||
- name: "Upload to code-scanning"
|
||||
uses: github/codeql-action/upload-sarif@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
|
||||
uses: github/codeql-action/upload-sarif@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0
|
||||
with:
|
||||
sarif_file: results.sarif
|
||||
|
||||
@@ -42,7 +42,7 @@ jobs:
|
||||
security-events: write
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
- uses: github/codeql-action/init@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
|
||||
- uses: github/codeql-action/init@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0
|
||||
with:
|
||||
languages: python
|
||||
- uses: github/codeql-action/analyze@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
|
||||
- uses: github/codeql-action/analyze@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0
|
||||
|
||||
@@ -14,7 +14,7 @@ jobs:
|
||||
issues: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- uses: actions/stale@eb5cf3af3ac0a1aa4c9c45633dd1ae542a27a899 # v10.3.0
|
||||
- uses: actions/stale@1e223db275d687790206a7acac4d1a11bd6fe629 # v10.4.0
|
||||
with:
|
||||
stale-issue-message: "This issue has been inactive for 30 days. It will be closed in 7 days if no further activity occurs."
|
||||
days-before-stale: 30
|
||||
|
||||
@@ -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/
|
||||
|
||||
|
||||
+820
@@ -9,6 +9,815 @@ 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
|
||||
|
||||
- **HVTrust badge restored in the root README.** hvtracker corrected the
|
||||
methodology v4.1 grade-computation bug (issue #109); the badge shows the
|
||||
right grade again, so the temporary comment-out from earlier today is
|
||||
reverted.
|
||||
|
||||
- **Installer no longer destroys a user's custom Claude Code hooks (DPLAN-0234
|
||||
Strand C).** setup.sh used to write `settings["hooks"]` wholesale — anyone
|
||||
with their own hooks in `~/.claude/settings.json` lost them on install or
|
||||
re-run. Now it merges: every AIPass bridge entry (identified by the
|
||||
`bridges/claude.py` marker) is refreshed, while user-wired hooks and custom
|
||||
events are preserved. Verified against fixtures: custom hooks survive, stale
|
||||
AIPass entries are replaced without duplicates, and the fresh-install output
|
||||
is shape-identical to before (7 events, 6 UserPromptSubmit + 6 PreCompact
|
||||
entries). Found while fire-testing a fresh install's hooks in Docker — all
|
||||
17 wired hook entries pass on a cold Linux clone (real kernel/navmap/branch
|
||||
prompt bytes, git gate blocks, clean no-ops on empty state).
|
||||
|
||||
- **Windows fresh installs get a working hook bridge.** setup.sh wrote the
|
||||
Claude bridge command with `.venv/bin/python3` on every OS — but Windows
|
||||
venvs put the interpreter at `.venv/Scripts/python.exe` and have no `bin/`,
|
||||
so hooks on a fresh Windows install pointed at a nonexistent python and
|
||||
would never fire. The bridge string is now OS-aware (bash passes
|
||||
`IS_WINDOWS` into the hook-install step). @hooks assessed the rest of the
|
||||
chain: `$AIPASS_HOME` expansion works on Windows because Claude Code runs
|
||||
hooks via Git Bash (which must exist for setup.sh to have run), and the
|
||||
bridge itself has zero POSIX assumptions — the interpreter path was the
|
||||
only gap. Verified: both OS modes produce the right bridge string, merge
|
||||
marker unchanged, custom-hook preservation intact. (assessed by @hooks)
|
||||
|
||||
### Added
|
||||
|
||||
- **`./aipass` — repo-root cold-clone launcher (DPLAN-0234 Strand B).** The
|
||||
branded entry point for the clone-first flow: `git clone`, `cd AIPass`,
|
||||
`./aipass install` — three commands to a working AIPass. Stdlib-only bash
|
||||
(zero deps, runs before anything is installed): pre-setup, only the `install`
|
||||
verb exists and delegates to `setup.sh` with full flag pass-through
|
||||
(`--no-init` / `--with-init` / `--project`); any other verb prints help
|
||||
pointing at `./aipass install`. Post-setup the launcher turns transparent —
|
||||
it execs the venv `aipass` binary for everything, so `./aipass doctor` just
|
||||
works. Bare `aipass` always resolves to the PATH binary; the launcher only
|
||||
ever runs as an explicit `./aipass`. 13 launcher tests (file properties,
|
||||
pre-setup help, install delegation, post-setup forwarding), @aipass suite
|
||||
622 green, seedgo 100%. README Quick Start now leads with `./aipass install`.
|
||||
(built by @aipass)
|
||||
|
||||
- **`./setup.sh` chains into `aipass init run` — clone-first one-command install
|
||||
(DPLAN-0234 Strand A).** Distribution is git-clone, not pip: the framework
|
||||
changes constantly, so a PyPI snapshot goes stale while a clone is always
|
||||
current HEAD. Now `git clone && cd AIPass && ./setup.sh` takes you from cold
|
||||
clone to a working first project in one command: on interactive terminals,
|
||||
setup ends by launching the guided `aipass init run` in a sibling directory
|
||||
(default `~/aipass-project`, prompt to choose — init refuses to run inside the
|
||||
engine tree). New flags mirror `aipass install`'s handoff rules: `--no-init`
|
||||
skips, `--with-init` forces even headless (init chains `--non-interactive`),
|
||||
`--project <dir>` picks the target. CI and piped shells skip automatically
|
||||
(`CI` env or no tty), so the windows/macos-test workflows that run bare
|
||||
`bash setup.sh` are untouched. `install.py` now calls `setup.sh --no-init`
|
||||
since install owns its own init handoff — no double-scaffold. Proven in
|
||||
clean-room Docker, both runs exit 0: bare headless run skips init with a
|
||||
hint; `--with-init` run chains init to `✓ Project initialized.` with the
|
||||
project dir scaffolded. 39/39 install tests, seedgo 30/30. README Quick
|
||||
Start updated to the one-command flow.
|
||||
|
||||
- **`aipass install` — one-command framework bootstrap.** The missing half of
|
||||
`pip install aipass`: a single command resolves the install home (default
|
||||
`~/AIPass`), git-clones the public repo, runs `setup.sh` (venv, editable
|
||||
install, hook wiring), verifies the toolchain, and auto-launches `aipass init`
|
||||
in the same terminal — so `pip install aipass && aipass install` bootstraps
|
||||
the whole system with nobody the wiser. New auto-discovered `install.py`
|
||||
module (zero shared-code edits) with flags `--non-interactive / --path /
|
||||
--here / --no-init / --with-init / --project / --dry-run`; 39 unit tests,
|
||||
seedgo 30/30, @aipass suite green. Proven in a clean-room Docker image
|
||||
(nothing pre-baked) across two runs, both exit 0: `pip install` (local wheel)
|
||||
→ clone → `setup.sh` (17 branches registered, 13 bootstrapped, hooks wired
|
||||
into `~/.claude/settings.json`, `AIPASS_HOME` set, `drone`/`aipass` on PATH) →
|
||||
live `drone systems`, and `--with-init` chaining straight into `aipass init`
|
||||
to completion. Ships install progress bars, an install→init handoff, and
|
||||
doctor coverage. (built by @aipass, DPLAN-0233 — PyPI release bump pending)
|
||||
|
||||
### Changed
|
||||
|
||||
- **HVTrust badge temporarily hidden in the root README.** hvtracker's
|
||||
methodology v4.1 recalibration is miscomputing the grade (showing D/~10 while
|
||||
the detail-page dimensions sum to ~78); the badge is commented out until it's
|
||||
corrected. Filed upstream as hvtracker issue #109 — restore when resolved.
|
||||
|
||||
- **devpulse branch prompt — sole-git-writer clarity.** Added a note to the git
|
||||
section: because no other agent can commit, merge, or push anywhere, dirty
|
||||
cross-branch files are always someone's live WIP, safe to leave and pick up
|
||||
later — never a loose end needing handoff.
|
||||
|
||||
## [2026-07-03]
|
||||
|
||||
Post-2.6.1 cycle — **unreleased** (held for a later merge).
|
||||
|
||||
### Changed
|
||||
|
||||
- **All 17 branches now pass the standards audit at 100% — Windows-compat
|
||||
hardening across the board.** Added `sys.stdout/stderr.reconfigure()` UTF-8
|
||||
guards (getattr form) to every Rich/CLI entry point, and platform-branched
|
||||
POSIX-only subprocess kwargs (`start_new_session` → `CREATE_NEW_PROCESS_GROUP`
|
||||
on win32). The seedgo `windows_compat` checker now credits the getattr guard
|
||||
form (not just direct `.reconfigure()` calls), with a locking regression test.
|
||||
Swept per-branch via dispatch; checker fix by @seedgo. Verified by a full
|
||||
17/17 audit (pyright clean).
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Telegram replies no longer overwrite the previous message.** The Stop-hook
|
||||
out-path (`hooks/.../notification/telegram_response.py`) reused a stale
|
||||
`processing_message_id`: after a successful delivery, `_advance_pending` kept
|
||||
the pending file but never cleared the placeholder id, so any reply that fired
|
||||
without a fresh "Processing…" bubble (remote/mirror input, multi-Stop turns)
|
||||
re-*edited* the same Telegram message instead of posting a new one — every
|
||||
response clobbered the last. Now clears `processing_message_id` after the first
|
||||
delivery, so subsequent Stops fall through to `_send_with_retry` (a new
|
||||
message). Root-caused live on the devpulse bot and proven by the delivery log
|
||||
flipping `edit`→`send`; +2 regression tests in `TestAdvancePending` (114/114).
|
||||
(fixed by @hooks, `f42a98b`, PR #651 — not yet merged)
|
||||
|
||||
- **`template` audit checker no longer false-flags documentation *about* templates.**
|
||||
The advisory stale-template checker (`seedgo/.../template_check.py`) matched its
|
||||
marker strings anywhere in a file, so it fired on prose and code that merely
|
||||
*mention* the markers rather than on un-rendered stubs — flagging 5 branches,
|
||||
only 3 of them real. Three root causes, all fixed:
|
||||
(1) it globbed **`.trinity/*.json`**, scanning live memory (`local.json`,
|
||||
`observations.json`) that naturally accumulates marker mentions (seedgo's own
|
||||
note "Detects NEEDS CONFIGURATION", prax's note about `template_pusher` restoring
|
||||
`{{BRANCHNAME}}`); now scans **`passport.json` only**, the sole spawn-templated
|
||||
trinity file.
|
||||
(2) the single-curly `{…}` regex ran on every `.md` and matched inline JSON /
|
||||
f-strings / code paths in READMEs (`{"new": 3}`, `{e}`, `apps/plugins/{name}/`);
|
||||
now runs on the branch **prompt only** (the spawn README template has no
|
||||
single-curly placeholders).
|
||||
(3) the definitive-marker scan matched `{{BRANCH}}` inside markdown inline code —
|
||||
e.g. spawn's README documenting ``Replace `{{BRANCH}}`…``, which is scaffolding
|
||||
docs, not a stub. For `.md` files, fenced + inline code is now stripped once up
|
||||
front before **both** scans (`passport.json` still scans raw). Safe because real
|
||||
stubs carry markers in prose/headings (the `## Status: NEEDS CONFIGURATION`
|
||||
line), never exclusively in code.
|
||||
Verified system-wide: `Template` avg **80% → 94%**, the two pure false positives
|
||||
(seedgo memory, spawn README) cleared to `100%`, only the three genuine
|
||||
unconfigured prompt stubs (cli/drone/prax) still flag. +7 tests (24/24), full
|
||||
suite green. (fixed by @seedgo across 3 dispatched passes, verified by @devpulse)
|
||||
|
||||
- **cli / drone / prax branch prompts configured** (were spawn stubs). The three
|
||||
branches the template checker correctly flagged had never had their
|
||||
`.aipass/aipass_local_prompt.md` filled in — they booted with a `NEEDS
|
||||
CONFIGURATION` placeholder and no branch-specific identity. Each branch wrote its
|
||||
own real prompt (identity, key commands, architecture, critical rules,
|
||||
integration points; ~63–67 lines, `PROMPT_STYLE.md` format); all three now score
|
||||
`Template 100%`. (written by @cli/@drone/@prax, dispatched + verified by @devpulse)
|
||||
|
||||
## [2026-07-02]
|
||||
|
||||
Released as **2.6.1**. Rolls up the DPLAN-0226 / FPLAN-0289 / TDPLAN-0010 /
|
||||
@@ -17,6 +826,17 @@ gate, live Telegram streaming, `aipass init` template selector + portability,
|
||||
`@backup share`) — all documented under `[2026-07-01]` — plus the CI
|
||||
stabilization below.
|
||||
|
||||
### Added
|
||||
|
||||
- **`drone @git tag <vX.Y.Z>` — guarded release-tag automation (post-2.6.1).**
|
||||
Devpulse-tier verb that pushes a release tag with no manual step: fetches
|
||||
`origin`, refuses unless the tag's `X.Y.Z` matches **both** `pyproject.toml`
|
||||
and `src/aipass/__init__.py` on `origin/main` (version guard) and the tag
|
||||
doesn't already exist (exists guard), then tags `origin/main` and pushes —
|
||||
firing `publish.yml`. `drone @git tag --list` lists tags. Removes the merge
|
||||
playbook's last manual `git tag`/`push` step, so releases need zero user
|
||||
input. (built by @drone, S274)
|
||||
|
||||
### Fixed
|
||||
|
||||
- **CI green — six regressions from the DPLAN-0226 / FPLAN-0289 / TDPLAN-0010
|
||||
|
||||
+1
-1
@@ -15,7 +15,7 @@ PRs are welcome after an issue has been discussed. Keep changes focused -- one f
|
||||
- Python 3.10+
|
||||
- Tests: `pytest` (4,900+ tests across the project)
|
||||
- Quality: AIPass uses its own standards system ([seedgo](src/aipass/seedgo/README.md)) for automated audits
|
||||
- Run `./setup.sh` to bootstrap the full environment
|
||||
- Run `./aipass install --no-init` to bootstrap the full environment (contributors work in the engine repo itself — no first-project scaffold needed)
|
||||
|
||||
## Questions?
|
||||
|
||||
|
||||
@@ -31,13 +31,11 @@ AIPass is a CLI-native scaffold that adds **persistent memory, identity, and coo
|
||||
|
||||
```bash
|
||||
git clone https://github.com/AIOSAI/AIPass.git
|
||||
cd AIPass && ./setup.sh # installs the `aipass` + `drone` commands on your PATH
|
||||
|
||||
cd ~ && mkdir my-project && cd my-project
|
||||
aipass init run
|
||||
cd AIPass
|
||||
./aipass install # installs everything, then walks you into your first project
|
||||
```
|
||||
|
||||
A guided setup creates your project, your first agent, and opens a terminal where that agent is already running. Say "hi" — it knows who it is. Come back tomorrow — it remembers.
|
||||
One command does it all: builds the environment, puts `aipass` + `drone` on your PATH, then chains straight into a guided init that creates your project, your first agent, and opens a terminal where that agent is already running. Say "hi" — it knows who it is. Come back tomorrow — it remembers.
|
||||
|
||||
This is the base framework. It gives your agents the infrastructure to persist, communicate, and organize — everything else you build on top.
|
||||
|
||||
@@ -83,10 +81,13 @@ aipass init agent my-agent # Full agent: apps, mail, memory, identity
|
||||
|
||||
```bash
|
||||
git clone https://github.com/AIOSAI/AIPass.git
|
||||
cd AIPass && ./setup.sh # Creates venv, installs, puts `aipass` + `drone` on your PATH, bootstraps 17 agents
|
||||
cd AIPass
|
||||
./aipass install # Creates venv, installs, puts `aipass` + `drone` on your PATH, bootstraps 17 agents
|
||||
```
|
||||
|
||||
### 2. Your own project
|
||||
On an interactive terminal, install ends by chaining into `aipass init run` — one command takes you from clone to a working first project. Pass `--no-init` to skip the chain, `--project <dir>` to pick where the project lands (CI and piped shells skip automatically). `./aipass` is a thin repo-root launcher over `setup.sh`; after setup it simply forwards to the installed `aipass` binary.
|
||||
|
||||
### 2. Your own project (if you skipped the chain)
|
||||
|
||||
```bash
|
||||
cd ~ && mkdir my-project && cd my-project
|
||||
@@ -143,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"
|
||||
```
|
||||
@@ -230,7 +231,7 @@ AIPass is built and tested with **Claude Code** on Linux/WSL.
|
||||
| [Claude Code](https://code.claude.com/docs) | `claude -p "prompt" --permission-mode bypassPermissions` | Fully tested |
|
||||
| [Codex](https://github.com/openai/codex) | `codex exec "prompt" --dangerously-bypass-approvals-and-sandbox` | Experimental |
|
||||
|
||||
setup.sh auto-detects which CLIs are installed and configures hooks for each.
|
||||
The installer (`./aipass install`, powered by setup.sh) auto-detects which CLIs are installed and configures hooks for each — merging with any hooks you've already wired, never overwriting them.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
#!/usr/bin/env bash
|
||||
# aipass — cold-clone entry point (pre-venv launcher)
|
||||
#
|
||||
# The first command after cloning:
|
||||
# git clone https://github.com/AIOSAI/AIPass.git
|
||||
# cd AIPass
|
||||
# ./aipass install
|
||||
#
|
||||
# Pre-setup: only `install` is available — delegates to setup.sh.
|
||||
# Post-setup: forwards everything to the venv-installed aipass binary.
|
||||
#
|
||||
# Stdlib-only, zero AIPass imports, zero third-party deps.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
|
||||
# --- Post-setup: forward to the real binary ---
|
||||
if [[ -x "$SCRIPT_DIR/.venv/bin/aipass" ]]; then
|
||||
exec "$SCRIPT_DIR/.venv/bin/aipass" "$@"
|
||||
fi
|
||||
if [[ -x "$SCRIPT_DIR/.venv/Scripts/aipass.exe" ]]; then
|
||||
exec "$SCRIPT_DIR/.venv/Scripts/aipass.exe" "$@"
|
||||
fi
|
||||
if [[ -x "$SCRIPT_DIR/.venv/Scripts/aipass" ]]; then
|
||||
exec "$SCRIPT_DIR/.venv/Scripts/aipass" "$@"
|
||||
fi
|
||||
|
||||
# --- Pre-setup: only 'install' works ---
|
||||
if [[ "${1:-}" == "install" ]]; then
|
||||
shift
|
||||
exec bash "$SCRIPT_DIR/setup.sh" "$@"
|
||||
fi
|
||||
|
||||
# --- Help (no venv, no 'install' verb) ---
|
||||
cat <<'HELP'
|
||||
AIPass is not set up yet.
|
||||
|
||||
./aipass install # set up AIPass (venv + deps + hooks)
|
||||
./aipass install --no-init # set up without the guided first-project setup
|
||||
./aipass install --project DIR # set the first-project directory
|
||||
|
||||
After setup, all aipass commands become available:
|
||||
./aipass doctor # system health
|
||||
./aipass help # how-does-X-work Q&A
|
||||
./aipass init run # scaffold a new project
|
||||
|
||||
Quick start:
|
||||
git clone https://github.com/AIOSAI/AIPass.git
|
||||
cd AIPass
|
||||
./aipass install
|
||||
HELP
|
||||
+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"
|
||||
|
||||
@@ -2,6 +2,15 @@
|
||||
#
|
||||
# AIPass setup script
|
||||
# Creates a venv, installs the package in editable mode, and verifies CLI entry points.
|
||||
# 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>] [--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
|
||||
@@ -27,6 +36,31 @@ case "${OSTYPE:-}" in
|
||||
;;
|
||||
esac
|
||||
|
||||
# --- Args ---
|
||||
# Mirrors the `aipass install` handoff rules: --no-init wins, --with-init forces,
|
||||
# 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
|
||||
INIT_PROJECT="$arg"
|
||||
PREV_ARG=""
|
||||
continue
|
||||
fi
|
||||
case "$arg" in
|
||||
--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
|
||||
|
||||
echo "=== AIPass Setup ==="
|
||||
echo "Repo root: $SCRIPT_DIR"
|
||||
echo ""
|
||||
@@ -190,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.
|
||||
@@ -605,7 +639,7 @@ if [ -f "$SCRIPT_DIR/src/aipass/hooks/apps/handlers/bridges/claude.py" ]; then
|
||||
echo "Installing Claude Code hooks ..."
|
||||
mkdir -p "$HOME/.claude"
|
||||
|
||||
"$PYTHON" - "$SCRIPT_DIR" "$CLAUDE_SETTINGS" << 'PYEOF'
|
||||
"$PYTHON" - "$SCRIPT_DIR" "$CLAUDE_SETTINGS" "$IS_WINDOWS" << 'PYEOF'
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
@@ -613,11 +647,16 @@ from pathlib import Path
|
||||
|
||||
repo_root = sys.argv[1]
|
||||
settings_path = Path(sys.argv[2])
|
||||
is_windows = len(sys.argv) > 3 and sys.argv[3] == "1"
|
||||
|
||||
# Bridge entry point — all hooks route through the engine via this bridge.
|
||||
# Uses $AIPASS_HOME env var (injected into settings.env below) so the
|
||||
# settings file stays relocatable.
|
||||
bridge = "$AIPASS_HOME/.venv/bin/python3 $AIPASS_HOME/src/aipass/hooks/apps/handlers/bridges/claude.py"
|
||||
# settings file stays relocatable. CC on Windows runs hooks via Git Bash
|
||||
# (which must exist for setup.sh to have run), so $VAR expansion works —
|
||||
# but the venv interpreter lives at Scripts/python.exe there, not bin/python3
|
||||
# (@hooks assessment, DPLAN-0234 Strand C).
|
||||
venv_python = ".venv/Scripts/python.exe" if is_windows else ".venv/bin/python3"
|
||||
bridge = f"$AIPASS_HOME/{venv_python} $AIPASS_HOME/src/aipass/hooks/apps/handlers/bridges/claude.py"
|
||||
|
||||
# Load existing settings or start fresh
|
||||
if settings_path.exists():
|
||||
@@ -629,7 +668,8 @@ 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
|
||||
settings["hooks"] = {
|
||||
# SessionStart: cadence reset on startup/clear (handler skips resume itself)
|
||||
aipass_hooks = {
|
||||
"UserPromptSubmit": [
|
||||
{"hooks": [{"type": "command", "command": f"{bridge} UserPromptSubmit:tier0_kernel"}]},
|
||||
{"hooks": [{"type": "command", "command": f"{bridge} UserPromptSubmit:navmap"}]},
|
||||
@@ -663,8 +703,32 @@ settings["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
|
||||
# (identified by the bridges/claude.py marker) but preserve any hooks the user
|
||||
# wired themselves. Re-runs stay idempotent; custom hooks survive reinstall.
|
||||
existing_hooks = settings.get("hooks", {})
|
||||
merged_hooks = {}
|
||||
for event in set(existing_hooks) | set(aipass_hooks):
|
||||
user_entries = [
|
||||
entry for entry in existing_hooks.get(event, [])
|
||||
if "bridges/claude.py" not in json.dumps(entry)
|
||||
]
|
||||
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
|
||||
env_block = settings.get("env", {})
|
||||
env_block["AIPASS_HOME"] = repo_root
|
||||
@@ -740,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.
|
||||
@@ -906,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).
|
||||
@@ -919,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
|
||||
@@ -934,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
|
||||
@@ -951,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
|
||||
@@ -960,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
|
||||
@@ -982,6 +1104,77 @@ if [ "$FAIL" -eq 0 ]; then
|
||||
echo " Claude Code: hooks installed to ~/.claude/settings.json"
|
||||
command -v codex &>/dev/null && echo " Codex CLI: hooks at .codex/hooks.json + config at ~/.codex/config.toml"
|
||||
echo ""
|
||||
|
||||
# --- Chain into first-project init (DPLAN-0234: one command does setup + init) ---
|
||||
# `aipass init` refuses to run inside the engine tree, so it always targets a
|
||||
# sibling directory — never the repo itself. Decision mirrors install.py:
|
||||
# --no-init wins, --with-init forces (headless chains --non-interactive),
|
||||
# default = interactive terminals only (CI and piped shells skip).
|
||||
AIPASS_BIN=""
|
||||
if [ "$IS_WINDOWS" -eq 1 ] && [ -f "$SCRIPT_DIR/.venv/Scripts/aipass.exe" ]; then
|
||||
AIPASS_BIN="$SCRIPT_DIR/.venv/Scripts/aipass.exe"
|
||||
elif [ "$IS_WINDOWS" -eq 1 ] && [ -f "$SCRIPT_DIR/.venv/Scripts/aipass" ]; then
|
||||
AIPASS_BIN="$SCRIPT_DIR/.venv/Scripts/aipass"
|
||||
elif [ -f "$SCRIPT_DIR/.venv/bin/aipass" ]; then
|
||||
AIPASS_BIN="$SCRIPT_DIR/.venv/bin/aipass"
|
||||
elif command -v aipass &>/dev/null; then
|
||||
AIPASS_BIN="$(command -v aipass)"
|
||||
fi
|
||||
|
||||
LAUNCH_INIT=0
|
||||
INIT_HEADLESS=0
|
||||
if [ "$RUN_INIT" = "no" ]; then
|
||||
echo "Skipping first-project init (--no-init). Run 'aipass init run' in a fresh directory when ready."
|
||||
elif [ "$RUN_INIT" = "yes" ]; then
|
||||
LAUNCH_INIT=1
|
||||
if [ ! -t 0 ]; then
|
||||
INIT_HEADLESS=1
|
||||
fi
|
||||
elif [ -t 0 ] && [ -z "${CI:-}" ]; then
|
||||
LAUNCH_INIT=1
|
||||
else
|
||||
echo "Non-interactive shell — skipping first-project init. Run 'aipass init run' in a fresh directory when ready."
|
||||
fi
|
||||
|
||||
if [ "$LAUNCH_INIT" -eq 1 ]; then
|
||||
if [ -z "$AIPASS_BIN" ]; then
|
||||
echo "WARN: aipass binary not found — open a new terminal and run 'aipass init run' in a fresh directory."
|
||||
else
|
||||
DEFAULT_PROJECT_DIR="$HOME/aipass-project"
|
||||
PROJECT_DIR="$INIT_PROJECT"
|
||||
if [ -z "$PROJECT_DIR" ] && [ "$INIT_HEADLESS" -eq 0 ] && [ -t 0 ]; then
|
||||
echo "AIPass projects live in their own directory, never inside the engine repo."
|
||||
read -r -p "Set up your first project now? [Y/n]: " INIT_REPLY
|
||||
case "$INIT_REPLY" in
|
||||
[nN]*)
|
||||
PROJECT_DIR="-"
|
||||
echo " Skipped. Run 'aipass init run' in a fresh directory when ready."
|
||||
;;
|
||||
*)
|
||||
read -r -p " Project directory [$DEFAULT_PROJECT_DIR]: " INIT_INPUT
|
||||
PROJECT_DIR="${INIT_INPUT:-$DEFAULT_PROJECT_DIR}"
|
||||
;;
|
||||
esac
|
||||
elif [ -z "$PROJECT_DIR" ]; then
|
||||
PROJECT_DIR="$DEFAULT_PROJECT_DIR"
|
||||
fi
|
||||
|
||||
if [ "$PROJECT_DIR" != "-" ]; then
|
||||
mkdir -p "$PROJECT_DIR"
|
||||
INIT_CMD=("$AIPASS_BIN" init run)
|
||||
if [ "$INIT_HEADLESS" -eq 1 ]; then
|
||||
INIT_CMD+=(--non-interactive)
|
||||
fi
|
||||
echo ""
|
||||
echo "Launching guided setup in $PROJECT_DIR ..."
|
||||
if (cd "$PROJECT_DIR" && "${INIT_CMD[@]}"); then
|
||||
echo "First project initialized at $PROJECT_DIR"
|
||||
else
|
||||
echo "Init didn't complete — run 'aipass init run' in $PROJECT_DIR when ready."
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
else
|
||||
echo "=== Setup finished with errors ==="
|
||||
echo "The venv was created and the package was installed, but one or more"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -14,12 +14,19 @@ Main handles routing, modules implement functionality.
|
||||
"""
|
||||
|
||||
# Standard library imports
|
||||
import os
|
||||
import sys
|
||||
import importlib
|
||||
import signal
|
||||
from pathlib import Path
|
||||
from typing import Any, List
|
||||
|
||||
# AIPass infrastructure imports
|
||||
from aipass.prax.apps.modules.logger import system_logger as logger
|
||||
|
||||
# CLI services for display
|
||||
from aipass.cli.apps.modules import console, error
|
||||
|
||||
# Handle broken pipe gracefully (e.g. output piped to head)
|
||||
# SIGPIPE does not exist on Windows
|
||||
if hasattr(signal, "SIGPIPE"):
|
||||
@@ -28,11 +35,12 @@ if hasattr(signal, "SIGPIPE"):
|
||||
# Dashboard integration (optional, provided by prax)
|
||||
_UPDATE_SECTION = None # type: ignore
|
||||
|
||||
# AIPass infrastructure imports
|
||||
from aipass.prax.apps.modules.logger import system_logger as logger
|
||||
|
||||
# CLI services for display
|
||||
from aipass.cli.apps.modules import console, error
|
||||
if sys.platform == "win32":
|
||||
os.environ.setdefault("PYTHONUTF8", "1")
|
||||
for _stream in (sys.stdout, sys.stderr):
|
||||
_reconfigure = getattr(_stream, "reconfigure", None)
|
||||
if _reconfigure is not None:
|
||||
_reconfigure(encoding="utf-8", errors="replace")
|
||||
|
||||
# =============================================================================
|
||||
# CONSTANTS & CONFIG
|
||||
@@ -235,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
|
||||
|
||||
@@ -19,6 +19,7 @@ Architecture:
|
||||
"""
|
||||
|
||||
# CRITICAL: Use importlib to bypass local json/ directory and get stdlib json
|
||||
import os
|
||||
import sys
|
||||
import importlib.util
|
||||
|
||||
@@ -32,13 +33,20 @@ stdlib_json = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(stdlib_json)
|
||||
sys.path = _saved_path
|
||||
|
||||
from pathlib import Path
|
||||
from datetime import datetime
|
||||
from typing import Dict, Any, List, Tuple
|
||||
from pathlib import Path # noqa: E402
|
||||
from datetime import datetime # noqa: E402
|
||||
from typing import Dict, Any, List, Tuple # noqa: E402
|
||||
|
||||
from aipass.prax.apps.modules.logger import system_logger as logger
|
||||
from aipass.ai_mail.apps.handlers.json import json_handler
|
||||
from aipass.ai_mail.apps.handlers.paths import find_repo_root
|
||||
from aipass.prax.apps.modules.logger import system_logger as logger # noqa: E402
|
||||
from aipass.ai_mail.apps.handlers.json import json_handler # noqa: E402
|
||||
from aipass.ai_mail.apps.handlers.paths import find_repo_root # noqa: E402
|
||||
|
||||
if sys.platform == "win32":
|
||||
os.environ.setdefault("PYTHONUTF8", "1")
|
||||
for _stream in (sys.stdout, sys.stderr):
|
||||
_reconfigure = getattr(_stream, "reconfigure", None)
|
||||
if _reconfigure is not None:
|
||||
_reconfigure(encoding="utf-8", errors="replace")
|
||||
|
||||
|
||||
# =============================================================================
|
||||
@@ -341,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
|
||||
|
||||
@@ -33,6 +33,7 @@ from aipass.prax.apps.modules.logger import system_logger as logger
|
||||
from aipass.ai_mail.apps.handlers.json import json_handler
|
||||
from aipass.ai_mail.apps.handlers.dispatch.status import log_dispatch
|
||||
from aipass.ai_mail.apps.handlers.paths import find_repo_root
|
||||
from aipass.ai_mail.apps.handlers.dispatch.test_token import scan_and_ack_test_emails
|
||||
|
||||
|
||||
# Infrastructure paths
|
||||
@@ -49,9 +50,6 @@ BRANCH_REGISTRY = _REPO_ROOT / "AIPASS_REGISTRY.json"
|
||||
# Graceful shutdown
|
||||
SHUTDOWN = False
|
||||
|
||||
# AIPASS-TEST token handling extracted to test_token.py
|
||||
from aipass.ai_mail.apps.handlers.dispatch.test_token import scan_and_ack_test_emails
|
||||
|
||||
|
||||
def _handle_signal(signum, _frame):
|
||||
"""Handle shutdown signals for graceful daemon stop."""
|
||||
@@ -88,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"
|
||||
@@ -98,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:
|
||||
@@ -216,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")
|
||||
|
||||
@@ -410,14 +448,19 @@ def spawn_agent(
|
||||
logger.info(f"Lock acquisition failed for {branch_email}: {lock_msg}")
|
||||
return False
|
||||
|
||||
_detach_kwargs: dict = {}
|
||||
if sys.platform == "win32":
|
||||
_detach_kwargs["creationflags"] = subprocess.CREATE_NEW_PROCESS_GROUP
|
||||
else:
|
||||
_detach_kwargs["start_new_session"] = True
|
||||
try:
|
||||
process = subprocess.Popen(
|
||||
monitor_cmd,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
start_new_session=True,
|
||||
cwd=str(branch_path),
|
||||
env=spawn_env,
|
||||
**_detach_kwargs,
|
||||
)
|
||||
|
||||
monitor_pid = process.pid
|
||||
@@ -471,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"
|
||||
|
||||
|
||||
@@ -491,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)
|
||||
|
||||
|
||||
|
||||
@@ -14,13 +14,22 @@ without triggering dispatch. Extracted from daemon.py to keep
|
||||
the daemon under the 700-line architecture threshold.
|
||||
"""
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict
|
||||
|
||||
from aipass.prax.apps.modules.logger import system_logger as logger
|
||||
from aipass.ai_mail.apps.handlers.json import json_handler
|
||||
|
||||
if sys.platform == "win32":
|
||||
os.environ.setdefault("PYTHONUTF8", "1")
|
||||
for _stream in (sys.stdout, sys.stderr):
|
||||
_reconfigure = getattr(_stream, "reconfigure", None)
|
||||
if _reconfigure is not None:
|
||||
_reconfigure(encoding="utf-8", errors="replace")
|
||||
|
||||
TEST_TOKEN = "[AIPASS-TEST — do not update memories, do not execute, reply 'ack' only]"
|
||||
|
||||
|
||||
|
||||
@@ -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):
|
||||
@@ -578,14 +670,19 @@ def wake_branch(
|
||||
|
||||
if not spawned_via_scope:
|
||||
try:
|
||||
_detach_kwargs: dict = {}
|
||||
if sys.platform == "win32":
|
||||
_detach_kwargs["creationflags"] = subprocess.CREATE_NEW_PROCESS_GROUP
|
||||
else:
|
||||
_detach_kwargs["start_new_session"] = True
|
||||
process = subprocess.Popen(
|
||||
monitor_cmd,
|
||||
stdin=subprocess.DEVNULL,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
start_new_session=True,
|
||||
cwd=str(branch_path),
|
||||
env=spawn_env,
|
||||
**_detach_kwargs,
|
||||
)
|
||||
|
||||
monitor_pid = process.pid
|
||||
|
||||
@@ -14,6 +14,8 @@ Solves the BRANCH DETECTION FAILED problem when external projects call drone
|
||||
— contacts lookup works even when CWD-walking cannot identify the caller.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
from datetime import datetime
|
||||
from typing import Dict, Optional
|
||||
|
||||
@@ -21,6 +23,13 @@ from aipass.prax.apps.modules.logger import system_logger as logger
|
||||
from aipass.ai_mail.apps.handlers.json import json_handler
|
||||
from aipass.ai_mail.apps.handlers.paths import find_repo_root
|
||||
|
||||
if sys.platform == "win32":
|
||||
os.environ.setdefault("PYTHONUTF8", "1")
|
||||
for _stream in (sys.stdout, sys.stderr):
|
||||
_reconfigure = getattr(_stream, "reconfigure", None)
|
||||
if _reconfigure is not None:
|
||||
_reconfigure(encoding="utf-8", errors="replace")
|
||||
|
||||
CONTACTS_FILE = find_repo_root() / "src/aipass/ai_mail/.ai_mail.local/contacts.json"
|
||||
|
||||
|
||||
|
||||
@@ -14,6 +14,8 @@ Independent handler - no module dependencies.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from datetime import datetime
|
||||
from typing import Dict, Optional
|
||||
@@ -21,6 +23,13 @@ from typing import Dict, Optional
|
||||
from aipass.prax.apps.modules.logger import system_logger as logger
|
||||
from aipass.ai_mail.apps.handlers.json import json_handler
|
||||
|
||||
if sys.platform == "win32":
|
||||
os.environ.setdefault("PYTHONUTF8", "1")
|
||||
for _stream in (sys.stdout, sys.stderr):
|
||||
_reconfigure = getattr(_stream, "reconfigure", None)
|
||||
if _reconfigure is not None:
|
||||
_reconfigure(encoding="utf-8", errors="replace")
|
||||
|
||||
# Lazy imports
|
||||
_append_footer = None
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ Independent handler - no module dependencies.
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from typing import Dict, Tuple, List, Optional, Callable
|
||||
@@ -24,6 +25,13 @@ from aipass.ai_mail.apps.handlers.json import json_handler
|
||||
from aipass.ai_mail.apps.handlers.paths import find_repo_root
|
||||
from aipass.ai_mail.apps.handlers.registry.read import get_all_branches
|
||||
|
||||
if sys.platform == "win32":
|
||||
os.environ.setdefault("PYTHONUTF8", "1")
|
||||
for _stream in (sys.stdout, sys.stderr):
|
||||
_reconfigure = getattr(_stream, "reconfigure", None)
|
||||
if _reconfigure is not None:
|
||||
_reconfigure(encoding="utf-8", errors="replace")
|
||||
|
||||
|
||||
_REPO_ROOT = find_repo_root()
|
||||
|
||||
|
||||
@@ -14,12 +14,21 @@ Independent handler - no module dependencies.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from typing import Dict, Optional
|
||||
|
||||
from aipass.prax.apps.modules.logger import system_logger as logger
|
||||
from aipass.ai_mail.apps.handlers.json import json_handler
|
||||
from aipass.ai_mail.apps.handlers.paths import find_repo_root
|
||||
|
||||
if sys.platform == "win32":
|
||||
os.environ.setdefault("PYTHONUTF8", "1")
|
||||
for _stream in (sys.stdout, sys.stderr):
|
||||
_reconfigure = getattr(_stream, "reconfigure", None)
|
||||
if _reconfigure is not None:
|
||||
_reconfigure(encoding="utf-8", errors="replace")
|
||||
|
||||
|
||||
REGISTRY_PATH = find_repo_root() / "AIPASS_REGISTRY.json"
|
||||
|
||||
@@ -164,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")
|
||||
|
||||
@@ -16,6 +16,8 @@ v3.0.0: Now uses deleted/ directory with individual JSON files (like sent/).
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from datetime import datetime
|
||||
from typing import Dict, Tuple, Optional, Any
|
||||
@@ -23,6 +25,13 @@ from typing import Dict, Tuple, Optional, Any
|
||||
from aipass.prax.apps.modules.logger import system_logger as logger
|
||||
from aipass.ai_mail.apps.handlers.json import json_handler
|
||||
|
||||
if sys.platform == "win32":
|
||||
os.environ.setdefault("PYTHONUTF8", "1")
|
||||
for _stream in (sys.stdout, sys.stderr):
|
||||
_reconfigure = getattr(_stream, "reconfigure", None)
|
||||
if _reconfigure is not None:
|
||||
_reconfigure(encoding="utf-8", errors="replace")
|
||||
|
||||
|
||||
# Lazy import for inbox file lock
|
||||
_inbox_lock = None
|
||||
|
||||
@@ -14,12 +14,21 @@ Independent handler - no module dependencies.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Dict
|
||||
|
||||
from aipass.prax.apps.modules.logger import system_logger as logger
|
||||
from aipass.ai_mail.apps.handlers.json import json_handler
|
||||
|
||||
if sys.platform == "win32":
|
||||
os.environ.setdefault("PYTHONUTF8", "1")
|
||||
for _stream in (sys.stdout, sys.stderr):
|
||||
_reconfigure = getattr(_stream, "reconfigure", None)
|
||||
if _reconfigure is not None:
|
||||
_reconfigure(encoding="utf-8", errors="replace")
|
||||
|
||||
# Lazy import for inbox file lock
|
||||
_inbox_lock = None
|
||||
|
||||
@@ -125,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")
|
||||
|
||||
@@ -31,6 +31,13 @@ from aipass.prax.apps.modules.logger import system_logger as logger
|
||||
from aipass.ai_mail.apps.handlers.json import json_handler
|
||||
from aipass.ai_mail.apps.handlers.paths import find_repo_root
|
||||
|
||||
if sys.platform == "win32":
|
||||
os.environ.setdefault("PYTHONUTF8", "1")
|
||||
for _stream in (sys.stdout, sys.stderr):
|
||||
_reconfigure = getattr(_stream, "reconfigure", None)
|
||||
if _reconfigure is not None:
|
||||
_reconfigure(encoding="utf-8", errors="replace")
|
||||
|
||||
# Purge configuration
|
||||
MAX_EMAILS = 10
|
||||
|
||||
|
||||
@@ -13,6 +13,8 @@ Handles replying to emails and auto-closing the original.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from typing import Dict, Tuple, Optional
|
||||
@@ -22,6 +24,13 @@ from aipass.prax.apps.modules.logger import system_logger as logger
|
||||
from aipass.ai_mail.apps.handlers.json import json_handler
|
||||
from aipass.ai_mail.apps.handlers.email.delivery import deliver_to_inbox_file
|
||||
|
||||
if sys.platform == "win32":
|
||||
os.environ.setdefault("PYTHONUTF8", "1")
|
||||
for _stream in (sys.stdout, sys.stderr):
|
||||
_reconfigure = getattr(_stream, "reconfigure", None)
|
||||
if _reconfigure is not None:
|
||||
_reconfigure(encoding="utf-8", errors="replace")
|
||||
|
||||
# Services imported in __main__ only (handlers should not display)
|
||||
|
||||
|
||||
|
||||
@@ -13,10 +13,19 @@ Provides repo root discovery used across all handler files.
|
||||
Consolidated from 8 identical copies per DPLAN-0036 audit.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from aipass.ai_mail.apps.handlers.json import json_handler
|
||||
|
||||
if sys.platform == "win32":
|
||||
os.environ.setdefault("PYTHONUTF8", "1")
|
||||
for _stream in (sys.stdout, sys.stderr):
|
||||
_reconfigure = getattr(_stream, "reconfigure", None)
|
||||
if _reconfigure is not None:
|
||||
_reconfigure(encoding="utf-8", errors="replace")
|
||||
|
||||
|
||||
def find_repo_root() -> Path:
|
||||
"""Walk up from this file to find AIPASS_REGISTRY.json (repo root)."""
|
||||
|
||||
@@ -21,6 +21,8 @@ Handler Independence:
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import List, Dict, Optional
|
||||
|
||||
@@ -28,6 +30,13 @@ from aipass.prax.apps.modules.logger import system_logger as logger
|
||||
from aipass.ai_mail.apps.handlers.json import json_handler
|
||||
from aipass.ai_mail.apps.handlers.paths import find_repo_root
|
||||
|
||||
if sys.platform == "win32":
|
||||
os.environ.setdefault("PYTHONUTF8", "1")
|
||||
for _stream in (sys.stdout, sys.stderr):
|
||||
_reconfigure = getattr(_stream, "reconfigure", None)
|
||||
if _reconfigure is not None:
|
||||
_reconfigure(encoding="utf-8", errors="replace")
|
||||
|
||||
|
||||
# Constants
|
||||
MODULE_NAME = "registry.read"
|
||||
|
||||
@@ -17,6 +17,7 @@ Walks up directory tree to find branch root (has .trinity/passport.json).
|
||||
# IMPORTS
|
||||
# =============================================
|
||||
import os
|
||||
import sys
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Dict, Optional
|
||||
@@ -25,6 +26,13 @@ from aipass.prax.apps.modules.logger import system_logger as logger
|
||||
from aipass.ai_mail.apps.handlers.json import json_handler
|
||||
from aipass.ai_mail.apps.handlers.paths import find_repo_root
|
||||
|
||||
if sys.platform == "win32":
|
||||
os.environ.setdefault("PYTHONUTF8", "1")
|
||||
for _stream in (sys.stdout, sys.stderr):
|
||||
_reconfigure = getattr(_stream, "reconfigure", None)
|
||||
if _reconfigure is not None:
|
||||
_reconfigure(encoding="utf-8", errors="replace")
|
||||
|
||||
# =============================================
|
||||
# CONSTANTS
|
||||
# =============================================
|
||||
@@ -297,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
|
||||
@@ -24,6 +23,13 @@ from aipass.cli.apps.modules import console, error
|
||||
from aipass.ai_mail.apps.handlers.json import json_handler
|
||||
from aipass.ai_mail.apps.handlers.dispatch.status import load_dispatch_log, check_pid_status, calculate_age
|
||||
|
||||
if sys.platform == "win32":
|
||||
os.environ.setdefault("PYTHONUTF8", "1")
|
||||
for _stream in (sys.stdout, sys.stderr):
|
||||
_reconfigure = getattr(_stream, "reconfigure", None)
|
||||
if _reconfigure is not None:
|
||||
_reconfigure(encoding="utf-8", errors="replace")
|
||||
|
||||
|
||||
def print_help() -> None:
|
||||
"""Print help for dispatch commands."""
|
||||
@@ -41,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
|
||||
@@ -219,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 = []
|
||||
@@ -234,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):
|
||||
@@ -342,57 +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", "")
|
||||
|
||||
try:
|
||||
subprocess.Popen(
|
||||
cmd,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
start_new_session=True,
|
||||
cwd=str(devpulse_dir),
|
||||
env=spawn_env,
|
||||
)
|
||||
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")
|
||||
|
||||
@@ -19,6 +19,7 @@ Module Pattern:
|
||||
- NO business logic in this file
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import List
|
||||
@@ -42,6 +43,13 @@ from aipass.ai_mail.apps.handlers.email.inbox_resolve import resolve_inbox_targe
|
||||
from aipass.ai_mail.apps.handlers.paths import find_repo_root
|
||||
from aipass.ai_mail.apps.modules.email_send import handle_send
|
||||
|
||||
if sys.platform == "win32":
|
||||
os.environ.setdefault("PYTHONUTF8", "1")
|
||||
for _stream in (sys.stdout, sys.stderr):
|
||||
_reconfigure = getattr(_stream, "reconfigure", None)
|
||||
if _reconfigure is not None:
|
||||
_reconfigure(encoding="utf-8", errors="replace")
|
||||
|
||||
_AI_MAIL_DIR = Path(__file__).resolve().parents[2]
|
||||
_REPO_ROOT = find_repo_root()
|
||||
|
||||
|
||||
@@ -14,6 +14,8 @@ broadcast, and dispatch trigger. Extracted from email.py to keep modules
|
||||
under the size threshold.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import List
|
||||
|
||||
@@ -37,6 +39,13 @@ from aipass.ai_mail.apps.handlers.email.error_dispatch import dispatch_send_erro
|
||||
from aipass.ai_mail.apps.handlers.email.send_args import parse_send_args, resolve_dispatch_target
|
||||
from aipass.ai_mail.apps.handlers.paths import find_repo_root
|
||||
|
||||
if sys.platform == "win32":
|
||||
os.environ.setdefault("PYTHONUTF8", "1")
|
||||
for _stream in (sys.stdout, sys.stderr):
|
||||
_reconfigure = getattr(_stream, "reconfigure", None)
|
||||
if _reconfigure is not None:
|
||||
_reconfigure(encoding="utf-8", errors="replace")
|
||||
|
||||
_AI_MAIL_DIR = Path(__file__).resolve().parents[2]
|
||||
_REPO_ROOT = find_repo_root()
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -969,168 +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"]
|
||||
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
|
||||
@@ -1138,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(
|
||||
@@ -1165,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
|
||||
@@ -1185,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")),
|
||||
@@ -1210,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,7 +18,7 @@ Not suggestions. Violating = bug.
|
||||
|
||||
## What I Do
|
||||
|
||||
- Guide new users through `aipass init` (12 stages: welcome, system detect, doctor, profile, style questions, tool choice, docker offer, first agent, ping sweep, smoke test, handoff, done)
|
||||
- Guide new users through `aipass init` (10 stages: welcome, system detect, profile, style questions, tool choice, first agent, ping sweep, smoke test, handoff, done)
|
||||
- Answer "how does X work?" via `aipass help` — live README reads, offer depth, route branch experts
|
||||
- Run `aipass doctor` — aggregate seedgo, pytest, registry, hooks, git state, AIPASS_HOME
|
||||
- Remember user — name, OS, preferred CLI, setup progress `.trinity/local.json`
|
||||
@@ -30,7 +30,7 @@ Not suggestions. Violating = bug.
|
||||
aipass # Help banner with all commands
|
||||
aipass help [q] # Chatbot Q&A over branch READMEs
|
||||
aipass doctor # System health aggregation
|
||||
aipass init # 12-stage guided setup for new users, resumable
|
||||
aipass init # 10-stage guided setup for new users, resumable
|
||||
aipass profile # Show/edit what I know about the user
|
||||
aipass --version
|
||||
```
|
||||
@@ -53,7 +53,7 @@ apps/
|
||||
├── modules/
|
||||
│ ├── doctor.py # System health aggregation
|
||||
│ ├── help_chat.py # README-backed Q&A
|
||||
│ ├── init_flow.py # 12-stage guided setup, resumable
|
||||
│ ├── init_flow.py # 10-stage guided setup, resumable
|
||||
│ ├── handoff.py # CLI handoff (tmux / wt.exe)
|
||||
│ └── profile.py # User profile read/write
|
||||
└── handlers/
|
||||
@@ -79,6 +79,6 @@ apps/
|
||||
|
||||
## Known Gotchas
|
||||
|
||||
- **Status: under construction.** Whole branch gitignored. Do not PR anything this directory until Phase 8 reveal (DPLAN-0136).
|
||||
- **Status: under construction (DPLAN-0136).** Don't PR / reveal this branch until Phase 8 — that's a *policy*, NOT a gitignore. Only the usual runtime/memory layer is ignored (`.trinity/`, plan files, `*.local`, logs) same as every branch; my code (init_flow, cross_os, tests, README) IS trackable. Committed-or-not = git's call, devpulse's lane.
|
||||
- **`aipass` binary currently `cli` branch's `aipass init`** — project bootstrap, not citizen creation. Eventually this CLI entry moves here. Until then, use `drone @spawn create` citizen creation.
|
||||
- **Test-convention tokens need buy-in.** Core agents don't yet recognize `[AIPASS-TEST — ...]`. Coordinating @ai_mail before pinging anyone.
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
"metadata": {
|
||||
"version": "2.0.0",
|
||||
"created": "2026-04-16",
|
||||
"description": "Bypass config for @aipass citizen. Modules operational: concierge/init/doctor/handoff/profile built and tested. Bypasses cover: binary-invocation introspection pattern (bare aipass <cmd> runs, --info for introspection), thin entry-point router (aipass.py uses bare print, no CLI imports), pure-python bootstrap (bootstrap.py/scaffold_content.py run before AIPass services exist), test-isolation patterns (architecture/encapsulation for tests/ directory), and CLI flag name references (permission_flags in test assertions and handoff platform).",
|
||||
"last_updated": "2026-06-02"
|
||||
"description": "Bypass config for @aipass citizen. Modules operational: concierge/init/doctor/handoff/profile/install built and tested. Bypasses cover: binary-invocation introspection pattern (bare aipass <cmd> runs, --info for introspection), thin entry-point router (aipass.py uses bare print, no CLI imports), pure-python bootstrap (bootstrap.py/scaffold_content.py run before AIPass services exist), test-isolation patterns (architecture/encapsulation for tests/ directory), and CLI flag name references (permission_flags in test assertions and handoff platform).",
|
||||
"last_updated": "2026-07-05"
|
||||
},
|
||||
"bypass": [
|
||||
{
|
||||
@@ -91,6 +91,21 @@
|
||||
"standard": "permission_flags",
|
||||
"reason": "Assertions verify that the CLI flag name appears/absent in handoff_command output. String is in assertion context only — not a permission bypass in this file."
|
||||
},
|
||||
{
|
||||
"file": "tests/test_install.py",
|
||||
"standard": "architecture",
|
||||
"reason": "Test file lives in tests/ by convention — not in apps/. Standard 3-layer structure applies to production code only."
|
||||
},
|
||||
{
|
||||
"file": "tests/test_install.py",
|
||||
"standard": "encapsulation",
|
||||
"reason": "Unit tests must import the install module directly to test home resolution, clone, and setup orchestration in isolation. Entry-point imports would defeat the purpose of unit testing."
|
||||
},
|
||||
{
|
||||
"file": "tests/test_launcher.py",
|
||||
"standard": "architecture",
|
||||
"reason": "Test file lives in tests/ by convention — not in apps/. Standard 3-layer structure applies to production code only."
|
||||
},
|
||||
{
|
||||
"file": "tests/test_ping_sweep.py",
|
||||
"standard": "architecture",
|
||||
@@ -271,11 +286,36 @@
|
||||
"standard": "introspection",
|
||||
"reason": "aipass is binary-invoked: aipass profile runs the command; introspection via --info"
|
||||
},
|
||||
{
|
||||
"file": "apps/modules/install.py",
|
||||
"standard": "introspection",
|
||||
"reason": "aipass is binary-invoked: bare 'aipass install' runs the bootstrap; introspection via --info (same pattern as doctor/init_flow/profile)."
|
||||
},
|
||||
{
|
||||
"file": "apps/modules/install.py",
|
||||
"standard": "modules",
|
||||
"reason": "Thin bootstrap orchestrator — preps the target/project dir (mkdir) immediately before shelling out to git clone / setup.sh / aipass init. Pre-subprocess dir prep, not business file ops; a handler adds indirection for 2 mkdir calls in a linear flow (same pattern as init_flow/doctor)."
|
||||
},
|
||||
{
|
||||
"file": "apps/handlers/json/json_handler.py",
|
||||
"standard": "test_quality",
|
||||
"reason": "save_json now raises ValueError on invalid structure (aipass.aipass.shared contract, TDPLAN-0006 P2). Tested via pytest.raises — no False return path to test."
|
||||
},
|
||||
{
|
||||
"file": "tests/test_cross_os.py",
|
||||
"standard": "architecture",
|
||||
"reason": "Test file lives in tests/ by convention — not in apps/. Standard 3-layer structure applies to production code only."
|
||||
},
|
||||
{
|
||||
"file": "tests/test_cross_os.py",
|
||||
"standard": "encapsulation",
|
||||
"reason": "Unit tests must import the cross_os handler directly to test the parser/filter in isolation. Entry-point imports would defeat the purpose of unit testing."
|
||||
},
|
||||
{
|
||||
"file": "tests/test_cross_os.py",
|
||||
"standard": "documentation",
|
||||
"reason": "Test methods use descriptive names (test_filter_win32_gets_win_and_all, test_missing_doc_raises) that are self-documenting. Adding docstrings to every test function adds noise without value."
|
||||
},
|
||||
{
|
||||
"file": "tests/test_sandbox_check.py",
|
||||
"standard": "architecture",
|
||||
|
||||
@@ -15,14 +15,16 @@ aipass/
|
||||
├── apps/
|
||||
│ ├── aipass.py # Entry point — subcommand dispatch
|
||||
│ ├── modules/
|
||||
│ │ ├── doctor.py # System health aggregation
|
||||
│ │ ├── doctor.py # System health aggregation + cross-OS pre-flight (--cross-os)
|
||||
│ │ ├── doctor_fix.py # Remediation report (--fix, --json)
|
||||
│ │ ├── doctor_wire.py # Auto-wire provider settings + stale-deny re-export
|
||||
│ │ ├── handoff.py # CLI handoff (placeholder)
|
||||
│ │ ├── help_chat.py # README-backed Q&A (reads via readme_map handler)
|
||||
│ │ ├── init_flow.py # 12-stage guided setup
|
||||
│ │ ├── init_flow.py # 10-stage guided setup
|
||||
│ │ ├── install.py # aipass install — one-command bootstrap (clone + setup + init)
|
||||
│ │ └── profile.py # User profile read/write
|
||||
│ ├── handlers/
|
||||
│ │ ├── cross_os/ # Cross-OS pre-flight: gap_registry, preflight, run_record
|
||||
│ │ ├── handoff_platform/ # Platform-specific handoff detection
|
||||
│ │ ├── init/ # bootstrap.py, scaffold_content.py
|
||||
│ │ ├── json/ # JSON read/write utilities
|
||||
@@ -33,7 +35,7 @@ aipass/
|
||||
│ │ ├── system_detect/ # OS, shell, Python, RAM, CPU
|
||||
│ │ └── ui/ # Progress bars, menus, banners
|
||||
│ └── plugins/
|
||||
├── tests/ # 432 passing
|
||||
├── tests/ # 609 passing
|
||||
├── requirements.project.txt # Project-specific Python dependencies
|
||||
├── .trinity/ # Identity + session history + observations
|
||||
└── README.md
|
||||
@@ -48,7 +50,11 @@ aipass/
|
||||
| `aipass doctor` | System health — structure, registry, hooks, pytest |
|
||||
| `aipass doctor --fix` | Remediation report with `drone @spawn repair` commands |
|
||||
| `aipass doctor --json` | JSON output for structure scan results |
|
||||
| `aipass init` | 12-stage guided setup (resumable) |
|
||||
| `aipass doctor --cross-os` | Cross-OS pre-flight (Layer-3-lite, machine) — OS-gap cross-ref + routing/versions/hookstatus |
|
||||
| `aipass doctor --cross-os --e2e` | ...also runs the real Layer-2 e2e wiring suite (heavy, opt-in) |
|
||||
| `aipass doctor --cross-os --record [PATH]` | Write a machine-filled Run Record for the human Layer-3 acceptance pass |
|
||||
| `aipass init` | 10-stage guided setup (resumable) |
|
||||
| `aipass install` | One-command bootstrap — clone + setup.sh + hooks, then hand off to init (`--no-init`/`--with-init`/`--path`/`--here`) |
|
||||
| `aipass profile` | Show/edit user profile |
|
||||
| `aipass --version` | Version |
|
||||
|
||||
@@ -70,7 +76,7 @@ Humans only. Nothing in AIPass depends on this branch.
|
||||
|
||||
## Tests
|
||||
|
||||
432 passing — `pytest src/aipass/aipass/tests/`
|
||||
609 passing — `pytest src/aipass/aipass/tests/`
|
||||
|
||||
## Known Issues
|
||||
|
||||
@@ -78,4 +84,4 @@ Humans only. Nothing in AIPass depends on this branch.
|
||||
|
||||
## Last Updated
|
||||
|
||||
Last Updated: 2026-06-05
|
||||
Last Updated: 2026-07-05
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
"""cross_os — gap-registry cross-reference + non-mutating pre-flight runners."""
|
||||
|
||||
from aipass.aipass.apps.handlers.cross_os.gap_registry import ( # type: ignore[import-not-found]
|
||||
CrossOsGap,
|
||||
CrossOsGapError,
|
||||
find_gap_doc,
|
||||
gaps_for_platform,
|
||||
load_gaps,
|
||||
os_matches,
|
||||
parse_gap_registry,
|
||||
)
|
||||
from aipass.aipass.apps.handlers.cross_os.preflight import ( # type: ignore[import-not-found]
|
||||
PreflightResult,
|
||||
check_hookstatus,
|
||||
check_routing,
|
||||
check_versions,
|
||||
find_e2e_dir,
|
||||
run_e2e,
|
||||
)
|
||||
from aipass.aipass.apps.handlers.cross_os.run_record import ( # type: ignore[import-not-found]
|
||||
RunRecordError,
|
||||
build_run_record,
|
||||
default_record_path,
|
||||
generate_run_record,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"CrossOsGap",
|
||||
"CrossOsGapError",
|
||||
"PreflightResult",
|
||||
"RunRecordError",
|
||||
"build_run_record",
|
||||
"check_hookstatus",
|
||||
"check_routing",
|
||||
"check_versions",
|
||||
"default_record_path",
|
||||
"find_e2e_dir",
|
||||
"find_gap_doc",
|
||||
"gaps_for_platform",
|
||||
"generate_run_record",
|
||||
"load_gaps",
|
||||
"os_matches",
|
||||
"parse_gap_registry",
|
||||
"run_e2e",
|
||||
]
|
||||
@@ -0,0 +1,167 @@
|
||||
# =================== AIPass ====================
|
||||
# Name: gap_registry.py
|
||||
# Description: Live-parse the cross-OS gap registry and filter by platform
|
||||
# Version: 1.0.0
|
||||
# Created: 2026-07-02
|
||||
# Modified: 2026-07-02
|
||||
# =============================================
|
||||
|
||||
"""Cross-OS gap registry parser — Layer-3-lite pre-flight source.
|
||||
|
||||
Live-reads ``tests/CROSS_OS_TESTING.md`` (read-only to @aipass), parses the
|
||||
"Known cross-OS gap registry" markdown table, and filters rows to the running
|
||||
platform. This is a *machine pre-flight* — it surfaces tracked OS-specific gaps
|
||||
for the box the user is on. It NEVER claims the checklist's human acceptance
|
||||
green ("you watched it work on that OS").
|
||||
|
||||
Fail-to-error contract: if the doc is missing, the section is absent, or no data
|
||||
rows can be parsed, functions raise ``CrossOsGapError`` — they never silently
|
||||
return "no gaps". Callers must surface the error as a WARN/FAIL, not swallow it.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import List, NamedTuple
|
||||
|
||||
from aipass.prax import logger
|
||||
from aipass.aipass.apps.handlers.json import json_handler
|
||||
|
||||
# Path of the source-of-truth doc, relative to the repo root.
|
||||
DOC_RELATIVE = Path("tests") / "CROSS_OS_TESTING.md"
|
||||
|
||||
# Case-insensitive marker identifying the registry section header line.
|
||||
_SECTION_MARKER = "known cross-os gap registry"
|
||||
|
||||
# Expected column count in the registry table: # | Gap | OS | Symptom | Owner | Status
|
||||
_EXPECTED_COLUMNS = 6
|
||||
|
||||
|
||||
class CrossOsGapError(RuntimeError):
|
||||
"""Raised when the cross-OS gap registry cannot be located or parsed."""
|
||||
|
||||
|
||||
class CrossOsGap(NamedTuple):
|
||||
"""One row of the Known cross-OS gap registry table."""
|
||||
|
||||
number: str
|
||||
gap: str
|
||||
os: str
|
||||
symptom: str
|
||||
owner: str
|
||||
status: str
|
||||
|
||||
|
||||
def find_gap_doc(start: Path | None = None) -> Path:
|
||||
"""Search upward from ``start`` (or this file) for ``tests/CROSS_OS_TESTING.md``.
|
||||
|
||||
Portable — derives the repo root by walking ancestors rather than hardcoding
|
||||
an absolute path.
|
||||
|
||||
Raises:
|
||||
CrossOsGapError: if the doc is not found in any ancestor directory.
|
||||
"""
|
||||
base = (start or Path(__file__)).resolve()
|
||||
for parent in [base, *base.parents]:
|
||||
candidate = parent / DOC_RELATIVE
|
||||
if candidate.is_file():
|
||||
return candidate
|
||||
raise CrossOsGapError(f"cross-OS testing doc not found (searched upward from {base} for {DOC_RELATIVE})")
|
||||
|
||||
|
||||
def parse_gap_registry(text: str) -> List[CrossOsGap]:
|
||||
"""Parse the 'Known cross-OS gap registry' table out of the doc text.
|
||||
|
||||
Only rows whose first cell begins with a digit are treated as data rows,
|
||||
which naturally skips the header and the ``|---|`` separator.
|
||||
|
||||
Raises:
|
||||
CrossOsGapError: if the section is missing or no data rows parse.
|
||||
"""
|
||||
lines = text.splitlines()
|
||||
|
||||
section_idx = None
|
||||
for i, line in enumerate(lines):
|
||||
if line.lstrip().startswith("#") and _SECTION_MARKER in line.lower():
|
||||
section_idx = i
|
||||
break
|
||||
if section_idx is None:
|
||||
raise CrossOsGapError("'Known cross-OS gap registry' section not found in doc")
|
||||
|
||||
gaps: List[CrossOsGap] = []
|
||||
for line in lines[section_idx + 1 :]:
|
||||
stripped = line.strip()
|
||||
# Stop at the next top-level section.
|
||||
if stripped.startswith("## "):
|
||||
break
|
||||
if not stripped.startswith("|"):
|
||||
continue
|
||||
cells = [c.strip() for c in stripped.strip("|").split("|")]
|
||||
if len(cells) < _EXPECTED_COLUMNS:
|
||||
continue
|
||||
number = cells[0]
|
||||
# Skip header ("#") and separator ("---") rows — data rows start with a digit.
|
||||
if not number or not number[0].isdigit():
|
||||
continue
|
||||
gaps.append(
|
||||
CrossOsGap(
|
||||
number=number,
|
||||
gap=cells[1],
|
||||
os=cells[2],
|
||||
symptom=cells[3],
|
||||
owner=cells[4],
|
||||
status=cells[5],
|
||||
)
|
||||
)
|
||||
|
||||
if not gaps:
|
||||
raise CrossOsGapError("gap registry table found but no data rows could be parsed")
|
||||
return gaps
|
||||
|
||||
|
||||
def load_gaps(start: Path | None = None) -> List[CrossOsGap]:
|
||||
"""Locate, read, and parse the full gap registry.
|
||||
|
||||
Raises:
|
||||
CrossOsGapError: on missing/unreadable doc or unparseable table.
|
||||
"""
|
||||
doc = find_gap_doc(start)
|
||||
try:
|
||||
raw = doc.read_text(encoding="utf-8")
|
||||
except OSError as exc:
|
||||
raise CrossOsGapError(f"cross-OS testing doc unreadable at {doc}: {exc}") from exc
|
||||
logger.info("[cross_os] parsing gap registry from %s", doc)
|
||||
return parse_gap_registry(raw)
|
||||
|
||||
|
||||
def os_matches(os_cell: str, platform_name: str) -> bool:
|
||||
"""Return True if an OS cell applies to ``platform_name`` (a ``sys.platform`` value).
|
||||
|
||||
Mapping (case-insensitive): 'all' matches every platform; 'win' matches
|
||||
win32; 'mac' matches darwin. So 'Win/mac' matches both win32 and darwin.
|
||||
"""
|
||||
cell = os_cell.lower()
|
||||
if "all" in cell:
|
||||
return True
|
||||
if platform_name == "win32":
|
||||
return "win" in cell
|
||||
if platform_name == "darwin":
|
||||
return "mac" in cell
|
||||
return False
|
||||
|
||||
|
||||
def gaps_for_platform(platform_name: str | None = None, start: Path | None = None) -> List[CrossOsGap]:
|
||||
"""Return only the gap rows relevant to ``platform_name`` (defaults to ``sys.platform``).
|
||||
|
||||
Raises:
|
||||
CrossOsGapError: on any load/parse failure (never silently empty).
|
||||
"""
|
||||
plat = platform_name or sys.platform
|
||||
gaps = load_gaps(start)
|
||||
relevant = [g for g in gaps if os_matches(g.os, plat)]
|
||||
json_handler.log_operation(
|
||||
"cross_os_gap_lookup",
|
||||
{"platform": plat, "total": len(gaps), "relevant": len(relevant)},
|
||||
)
|
||||
return relevant
|
||||
@@ -0,0 +1,230 @@
|
||||
# =================== AIPass ====================
|
||||
# Name: preflight.py
|
||||
# Description: Non-mutating cross-OS pre-flight runners (routing/version/hooks/e2e)
|
||||
# Version: 1.0.0
|
||||
# Created: 2026-07-02
|
||||
# Modified: 2026-07-02
|
||||
# =============================================
|
||||
|
||||
"""Cross-OS pre-flight runners — Layer-3-lite machine checks.
|
||||
|
||||
Each runner probes one machine-provable slice of the cross-OS acceptance
|
||||
checklist and returns a small ``PreflightResult(name, ok, detail)``. Everything
|
||||
here is *non-mutating* and NEVER wakes a citizen: the drone routes exercised
|
||||
(``drone systems``, ``drone @ai_mail --help``, ``drone @hooks status``) only
|
||||
print/route — they do not dispatch work to a branch.
|
||||
|
||||
Robustness contract: every subprocess has a timeout and every runner catches
|
||||
``FileNotFoundError`` / ``TimeoutExpired`` (and other ``OSError``) and turns it
|
||||
into ``ok=False`` with a clear ``detail`` — a runner never crashes the caller.
|
||||
|
||||
These are pre-flight rows. They can NEVER claim the checklist's human green
|
||||
("you watched it work on that OS"); callers must label them pre-flight.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import List, NamedTuple, Sequence, Tuple
|
||||
|
||||
from aipass.prax import logger
|
||||
from aipass.aipass.apps.handlers.json import json_handler
|
||||
|
||||
# Directory (relative to repo root) holding the Layer-2 e2e wiring suite.
|
||||
E2E_RELATIVE = Path("tests") / "e2e"
|
||||
|
||||
# Subprocess timeouts (seconds). Light routes are quick; e2e is heavy (it builds
|
||||
# a wheel and installs it into a fresh venv) so it gets a generous budget.
|
||||
_ROUTE_TIMEOUT = 20
|
||||
_VERSION_TIMEOUT = 15
|
||||
_HOOKSTATUS_TIMEOUT = 20
|
||||
_E2E_TIMEOUT = 600
|
||||
|
||||
# Detail prefix marking an e2e result that could NOT run (infra), so callers can
|
||||
# map it to WARN rather than FAIL. Real pytest failures do not use this prefix.
|
||||
E2E_UNRUNNABLE_PREFIX = "could not run"
|
||||
|
||||
|
||||
class PreflightResult(NamedTuple):
|
||||
"""One non-mutating pre-flight probe outcome."""
|
||||
|
||||
name: str
|
||||
ok: bool
|
||||
detail: str
|
||||
|
||||
|
||||
def _run(cmd: Sequence[str], timeout: int, cwd: str | None = None) -> Tuple[int | None, str]:
|
||||
"""Run ``cmd`` non-interactively; return ``(returncode, combined_output)``.
|
||||
|
||||
Never raises for the expected failure modes: a missing binary yields
|
||||
``(None, "not found: ...")`` and a timeout yields ``(None, "timed out ...")``.
|
||||
"""
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
list(cmd),
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=timeout,
|
||||
cwd=cwd,
|
||||
)
|
||||
return proc.returncode, (proc.stdout or "") + (proc.stderr or "")
|
||||
except FileNotFoundError as exc:
|
||||
logger.warning("[cross_os.preflight] binary not found for %s: %s", cmd, exc)
|
||||
return None, f"not found: {cmd[0]}"
|
||||
except subprocess.TimeoutExpired as exc:
|
||||
logger.warning("[cross_os.preflight] %s timed out after %ss: %s", cmd, timeout, exc)
|
||||
return None, f"timed out after {timeout}s"
|
||||
except OSError as exc:
|
||||
logger.warning("[cross_os.preflight] error running %s: %s", cmd, exc)
|
||||
return None, f"error running {cmd[0]}: {exc}"
|
||||
|
||||
|
||||
def _first_line(output: str) -> str:
|
||||
"""Return the first non-empty stripped line of ``output`` (or "")."""
|
||||
for line in output.splitlines():
|
||||
stripped = line.strip()
|
||||
if stripped:
|
||||
return stripped
|
||||
return ""
|
||||
|
||||
|
||||
def check_routing() -> PreflightResult:
|
||||
"""Verify drone routing (Phase 4): ``drone systems`` + one subprocess route.
|
||||
|
||||
Both ``drone systems`` (4.1) and ``drone @ai_mail --help`` (4.2) must exit 0.
|
||||
These are non-mutating routes — ``--help`` prints usage; neither dispatches
|
||||
work to a citizen.
|
||||
"""
|
||||
sys_rc, sys_out = _run(["drone", "systems"], _ROUTE_TIMEOUT)
|
||||
route_rc, route_out = _run(["drone", "@ai_mail", "--help"], _ROUTE_TIMEOUT)
|
||||
|
||||
ok = sys_rc == 0 and route_rc == 0
|
||||
if ok:
|
||||
detail = "drone systems exit 0; @ai_mail route exit 0"
|
||||
else:
|
||||
problems: List[str] = []
|
||||
if sys_rc != 0:
|
||||
problems.append(f"drone systems -> {sys_rc or _first_line(sys_out)}")
|
||||
if route_rc != 0:
|
||||
problems.append(f"@ai_mail --help -> {route_rc or _first_line(route_out)}")
|
||||
detail = "; ".join(problems)
|
||||
return PreflightResult("routing", ok, detail)
|
||||
|
||||
|
||||
def check_versions() -> PreflightResult:
|
||||
"""Verify ``drone --version`` and ``aipass --version`` exit 0 (Phase 1.3).
|
||||
|
||||
Captures the version strings into the detail.
|
||||
"""
|
||||
drone_rc, drone_out = _run(["drone", "--version"], _VERSION_TIMEOUT)
|
||||
aipass_rc, aipass_out = _run(["aipass", "--version"], _VERSION_TIMEOUT)
|
||||
|
||||
def _ver(label: str, rc: int | None, out: str) -> str:
|
||||
if rc == 0:
|
||||
return _first_line(out)
|
||||
return f"{label} --version failed ({rc}: {_first_line(out)})"
|
||||
|
||||
ok = drone_rc == 0 and aipass_rc == 0
|
||||
detail = f"{_ver('drone', drone_rc, drone_out)}; {_ver('aipass', aipass_rc, aipass_out)}"
|
||||
return PreflightResult("versions", ok, detail)
|
||||
|
||||
|
||||
def check_hookstatus() -> PreflightResult:
|
||||
"""Verify the @hooks per-project config renders (Phase 6.3).
|
||||
|
||||
Note: the checklist labels this ``drone @hooks hookstatus``, but that command
|
||||
name does not route on the current drone build (it returns "Unknown command"
|
||||
— gap #9 in the wild). The real, non-mutating subcommand that renders the
|
||||
per-project hook config is ``drone @hooks status``, which is what Phase 6.3
|
||||
actually verifies, so that is what we probe.
|
||||
"""
|
||||
rc, out = _run(["drone", "@hooks", "status"], _HOOKSTATUS_TIMEOUT)
|
||||
ok = rc == 0
|
||||
detail = _first_line(out) if ok else f"drone @hooks status exit {rc}: {_first_line(out)}"
|
||||
return PreflightResult("hookstatus", ok, detail)
|
||||
|
||||
|
||||
def find_e2e_dir(start: Path | None = None) -> Path | None:
|
||||
"""Search upward from ``start`` (or this file) for ``tests/e2e``.
|
||||
|
||||
Portable — walks ancestors rather than hardcoding a path (mirrors the
|
||||
gap_registry ``find_gap_doc`` pattern). Returns ``None`` if not found.
|
||||
"""
|
||||
base = (start or Path(__file__)).resolve()
|
||||
for parent in [base, *base.parents]:
|
||||
candidate = parent / E2E_RELATIVE
|
||||
if candidate.is_dir():
|
||||
return candidate
|
||||
return None
|
||||
|
||||
|
||||
def _resolve_pytest(repo_root: Path) -> Tuple[List[str], str] | None:
|
||||
"""Resolve a pytest invocation for the current box.
|
||||
|
||||
Prefers ``<repo>/.venv/<bin>/pytest`` (system ``python`` may be absent);
|
||||
falls back to ``sys.executable -m pytest``. Returns ``(argv_prefix, source)``
|
||||
or ``None`` if neither can be resolved.
|
||||
"""
|
||||
bin_dir = "Scripts" if os.name == "nt" else "bin"
|
||||
exe = "pytest.exe" if os.name == "nt" else "pytest"
|
||||
venv_pytest = repo_root / ".venv" / bin_dir / exe
|
||||
if venv_pytest.is_file():
|
||||
return [str(venv_pytest)], "venv pytest"
|
||||
if sys.executable:
|
||||
return [sys.executable, "-m", "pytest"], "sys.executable -m pytest"
|
||||
return None
|
||||
|
||||
|
||||
def run_e2e(start: Path | None = None) -> PreflightResult:
|
||||
"""Run the Layer-2 e2e wiring suite (``pytest tests/e2e -q``). HEAVY.
|
||||
|
||||
Only call this when explicitly opted into (``--e2e``): the suite builds the
|
||||
aipass wheel and installs it into a fresh venv. ``ok=True`` only when every
|
||||
test passes (pytest exit 0). Un-runnable cases (dir missing, no pytest,
|
||||
timeout, crash) return ``ok=False`` with a ``could not run`` detail so the
|
||||
caller can render them as WARN rather than a hard FAIL.
|
||||
"""
|
||||
e2e_dir = find_e2e_dir(start)
|
||||
if e2e_dir is None:
|
||||
return PreflightResult("e2e", False, f"{E2E_UNRUNNABLE_PREFIX}: e2e dir not found")
|
||||
|
||||
repo_root = e2e_dir.parent.parent
|
||||
resolved = _resolve_pytest(repo_root)
|
||||
if resolved is None:
|
||||
detail = f"{E2E_UNRUNNABLE_PREFIX}: pytest unavailable (no venv pytest, no interpreter)"
|
||||
return _log_e2e(PreflightResult("e2e", False, detail))
|
||||
|
||||
argv_prefix, source = resolved
|
||||
logger.info("[cross_os.preflight] running e2e suite via %s (cwd=%s)", source, repo_root)
|
||||
rc, out = _run([*argv_prefix, str(e2e_dir), "-q"], _E2E_TIMEOUT, cwd=str(repo_root))
|
||||
|
||||
if rc is None:
|
||||
# Timeout / missing binary / OSError — infra, not a real test failure.
|
||||
return _log_e2e(PreflightResult("e2e", False, f"{E2E_UNRUNNABLE_PREFIX}: {out}"))
|
||||
|
||||
summary = _e2e_summary(out)
|
||||
if rc == 0:
|
||||
return _log_e2e(PreflightResult("e2e", True, summary))
|
||||
# Non-zero: could be real failures OR pytest itself being absent under
|
||||
# `python -m pytest`. Distinguish so the caller can WARN vs FAIL.
|
||||
if "no module named pytest" in out.lower():
|
||||
return _log_e2e(PreflightResult("e2e", False, f"{E2E_UNRUNNABLE_PREFIX}: pytest not importable"))
|
||||
return _log_e2e(PreflightResult("e2e", False, summary))
|
||||
|
||||
|
||||
def _log_e2e(result: PreflightResult) -> PreflightResult:
|
||||
"""Record the e2e pre-flight outcome via json_handler, then return it."""
|
||||
json_handler.log_operation("cross_os_e2e_preflight", {"ok": result.ok, "detail": result.detail})
|
||||
return result
|
||||
|
||||
|
||||
def _e2e_summary(output: str) -> str:
|
||||
"""Extract pytest's terminal summary line (e.g. '14 passed', '2 failed...')."""
|
||||
for line in reversed(output.splitlines()):
|
||||
stripped = line.strip().strip("=").strip()
|
||||
if any(tok in stripped for tok in ("passed", "failed", "error", "no tests ran")):
|
||||
return stripped
|
||||
return _first_line(output) or "no pytest summary parsed"
|
||||
@@ -0,0 +1,243 @@
|
||||
# =================== AIPass ====================
|
||||
# Name: run_record.py
|
||||
# Description: Machine pre-flight Run Record generator for the cross-OS checklist
|
||||
# Version: 1.0.0
|
||||
# Created: 2026-07-02
|
||||
# Modified: 2026-07-02
|
||||
# =============================================
|
||||
|
||||
"""Cross-OS Run Record generator — Layer-3-lite machine pre-flight DRAFT.
|
||||
|
||||
Emits a text Run Record block modelled on the "## Run Record" fenced template in
|
||||
``tests/CROSS_OS_TESTING.md`` (that block is the format source-of-truth). The
|
||||
block is *constructed in code* rather than string-substituted into the parsed
|
||||
template on purpose: the load-bearing invariant here is the machine/human
|
||||
boundary — the machine fills ONLY what it can prove (env facts + the
|
||||
non-mutating pre-flight rows) and every human-only row (clean install,
|
||||
interactive init, daemons, audible sound, PTY, per-branch matrix, overall
|
||||
verdict, commit, tester) is left blank or marked ``— human``. Constructing the
|
||||
block gives exact control over that boundary; substituting values line-by-line
|
||||
into the free-text template would be brittle and could silently tick a human row.
|
||||
|
||||
Env facts are detected with stdlib (``platform`` / ``os``) directly — the same
|
||||
detection ``system_detector`` performs — because the seedgo cross-handler rule
|
||||
forbids this handler importing another handler.
|
||||
|
||||
A machine can NEVER claim the checklist's human green ("you watched it work on
|
||||
that OS"). This artifact is explicitly a pre-flight DRAFT that a person must
|
||||
complete by running the real Layer-3 acceptance pass.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import platform
|
||||
import sys
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import List
|
||||
|
||||
from aipass.prax import logger
|
||||
from aipass.aipass.shared.registry_discovery import find_registry
|
||||
|
||||
from aipass.aipass.apps.handlers.cross_os.gap_registry import CrossOsGapError, gaps_for_platform
|
||||
from aipass.aipass.apps.handlers.cross_os.preflight import (
|
||||
E2E_UNRUNNABLE_PREFIX,
|
||||
check_hookstatus,
|
||||
check_routing,
|
||||
run_e2e,
|
||||
)
|
||||
from aipass.aipass.apps.handlers.json import json_handler
|
||||
|
||||
# Marker glyphs for the record. Match the doc's Run Record block, which uses the
|
||||
# ✅ / ❌ emoji rather than the terminal ✓ / ✗ glyphs.
|
||||
_PASS = "✅"
|
||||
_FAIL = "❌"
|
||||
# Un-ticked human box — a person must still run and mark the real pass.
|
||||
_HUMAN = "⬜ — human"
|
||||
|
||||
# Horizontal rule used by the doc's Run Record block.
|
||||
_RULE = "─────────────────────────────────────────────"
|
||||
|
||||
|
||||
class RunRecordError(RuntimeError):
|
||||
"""Raised when the Run Record file cannot be written."""
|
||||
|
||||
|
||||
def _os_name() -> str:
|
||||
"""OS family name (e.g. 'Linux', 'Darwin', 'Windows')."""
|
||||
return platform.system() or "unknown"
|
||||
|
||||
|
||||
def _resolve_aipass_home() -> str:
|
||||
"""Resolve AIPASS_HOME from the env, else the discovered registry parent.
|
||||
|
||||
Returns "" (blank) if neither is available — a blank row is honest; we never
|
||||
invent a path.
|
||||
"""
|
||||
home = os.environ.get("AIPASS_HOME", "").strip()
|
||||
if home:
|
||||
return home
|
||||
registry = find_registry(package_root=str(Path(__file__).resolve().parent))
|
||||
if registry.exists():
|
||||
return str(registry.parent)
|
||||
return ""
|
||||
|
||||
|
||||
def _env_lines() -> List[str]:
|
||||
"""Build the machine-auto-filled environment header lines (Phase 0 facts)."""
|
||||
os_desc = f"{_os_name()} {platform.release()}".strip()
|
||||
shell_path = os.environ.get("SHELL", "")
|
||||
shell_name = Path(shell_path).name if shell_path else "unknown"
|
||||
term = os.environ.get("TERM", "").strip() or "unknown"
|
||||
today = datetime.now().strftime("%Y-%m-%d")
|
||||
|
||||
return [
|
||||
"AIPass Cross-OS Run Record",
|
||||
f"Machine/VM : {platform.node() or 'unknown'}",
|
||||
f"OS + version : {os_desc}",
|
||||
f"Arch : {platform.machine() or 'unknown'}",
|
||||
f"Python : {platform.python_version()}",
|
||||
f"Shell / term : {shell_name} / {term}",
|
||||
f"AIPASS_HOME : {_resolve_aipass_home()}",
|
||||
# Commit stays blank — no git here; a human fills it (hint inline).
|
||||
"Commit (drone @git log -1) : ← fill via drone @git log -1",
|
||||
# Tester stays blank (human); Date is machine-knowable.
|
||||
f"Tester : Date : {today}",
|
||||
]
|
||||
|
||||
|
||||
def _verdict(ok: bool) -> str:
|
||||
"""Map a pre-flight boolean to the machine glyph + a pre-flight label."""
|
||||
glyph = _PASS if ok else _FAIL
|
||||
return f"{glyph} pre-flight (machine)"
|
||||
|
||||
|
||||
def _phase2_line(run_heavy_e2e: bool) -> str:
|
||||
"""Phase 2 (e2e) line — only auto-filled when the heavy suite was opted into."""
|
||||
label = "Phase 2 e2e suite (14/14) ...."
|
||||
if not run_heavy_e2e:
|
||||
return f"{label} {_HUMAN} notes: — not run (pass --e2e to run it)"
|
||||
result = run_e2e()
|
||||
if result.ok:
|
||||
return f"{label} {_verdict(True)} notes: {result.detail}"
|
||||
if result.detail.startswith(E2E_UNRUNNABLE_PREFIX):
|
||||
# Infra could not run the suite — WARN-ish, not a proven fail.
|
||||
return f"{label} ⚠️ could not run (machine) notes: {result.detail}"
|
||||
return f"{label} {_verdict(False)} notes: {result.detail}"
|
||||
|
||||
|
||||
def _phase_lines(run_heavy_e2e: bool) -> List[str]:
|
||||
"""Build the Phase 0–7 + per-branch lines, machine-proving only what it can."""
|
||||
routing = check_routing()
|
||||
hooks = check_hookstatus()
|
||||
|
||||
return [
|
||||
# Phase 0 — the machine captured the env above, so this is proven.
|
||||
f"Phase 0 env capture .......... {_verdict(True)} notes: captured above",
|
||||
# Phase 1 — clean install (setup.sh / .venv) is pre-init + human.
|
||||
f"Phase 1 clean install ........ {_HUMAN} notes:",
|
||||
_phase2_line(run_heavy_e2e),
|
||||
# Phase 3 — real scaffold + interactive init is human (PTY).
|
||||
f"Phase 3 aipass init .......... {_HUMAN} notes:",
|
||||
# Phase 4 — drone routing is non-mutating and machine-provable.
|
||||
f"Phase 4 drone routing ........ {_verdict(routing.ok)} notes: {routing.detail}",
|
||||
# Phase 5 — daemons (os.kill / start_new_session) are mutating + human.
|
||||
f"Phase 5 daemons .............. {_HUMAN} notes:",
|
||||
# Phase 6 — hookstatus config renders (machine); audible sound is human.
|
||||
f"Phase 6 hooks + sound ........ {_verdict(hooks.ok)} hookstatus; 🔊 sound {_HUMAN} notes: {hooks.detail}",
|
||||
# Phase 7 — interactive PTY layer is human-only.
|
||||
f"Phase 7 interactive .......... {_HUMAN} notes:",
|
||||
# Per-branch smoke matrix is a human pass.
|
||||
f"Per-branch matrix (13) ....... {_HUMAN} reds:",
|
||||
]
|
||||
|
||||
|
||||
def _watch_lines(platform_name: str) -> List[str]:
|
||||
"""Build the tracked-gap watch-item lines for this platform.
|
||||
|
||||
Reads the live gap registry; on any registry error, degrades to a single
|
||||
note line (never crashes the record — it is primarily an env artifact).
|
||||
"""
|
||||
lines = ["Watch items (tracked cross-OS gaps for this platform):"]
|
||||
try:
|
||||
gaps = gaps_for_platform(platform_name)
|
||||
except CrossOsGapError as exc:
|
||||
logger.warning("[cross_os.run_record] gap registry unavailable: %s", exc)
|
||||
lines.append(f" - registry unavailable — {exc}")
|
||||
return lines
|
||||
|
||||
if not gaps:
|
||||
lines.append(f" - none tracked for {platform_name}")
|
||||
return lines
|
||||
|
||||
for gap in gaps:
|
||||
lines.append(f" - gap #{gap.number} [{gap.status}] {gap.symptom} — owner {gap.owner}")
|
||||
return lines
|
||||
|
||||
|
||||
def build_run_record(run_heavy_e2e: bool = False, platform_name: str | None = None) -> str:
|
||||
"""Build the full machine pre-flight Run Record text block.
|
||||
|
||||
Auto-fills the env header + the machine-provable phase rows (0/4/6, plus 2
|
||||
when ``run_heavy_e2e``); leaves every human-only row blank/marked. The output
|
||||
mirrors the "## Run Record" template in tests/CROSS_OS_TESTING.md.
|
||||
"""
|
||||
plat = platform_name or sys.platform
|
||||
|
||||
header = [
|
||||
"NOTE: machine pre-flight DRAFT — rows marked '(machine)' are auto-captured pre-flight",
|
||||
"only; they are NOT the checklist's human green. A human must complete every '— human'",
|
||||
"row and run the real Layer-3 acceptance pass before this record counts.",
|
||||
"",
|
||||
]
|
||||
|
||||
body: List[str] = []
|
||||
body.append(_RULE)
|
||||
body.extend(_env_lines())
|
||||
body.append(_RULE)
|
||||
body.extend(_phase_lines(run_heavy_e2e))
|
||||
body.append(_RULE)
|
||||
body.extend(_watch_lines(plat))
|
||||
body.append("")
|
||||
body.append("New gaps found (file + assign):")
|
||||
body.append("")
|
||||
body.append(f"Overall verdict: {_HUMAN} (PASS / PARTIAL / FAIL — after the real pass)")
|
||||
body.append(_RULE)
|
||||
|
||||
return "\n".join([*header, *body]) + "\n"
|
||||
|
||||
|
||||
def default_record_path() -> Path:
|
||||
"""Return the default record path in CWD (mirrors the doc's log naming)."""
|
||||
return Path.cwd() / f"aipass-crossos-record-{_os_name().lower()}.txt"
|
||||
|
||||
|
||||
def generate_run_record(path: str | None = None, run_heavy_e2e: bool = False) -> Path:
|
||||
"""Build and write the Run Record; return the written path.
|
||||
|
||||
Args:
|
||||
path: Destination file (``--record`` value). If None, a sensible default
|
||||
in CWD is used.
|
||||
run_heavy_e2e: When True, run and record the heavy Phase-2 e2e result.
|
||||
|
||||
Raises:
|
||||
RunRecordError: if the file cannot be written (OSError) — never crashes.
|
||||
"""
|
||||
content = build_run_record(run_heavy_e2e=run_heavy_e2e)
|
||||
target = Path(path) if path else default_record_path()
|
||||
try:
|
||||
parent = target.parent
|
||||
if parent and not parent.exists():
|
||||
parent.mkdir(parents=True, exist_ok=True)
|
||||
target.write_text(content, encoding="utf-8")
|
||||
except OSError as exc:
|
||||
logger.error("[cross_os.run_record] could not write record to %s: %s", target, exc)
|
||||
raise RunRecordError(f"could not write Run Record to {target}: {exc}") from exc
|
||||
|
||||
json_handler.log_operation(
|
||||
"cross_os_run_record",
|
||||
{"path": str(target), "e2e": run_heavy_e2e},
|
||||
)
|
||||
logger.info("[cross_os.run_record] wrote machine pre-flight record to %s", target)
|
||||
return target
|
||||
@@ -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
|
||||
@@ -1,9 +1,9 @@
|
||||
# =================== AIPass ====================
|
||||
# Name: progress.py
|
||||
# Description: Rich progress and glyph helpers for aipass doctor
|
||||
# Version: 1.0.0
|
||||
# Description: Rich progress and glyph helpers for aipass doctor + init
|
||||
# Version: 1.1.0
|
||||
# Created: 2026-04-16
|
||||
# Modified: 2026-04-16
|
||||
# Modified: 2026-07-05
|
||||
# =============================================
|
||||
|
||||
"""
|
||||
@@ -15,6 +15,9 @@ No bare print() — all output via logger or caller's console.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from contextlib import contextmanager
|
||||
from typing import Iterator
|
||||
|
||||
from rich.progress import Progress, SpinnerColumn, TextColumn
|
||||
|
||||
from aipass.aipass.apps.handlers.json import json_handler
|
||||
@@ -49,6 +52,26 @@ def make_doctor_progress() -> Progress:
|
||||
)
|
||||
|
||||
|
||||
@contextmanager
|
||||
def activity_spinner(description: str) -> Iterator[Progress]:
|
||||
"""Transient spinner for a blocking, non-streaming action.
|
||||
|
||||
Wrap Python work that would otherwise look frozen — scaffold build, ping
|
||||
sweep — so the user sees it is alive. Do NOT wrap a subprocess that streams
|
||||
its own output to the terminal (installer, spawn): the two renderers fight.
|
||||
Yields the Progress so a caller can retitle the task mid-flight if needed.
|
||||
|
||||
Args:
|
||||
description: What is happening, e.g. "Building project scaffold…".
|
||||
"""
|
||||
logger.info("[progress] activity spinner: %s", description)
|
||||
json_handler.log_operation("activity_spinner", {"description": description})
|
||||
prog = Progress(SpinnerColumn(), TextColumn("{task.description}"), transient=True)
|
||||
with prog:
|
||||
prog.add_task(description, total=None)
|
||||
yield prog
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# CHECK FORMATTER
|
||||
# =============================================================================
|
||||
@@ -81,3 +104,26 @@ def format_check(
|
||||
line = f"{line}\n{indent}[dim yellow]{remediation}[/dim yellow]"
|
||||
|
||||
return line
|
||||
|
||||
|
||||
def render_step_header(current: int, total: int, label: str, width: int = 18) -> str:
|
||||
"""Render a one-line stage header with an inline progress bar.
|
||||
|
||||
Example: 'Step 3/10 [██████░░░░░░░░░░░░] — User profile'. Pure formatting —
|
||||
a static string, so it composes with the interactive prompts between stages
|
||||
(a live Rich bar would have to stop/start around every input()).
|
||||
|
||||
Args:
|
||||
current: 1-based index of the stage now starting.
|
||||
total: Total number of stages (clamps to >= 1).
|
||||
label: Human label for the stage.
|
||||
width: Character width of the bar.
|
||||
|
||||
Returns:
|
||||
Rich markup string ready for console.print().
|
||||
"""
|
||||
total = max(total, 1)
|
||||
current = max(0, min(current, total))
|
||||
filled = round(width * current / total)
|
||||
bar = "█" * filled + "░" * (width - filled)
|
||||
return f"[bold cyan]Step {current}/{total}[/bold cyan] [green]{bar}[/green] — [bold]{label}[/bold]"
|
||||
|
||||
@@ -17,11 +17,23 @@ 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
|
||||
|
||||
from aipass.aipass.apps.handlers.cross_os import (
|
||||
CrossOsGapError,
|
||||
PreflightResult,
|
||||
RunRecordError,
|
||||
check_hookstatus,
|
||||
check_routing,
|
||||
check_versions,
|
||||
gaps_for_platform,
|
||||
generate_run_record,
|
||||
)
|
||||
from aipass.aipass.apps.handlers.cross_os import run_e2e as run_e2e_preflight
|
||||
from aipass.aipass.apps.handlers.cross_os.preflight import E2E_UNRUNNABLE_PREFIX
|
||||
from aipass.aipass.apps.handlers.json import json_handler
|
||||
from aipass.aipass.apps.handlers.sandbox_check.sandbox_checker import (
|
||||
check_broker_alive,
|
||||
@@ -48,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 (
|
||||
@@ -139,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] = []
|
||||
@@ -161,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
|
||||
@@ -210,6 +337,8 @@ def _check_identity() -> List[CheckResult]:
|
||||
else:
|
||||
results.append(CheckResult("passport", GLYPH_WARN, "not found", ""))
|
||||
|
||||
results.extend(_check_owner_seating())
|
||||
|
||||
return results
|
||||
|
||||
|
||||
@@ -369,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)
|
||||
@@ -456,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))
|
||||
@@ -661,6 +793,197 @@ def _check_sandbox() -> List[CheckResult]:
|
||||
return results
|
||||
|
||||
|
||||
# --- Cross-OS pre-flight group ---
|
||||
|
||||
|
||||
def _cross_os_gap_rows() -> List[CheckResult]:
|
||||
"""OS-gap cross-reference rows (slice 1): tracked gaps for this platform.
|
||||
|
||||
Machine pre-flight — surfaces OS-specific gaps from tests/CROSS_OS_TESTING.md
|
||||
for this box. Never claims the checklist's human green. WARN per gap, a single
|
||||
PASS when none apply, a single WARN when the registry can't be read (never
|
||||
silent).
|
||||
"""
|
||||
platform_name = sys.platform
|
||||
try:
|
||||
gaps = gaps_for_platform(platform_name)
|
||||
except CrossOsGapError as exc:
|
||||
logger.warning("[doctor] cross-OS gap registry unavailable: %s", exc)
|
||||
return [
|
||||
CheckResult(
|
||||
"cross-os registry (pre-flight)",
|
||||
GLYPH_WARN,
|
||||
f"pre-flight: gap registry unavailable — {exc}",
|
||||
"Ensure tests/CROSS_OS_TESTING.md has a 'Known cross-OS gap registry' table",
|
||||
)
|
||||
]
|
||||
|
||||
if not gaps:
|
||||
return [
|
||||
CheckResult(
|
||||
"cross-os (pre-flight)",
|
||||
GLYPH_PASS,
|
||||
f"pre-flight: no tracked cross-OS gaps for {platform_name}",
|
||||
"",
|
||||
)
|
||||
]
|
||||
|
||||
return [
|
||||
CheckResult(
|
||||
f"cross-os gap #{gap.number} (pre-flight)",
|
||||
GLYPH_WARN,
|
||||
f"pre-flight: {gap.symptom}",
|
||||
f"tracked gap [{gap.status}] — owner {gap.owner}; human Layer-3 pass still required",
|
||||
)
|
||||
for gap in gaps
|
||||
]
|
||||
|
||||
|
||||
def _preflight_row(label: str, result: PreflightResult, remediation: str) -> CheckResult:
|
||||
"""Map a non-mutating PreflightResult to a labelled pre-flight CheckResult.
|
||||
|
||||
ok -> PASS, else FAIL. The detail is always prefixed 'pre-flight:' so a row
|
||||
can never be mistaken for the checklist's human acceptance green.
|
||||
"""
|
||||
glyph = GLYPH_PASS if result.ok else GLYPH_FAIL
|
||||
return CheckResult(f"{label} (pre-flight)", glyph, f"pre-flight: {result.detail}", "" if result.ok else remediation)
|
||||
|
||||
|
||||
def _e2e_row(result: PreflightResult) -> CheckResult:
|
||||
"""Map the heavy e2e PreflightResult to a CheckResult (PASS/FAIL/WARN).
|
||||
|
||||
ok -> PASS. Un-runnable infra cases (dir missing, no pytest, timeout) -> WARN.
|
||||
Real test failures -> FAIL.
|
||||
"""
|
||||
if result.ok:
|
||||
glyph, remediation = GLYPH_PASS, ""
|
||||
elif result.detail.startswith(E2E_UNRUNNABLE_PREFIX):
|
||||
glyph = GLYPH_WARN
|
||||
remediation = "Ensure a project .venv with pytest (or system pytest) and tests/e2e are present"
|
||||
else:
|
||||
glyph, remediation = GLYPH_FAIL, "Run 'pytest tests/e2e -q' from the repo root to inspect the failures"
|
||||
return CheckResult("e2e suite (pre-flight)", glyph, f"pre-flight: {result.detail}", remediation)
|
||||
|
||||
|
||||
def _check_cross_os(run_e2e: bool = False) -> List[CheckResult]:
|
||||
"""Cross-OS pre-flight group (Layer-3-lite): gap cross-reference + machine routes.
|
||||
|
||||
Combines the slice-1 OS-gap rows with the non-mutating routing / --version /
|
||||
hookstatus probes (Phase 4 / 1.3 / 6.3). None of these wake a citizen. When
|
||||
``run_e2e`` is set, also runs the heavy Phase-2 e2e suite. Every row is
|
||||
labelled pre-flight and still needs the human Layer-3 pass.
|
||||
"""
|
||||
results = _cross_os_gap_rows()
|
||||
|
||||
results.append(
|
||||
_preflight_row(
|
||||
"routing", check_routing(), "Ensure aipass is installed (setup.sh) so 'drone systems' and routes resolve"
|
||||
)
|
||||
)
|
||||
results.append(
|
||||
_preflight_row(
|
||||
"versions", check_versions(), "Ensure 'drone' and 'aipass' are on PATH (clone the repo, run setup.sh)"
|
||||
)
|
||||
)
|
||||
results.append(
|
||||
_preflight_row("hookstatus", check_hookstatus(), "Check @hooks routing: 'drone @hooks status' should exit 0")
|
||||
)
|
||||
|
||||
if run_e2e:
|
||||
results.append(_e2e_row(run_e2e_preflight()))
|
||||
|
||||
return results
|
||||
|
||||
|
||||
def run_cross_os(run_e2e: bool = False) -> int:
|
||||
"""Render only the cross-OS pre-flight group (`aipass doctor --cross-os`).
|
||||
|
||||
Returns the error (FAIL) count; warnings do not fail, matching run_doctor.
|
||||
When ``run_e2e`` is set (``--cross-os --e2e``), the heavy e2e suite runs too.
|
||||
"""
|
||||
console.print()
|
||||
console.print("[bold cyan]aipass doctor --cross-os[/bold cyan]")
|
||||
console.print("[dim]machine pre-flight (Layer-3-lite) — augments, never replaces, the human acceptance pass[/dim]")
|
||||
if run_e2e:
|
||||
console.print("[dim]--e2e: running the heavy Phase-2 e2e wiring suite (builds a wheel + fresh venv)…[/dim]")
|
||||
console.print()
|
||||
|
||||
checks = _check_cross_os(run_e2e=run_e2e)
|
||||
pass_count = 0
|
||||
warn_count = 0
|
||||
error_count = 0
|
||||
|
||||
console.print(" [bold]Cross-OS[/bold]")
|
||||
for check in checks:
|
||||
line = format_check(check.label, check.glyph, check.detail, check.remediation)
|
||||
console.print(line)
|
||||
if check.glyph == GLYPH_PASS:
|
||||
pass_count += 1
|
||||
elif check.glyph == GLYPH_WARN:
|
||||
warn_count += 1
|
||||
else:
|
||||
error_count += 1
|
||||
console.print()
|
||||
|
||||
console.print("[dim]─────────────────────────────────[/dim]")
|
||||
console.print(
|
||||
f" [green]✓ pass: {pass_count}[/green] "
|
||||
f"[yellow]! warnings: {warn_count}[/yellow] "
|
||||
f"[red]✗ errors: {error_count}[/red]"
|
||||
)
|
||||
console.print()
|
||||
|
||||
logger.info("[doctor] cross-os run — pass=%d warn=%d error=%d", pass_count, warn_count, error_count)
|
||||
return error_count
|
||||
|
||||
|
||||
def _record_path_arg(args: list[str]) -> str | None:
|
||||
"""Extract the optional PATH value following ``--record`` (None if absent).
|
||||
|
||||
``--record`` may stand alone (default path) or be followed by a path; a
|
||||
following token that starts with ``-`` is another flag, not the path.
|
||||
"""
|
||||
if "--record" not in args:
|
||||
return None
|
||||
idx = args.index("--record")
|
||||
if idx + 1 < len(args):
|
||||
candidate = args[idx + 1]
|
||||
if not candidate.startswith("-"):
|
||||
return candidate
|
||||
return None
|
||||
|
||||
|
||||
def run_cross_os_record(path: str | None = None, run_e2e: bool = False) -> int:
|
||||
"""Generate a machine pre-flight Run Record (`aipass doctor --cross-os --record`).
|
||||
|
||||
Thin console wrapper: generation (env capture + machine-provable rows, human
|
||||
rows left blank/marked) lives in the cross_os handler. Returns 0 on success,
|
||||
1 if the file could not be written (never crashes).
|
||||
"""
|
||||
console.print()
|
||||
console.print("[bold cyan]aipass doctor --cross-os --record[/bold cyan]")
|
||||
console.print(
|
||||
"[dim]machine pre-flight DRAFT — auto-fills what the machine can prove; "
|
||||
"a human still runs the real Layer-3 pass[/dim]"
|
||||
)
|
||||
if run_e2e:
|
||||
console.print("[dim]--e2e: running the heavy Phase-2 e2e suite (builds a wheel + fresh venv)…[/dim]")
|
||||
console.print()
|
||||
|
||||
try:
|
||||
written = generate_run_record(path, run_heavy_e2e=run_e2e)
|
||||
except RunRecordError as exc:
|
||||
cli_error(str(exc))
|
||||
logger.error("[doctor] cross-os run record failed: %s", exc)
|
||||
return 1
|
||||
|
||||
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)
|
||||
return 0
|
||||
|
||||
|
||||
# --- Main doctor run ---
|
||||
|
||||
|
||||
@@ -698,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
|
||||
@@ -750,10 +1081,16 @@ 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]")
|
||||
console.print(
|
||||
" [green]aipass doctor --cross-os --record [PATH][/green]"
|
||||
"[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()
|
||||
|
||||
@@ -782,6 +1119,21 @@ def handle_command(command: str, args: list[str]) -> bool:
|
||||
print_introspection()
|
||||
return True
|
||||
|
||||
if "--cross-os" in args:
|
||||
e2e = "--e2e" in args
|
||||
if "--record" in args:
|
||||
record_path = _record_path_arg(args)
|
||||
rc = run_cross_os_record(record_path, run_e2e=e2e)
|
||||
json_handler.log_operation("doctor_cross_os_record", {"path": record_path, "e2e": e2e, "rc": rc})
|
||||
if rc != 0:
|
||||
raise SystemExit(1)
|
||||
return True
|
||||
error_count = run_cross_os(run_e2e=e2e)
|
||||
json_handler.log_operation("doctor_cross_os", {"error_count": error_count, "e2e": e2e})
|
||||
if error_count > 0:
|
||||
raise SystemExit(1)
|
||||
return True
|
||||
|
||||
verbose = "--verbose" in args or "-v" in args
|
||||
fix_mode = "--fix" in args
|
||||
json_mode = "--json" in args
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
# =================== AIPass ====================
|
||||
# Name: init_flow.py
|
||||
# Description: 12-stage guided first-run setup — aipass init command
|
||||
# Version: 1.0.0
|
||||
# Description: 10-stage guided first-run setup — aipass init command
|
||||
# Version: 1.2.0
|
||||
# Created: 2026-04-16
|
||||
# Modified: 2026-04-16
|
||||
# Modified: 2026-07-04
|
||||
# =============================================
|
||||
|
||||
"""
|
||||
aipass init — guided first-run setup
|
||||
|
||||
12 resumable stages. State persists to .aipass/init_progress.json.
|
||||
10 resumable stages. State persists to .aipass/init_progress.json.
|
||||
Ctrl-C at any stage resumes next time from that stage.
|
||||
|
||||
Usage:
|
||||
@@ -17,9 +17,9 @@ Usage:
|
||||
aipass init run # interactive
|
||||
aipass init run --non-interactive # CI/headless, all defaults
|
||||
aipass init run --name YourName --cli claude
|
||||
aipass init run --dry-run # walk all 12 stages, no destructive ops
|
||||
# - skips drone @spawn create (stage 8)
|
||||
# - skips tmux/wt handoff (stage 11)
|
||||
aipass init run --dry-run # walk all 10 stages, no destructive ops
|
||||
# - skips drone @spawn create (stage 6)
|
||||
# - skips tmux/wt handoff (stage 9)
|
||||
# - does NOT write .aipass/init_progress.json
|
||||
"""
|
||||
|
||||
@@ -35,13 +35,13 @@ 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
|
||||
from aipass.aipass.apps.handlers.ui.progress import activity_spinner, render_step_header
|
||||
from aipass.aipass.apps.handlers.system_detect.system_detector import (
|
||||
detect_cpu,
|
||||
detect_docker,
|
||||
detect_git,
|
||||
detect_install_method,
|
||||
detect_os,
|
||||
@@ -62,7 +62,7 @@ except ImportError as _qe:
|
||||
HAS_QUESTIONARY = False
|
||||
|
||||
COMMAND = "init"
|
||||
TOTAL_STAGES = 12
|
||||
TOTAL_STAGES = 10
|
||||
|
||||
_BRANCH_ROOT = Path(__file__).resolve().parents[2]
|
||||
|
||||
@@ -95,7 +95,8 @@ STYLE_CHOICES = ["building-my-own-project", "improving-aipass", "just-exploring"
|
||||
TEMPLATE_EMPTY = "empty project"
|
||||
TEMPLATE_AIPASS = "aipass_framework"
|
||||
TEMPLATE_CHOICES = [TEMPLATE_EMPTY, TEMPLATE_AIPASS]
|
||||
AIPASS_SPECIFIC_STAGES = {8, 9, 11, 12}
|
||||
# first_agent, ping_sweep, handoff, done — skipped for empty (non-framework) projects
|
||||
AIPASS_SPECIFIC_STAGES = {6, 7, 9, 10}
|
||||
|
||||
|
||||
# --- LOCAL JSON HELPERS ---
|
||||
@@ -209,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 ---
|
||||
@@ -233,15 +234,47 @@ def stage_1_welcome(dry_run: bool = False) -> Dict[str, Any]:
|
||||
if dry_run:
|
||||
console.print("[yellow]\\[dry-run][/yellow] No state will be written, no subprocesses launched.")
|
||||
console.print()
|
||||
console.print("[bold cyan]Step 1/12[/bold cyan] — Welcome")
|
||||
console.print(render_step_header(1, TOTAL_STAGES, "Welcome"))
|
||||
_save_stage(1, dry_run=dry_run)
|
||||
return {}
|
||||
|
||||
|
||||
def _print_os_gap_heads_up() -> None:
|
||||
"""Print tracked cross-OS gaps that apply to this OS (machine pre-flight, not a guarantee).
|
||||
|
||||
Lightweight: one doc read + parse. Failures are non-fatal — init must not
|
||||
crash here. This is NOT the human acceptance pass; it just surfaces tracked
|
||||
gaps at the moment the user is on the box.
|
||||
"""
|
||||
try:
|
||||
from aipass.aipass.apps.handlers.cross_os import CrossOsGapError, gaps_for_platform
|
||||
except ImportError as exc:
|
||||
logger.warning("[init_flow] cross_os handler unavailable: %s", exc)
|
||||
return
|
||||
|
||||
try:
|
||||
gaps = gaps_for_platform(sys.platform)
|
||||
except CrossOsGapError as exc:
|
||||
logger.warning("[init_flow] cross-OS gap registry unavailable: %s", exc)
|
||||
warning("cross-OS gap registry unavailable — skipping OS heads-up.")
|
||||
return
|
||||
|
||||
if not gaps:
|
||||
return
|
||||
|
||||
console.print()
|
||||
console.print(
|
||||
f"[dim]Heads-up — {len(gaps)} tracked cross-OS gap(s) may apply on this OS "
|
||||
"(machine pre-flight, not a guarantee):[/dim]"
|
||||
)
|
||||
for gap in gaps:
|
||||
console.print(f" [yellow]![/yellow] [dim]gap #{gap.number}: {gap.symptom} [{gap.status}][/dim]")
|
||||
|
||||
|
||||
def stage_2_system_detect(non_interactive: bool = False, dry_run: bool = False) -> Dict[str, Any]:
|
||||
"""Detect OS, Python, shell, RAM, CPU, install method, and optional tools."""
|
||||
console.print()
|
||||
console.print("[bold cyan]Step 2/12[/bold cyan] — System detection")
|
||||
console.print(render_step_header(2, TOTAL_STAGES, "System detection"))
|
||||
|
||||
from rich.table import Table
|
||||
|
||||
@@ -254,7 +287,6 @@ def stage_2_system_detect(non_interactive: bool = False, dry_run: bool = False)
|
||||
install = detect_install_method()
|
||||
has_tmux = detect_tmux()
|
||||
has_wt = detect_wt()
|
||||
has_docker = detect_docker()
|
||||
|
||||
table = Table(show_header=False, box=None)
|
||||
table.add_column("key", style="cyan")
|
||||
@@ -269,7 +301,6 @@ def stage_2_system_detect(non_interactive: bool = False, dry_run: bool = False)
|
||||
table.add_row("tmux", "yes" if has_tmux else "no")
|
||||
if sys.platform == "win32":
|
||||
table.add_row("wt.exe", "yes" if has_wt else "no")
|
||||
table.add_row("docker", "yes" if has_docker else "no")
|
||||
console.print(table)
|
||||
|
||||
console.print()
|
||||
@@ -277,50 +308,21 @@ def stage_2_system_detect(non_interactive: bool = False, dry_run: bool = False)
|
||||
install_labels = {"dev": "development (editable source)", "pip": "pip", "clone": "git clone", "unknown": "unknown"}
|
||||
console.print(f"Install type: [cyan]{install_labels.get(install, install)}[/cyan]")
|
||||
|
||||
_print_os_gap_heads_up()
|
||||
|
||||
system_data: Dict[str, Any] = {
|
||||
"os": os_info["os_name"],
|
||||
"python": py["version"],
|
||||
"shell": sh["name"],
|
||||
"ram_gb": ram["total_gb"],
|
||||
"install": install,
|
||||
"has_docker": has_docker,
|
||||
"has_tmux": has_tmux,
|
||||
}
|
||||
_save_stage(2, system_data, dry_run=dry_run)
|
||||
return system_data
|
||||
|
||||
|
||||
def stage_3_doctor(non_interactive: bool = False, dry_run: bool = False) -> Dict[str, Any]:
|
||||
"""Run aipass doctor health checks inline."""
|
||||
console.print()
|
||||
console.print("[bold cyan]Step 3/12[/bold cyan] — System health check")
|
||||
|
||||
error_count = 0
|
||||
provider_gaps: Dict[str, Any] = {}
|
||||
try:
|
||||
from aipass.aipass.apps.modules import doctor
|
||||
|
||||
error_count = doctor.run_doctor(interactive=not non_interactive)
|
||||
try:
|
||||
for r in doctor._check_provider_manifest():
|
||||
if r.glyph != doctor.GLYPH_PASS:
|
||||
provider_gaps[r.label] = r.detail
|
||||
except Exception as exc:
|
||||
logger.warning("[init_flow] provider manifest check failed: %s", exc)
|
||||
except Exception as exc:
|
||||
logger.warning("[init_flow] doctor run failed: %s", exc)
|
||||
warning(f"Doctor check skipped: {exc}")
|
||||
|
||||
if error_count > 0:
|
||||
warning(f"{error_count} issue(s) found above — review when convenient.")
|
||||
else:
|
||||
console.print("[green]✓[/green] Health check passed.")
|
||||
|
||||
_save_stage(3, {"doctor_errors": error_count}, dry_run=dry_run)
|
||||
return {"doctor_errors": error_count, "provider_gaps": provider_gaps}
|
||||
|
||||
|
||||
def stage_4_user_profile(
|
||||
def stage_3_user_profile(
|
||||
non_interactive: bool = False,
|
||||
name_override: str | None = None,
|
||||
system_data: dict | None = None,
|
||||
@@ -328,7 +330,7 @@ def stage_4_user_profile(
|
||||
) -> Dict[str, Any]:
|
||||
"""Collect user name and OS, save to profile."""
|
||||
console.print()
|
||||
console.print("[bold cyan]Step 4/12[/bold cyan] — User profile")
|
||||
console.print(render_step_header(3, TOTAL_STAGES, "User profile"))
|
||||
|
||||
from aipass.aipass.apps.modules import profile as profile_mod
|
||||
|
||||
@@ -356,19 +358,19 @@ def stage_4_user_profile(
|
||||
else:
|
||||
profile_mod.save_profile(existing)
|
||||
|
||||
console.print(f"[green]✓[/green] Hello, {name}!")
|
||||
_save_stage(4, {"name": name}, dry_run=dry_run)
|
||||
success(f"Hello, {name}!")
|
||||
_save_stage(3, {"name": name}, dry_run=dry_run)
|
||||
return {"name": name}
|
||||
|
||||
|
||||
def stage_5_style_questions(
|
||||
def stage_4_style_questions(
|
||||
non_interactive: bool = False,
|
||||
style_override: str | None = None,
|
||||
dry_run: bool = False,
|
||||
) -> Dict[str, Any]:
|
||||
"""Ask what the user wants to do — routes tone of later stages."""
|
||||
console.print()
|
||||
console.print("[bold cyan]Step 5/12[/bold cyan] — What brings you here?")
|
||||
console.print(render_step_header(4, TOTAL_STAGES, "What brings you here?"))
|
||||
|
||||
if style_override and style_override in STYLE_CHOICES:
|
||||
style = style_override
|
||||
@@ -377,8 +379,8 @@ def stage_5_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}")
|
||||
_save_stage(5, {"style": style}, dry_run=dry_run)
|
||||
success(f"Got it: {style}")
|
||||
_save_stage(4, {"style": style}, dry_run=dry_run)
|
||||
return {"style": style}
|
||||
|
||||
|
||||
@@ -415,29 +417,29 @@ def _handle_missing_claude(non_interactive: bool) -> None:
|
||||
"""Prompt to install Claude Code when missing, or warn in non-interactive mode."""
|
||||
if non_interactive:
|
||||
warning("[bold yellow]Claude Code ('claude') is not installed.[/bold yellow]")
|
||||
console.print(" Stage 11 handoff requires it. Install manually before then.")
|
||||
console.print(" Stage 9 handoff requires it. Install manually before then.")
|
||||
return
|
||||
|
||||
raw = _prompt("Claude Code ('claude') not found. Install now? [Y/n]", "Y")
|
||||
if raw.lower() in ("y", "yes", ""):
|
||||
console.print("[dim]Installing Claude Code...[/dim]")
|
||||
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")
|
||||
else:
|
||||
console.print("[dim]Skipped. Stage 11 handoff will need 'claude' on PATH.[/dim]")
|
||||
console.print("[dim]Skipped. Stage 9 handoff will need 'claude' on PATH.[/dim]")
|
||||
|
||||
|
||||
def stage_6_tool_choice(
|
||||
def stage_5_tool_choice(
|
||||
non_interactive: bool = False,
|
||||
cli_override: str | None = None,
|
||||
dry_run: bool = False,
|
||||
) -> Dict[str, Any]:
|
||||
"""Choose CLI tool and launch flag variant."""
|
||||
console.print()
|
||||
console.print("[bold cyan]Step 6/12[/bold cyan] — CLI tool choice")
|
||||
console.print(render_step_header(5, TOTAL_STAGES, "CLI tool choice"))
|
||||
|
||||
if cli_override and cli_override in CLI_CHOICES:
|
||||
cli_choice = cli_override
|
||||
@@ -458,8 +460,8 @@ def stage_6_tool_choice(
|
||||
default="default",
|
||||
)
|
||||
|
||||
console.print(f"[green]✓[/green] {cli_choice} ({flag_variant})")
|
||||
_save_stage(6, {"cli": cli_choice, "flag_variant": flag_variant}, dry_run=dry_run)
|
||||
success(f"{cli_choice} ({flag_variant})")
|
||||
_save_stage(5, {"cli": cli_choice, "flag_variant": flag_variant}, dry_run=dry_run)
|
||||
|
||||
if dry_run:
|
||||
console.print(f"[yellow]\\[dry-run][/yellow] would save preferred_cli={cli_choice} to profile")
|
||||
@@ -476,37 +478,10 @@ def stage_6_tool_choice(
|
||||
return {"cli": cli_choice, "flag_variant": flag_variant}
|
||||
|
||||
|
||||
def stage_7_docker_offer(
|
||||
non_interactive: bool = False,
|
||||
no_docker: bool = False,
|
||||
has_docker: bool | None = None,
|
||||
dry_run: bool = False,
|
||||
) -> Dict[str, Any]:
|
||||
"""Offer Docker sandbox test if Docker is detected."""
|
||||
console.print()
|
||||
console.print("[bold cyan]Step 7/12[/bold cyan] — Docker")
|
||||
|
||||
if has_docker is None:
|
||||
has_docker = detect_docker()
|
||||
|
||||
if not has_docker or no_docker or non_interactive:
|
||||
reason = "not detected" if not has_docker else ("--no-docker" if no_docker else "non-interactive")
|
||||
console.print(f"[dim]Docker offer skipped ({reason}).[/dim]")
|
||||
_save_stage(7, {"docker": "skipped"}, dry_run=dry_run)
|
||||
return {"docker": "skipped"}
|
||||
|
||||
raw = _prompt("Test in a Docker sandbox? [y/N]", "N")
|
||||
use_docker = raw.lower() in ("y", "yes")
|
||||
result = "yes" if use_docker else "no"
|
||||
console.print(f"[green]✓[/green] Docker: {result}")
|
||||
_save_stage(7, {"docker": result}, dry_run=dry_run)
|
||||
return {"docker": result}
|
||||
|
||||
|
||||
def stage_8_first_agent(non_interactive: bool = False, dry_run: bool = False) -> Dict[str, Any]:
|
||||
def stage_6_first_agent(non_interactive: bool = False, dry_run: bool = False) -> Dict[str, Any]:
|
||||
"""Create the user's first AI agent via drone @spawn."""
|
||||
console.print()
|
||||
console.print("[bold cyan]Step 8/12[/bold cyan] — Create your first agent")
|
||||
console.print(render_step_header(6, TOTAL_STAGES, "Create your first agent"))
|
||||
console.print("Let's create your first AI agent (citizen).")
|
||||
|
||||
if non_interactive:
|
||||
@@ -519,41 +494,41 @@ def stage_8_first_agent(non_interactive: bool = False, dry_run: bool = False) ->
|
||||
agent_path = f"{package_dir}/{agent_name}"
|
||||
else:
|
||||
agent_path = f"src/{agent_name}"
|
||||
console.print(f"Running: [cyan]drone @spawn create {agent_path}[/cyan]")
|
||||
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 8: %s", exc)
|
||||
logger.warning("[init_flow] drone not found in stage 6: %s", exc)
|
||||
warning("drone not found — skipping agent creation.")
|
||||
except subprocess.TimeoutExpired as exc:
|
||||
logger.warning("[init_flow] spawn timed out in stage 8: %s", exc)
|
||||
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(8, {"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}
|
||||
|
||||
|
||||
def stage_9_ping_sweep(non_interactive: bool = False, dry_run: bool = False) -> Dict[str, Any]:
|
||||
def stage_7_ping_sweep(non_interactive: bool = False, dry_run: bool = False) -> Dict[str, Any]:
|
||||
"""Ping all registered branches via test-convention emails."""
|
||||
console.print()
|
||||
console.print("[bold cyan]Step 9/12[/bold cyan] — Pinging agents")
|
||||
console.print(render_step_header(7, TOTAL_STAGES, "Pinging agents"))
|
||||
|
||||
from aipass.aipass.apps.handlers import ping_sweep
|
||||
|
||||
branches = ping_sweep._discover_branches()
|
||||
if not branches:
|
||||
console.print("[dim] No branches registered yet — skipping ping sweep.[/dim]")
|
||||
_save_stage(9, {"results": {}, "skipped": True}, dry_run=dry_run)
|
||||
_save_stage(7, {"results": {}, "skipped": True}, dry_run=dry_run)
|
||||
return {"ping_results": {}}
|
||||
|
||||
# Standalone projects can't ping agents via drone (drone only knows AIPass's registry).
|
||||
@@ -565,7 +540,7 @@ def stage_9_ping_sweep(non_interactive: bool = False, dry_run: bool = False) ->
|
||||
else:
|
||||
console.print(f"[dim] Found {len(branches)} agent(s) in this project.[/dim]")
|
||||
console.print("[dim] Ping skipped — agents will be reachable after handoff (next step).[/dim]")
|
||||
_save_stage(9, {"results": {}, "skipped_standalone": True}, dry_run=dry_run)
|
||||
_save_stage(7, {"results": {}, "skipped_standalone": True}, dry_run=dry_run)
|
||||
return {"ping_results": {}}
|
||||
|
||||
console.print(f"[dim] Found {len(branches)} agent(s). Checking reachability...[/dim]")
|
||||
@@ -573,7 +548,8 @@ def stage_9_ping_sweep(non_interactive: bool = False, dry_run: bool = False) ->
|
||||
"[dim] (Agents with a running session will auto-ack; new agents will time out — that's normal.)[/dim]"
|
||||
)
|
||||
|
||||
results = ping_sweep.sweep_all_branches(timeout=10)
|
||||
with activity_spinner("Pinging agents…"):
|
||||
results = ping_sweep.sweep_all_branches(timeout=10)
|
||||
|
||||
for branch, status in results.items():
|
||||
if status == "ack":
|
||||
@@ -587,33 +563,35 @@ def stage_9_ping_sweep(non_interactive: bool = False, dry_run: bool = False) ->
|
||||
|
||||
summary = ping_sweep.sweep_summary(results)
|
||||
console.print(f" {summary}")
|
||||
_save_stage(9, {"results": results}, dry_run=dry_run)
|
||||
_save_stage(7, {"results": results}, dry_run=dry_run)
|
||||
return {"ping_results": results}
|
||||
|
||||
|
||||
def stage_10_smoke_test(non_interactive: bool = False, dry_run: bool = False) -> Dict[str, Any]:
|
||||
def stage_8_smoke_test(non_interactive: bool = False, dry_run: bool = False) -> Dict[str, Any]:
|
||||
"""Verify drone and aipass binaries are on PATH."""
|
||||
console.print()
|
||||
console.print("[bold cyan]Step 10/12[/bold cyan] — Smoke test")
|
||||
console.print(render_step_header(8, TOTAL_STAGES, "Smoke test"))
|
||||
|
||||
drone_bin = shutil.which("drone")
|
||||
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")
|
||||
|
||||
_save_stage(10, {"drone": drone_bin, "aipass": aipass_bin}, dry_run=dry_run)
|
||||
console.print("[dim]Full cross-OS pre-flight: aipass doctor --cross-os[/dim]")
|
||||
|
||||
_save_stage(8, {"drone": drone_bin, "aipass": aipass_bin}, dry_run=dry_run)
|
||||
return {"drone": drone_bin, "aipass": aipass_bin}
|
||||
|
||||
|
||||
def stage_11_handoff(
|
||||
def stage_9_handoff(
|
||||
cli_choice: str = "claude",
|
||||
flag_variant: str = "default",
|
||||
agent_path: str = "src/my_agent",
|
||||
@@ -623,7 +601,7 @@ def stage_11_handoff(
|
||||
) -> Dict[str, Any]:
|
||||
"""Launch user's chosen CLI — inline (same terminal) or new window."""
|
||||
console.print()
|
||||
console.print("[bold cyan]Step 11/12[/bold cyan] — Handoff")
|
||||
console.print(render_step_header(9, TOTAL_STAGES, "Handoff"))
|
||||
|
||||
init_prompt = "I just completed aipass init. I am ready to start. What should I do first?"
|
||||
|
||||
@@ -657,12 +635,12 @@ def stage_11_handoff(
|
||||
inline = choice != "2"
|
||||
|
||||
if inline:
|
||||
_save_stage(11, {"command": command, "launched": True, "inline": True}, dry_run=dry_run)
|
||||
_save_stage(12, dry_run=dry_run)
|
||||
_save_stage(9, {"command": command, "launched": True, "inline": True}, dry_run=dry_run)
|
||||
_save_stage(10, dry_run=dry_run)
|
||||
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
|
||||
|
||||
@@ -679,10 +657,24 @@ def stage_11_handoff(
|
||||
)
|
||||
|
||||
if not inline:
|
||||
_save_stage(11, {"command": command, "launched": launched}, dry_run=dry_run)
|
||||
_save_stage(9, {"command": command, "launched": launched}, dry_run=dry_run)
|
||||
return {"handoff_command": command, "launched": launched}
|
||||
|
||||
|
||||
def _collect_provider_gaps() -> Dict[str, Any]:
|
||||
"""Gather provider-manifest gaps for the init report — standalone, no full doctor run."""
|
||||
gaps: Dict[str, Any] = {}
|
||||
try:
|
||||
from aipass.aipass.apps.modules import doctor
|
||||
|
||||
for r in doctor._check_provider_manifest():
|
||||
if r.glyph != doctor.GLYPH_PASS:
|
||||
gaps[r.label] = r.detail
|
||||
except Exception as exc:
|
||||
logger.warning("[init_flow] provider manifest check failed: %s", exc)
|
||||
return gaps
|
||||
|
||||
|
||||
def _write_init_report(agent_path: str, accumulated: Dict[str, Any], dry_run: bool = False) -> None:
|
||||
"""Drop init_report.json into the agent's dropbox."""
|
||||
if dry_run or not agent_path:
|
||||
@@ -707,7 +699,7 @@ def _write_init_report(agent_path: str, accumulated: Dict[str, Any], dry_run: bo
|
||||
" drone @devpulse watchdog agent @target"
|
||||
),
|
||||
}
|
||||
provider_gaps = accumulated.get("provider_gaps", {})
|
||||
provider_gaps = _collect_provider_gaps()
|
||||
if provider_gaps:
|
||||
report["provider_gaps"] = provider_gaps
|
||||
report["provider_action"] = (
|
||||
@@ -719,12 +711,12 @@ def _write_init_report(agent_path: str, accumulated: Dict[str, Any], dry_run: bo
|
||||
logger.info("[init_flow] init report written to %s", report_path)
|
||||
|
||||
|
||||
def stage_12_done(accumulated: Dict[str, Any] | None = None, dry_run: bool = False) -> Dict[str, Any]:
|
||||
def stage_10_done(accumulated: Dict[str, Any] | None = None, dry_run: bool = False) -> Dict[str, Any]:
|
||||
"""Print completion summary and drop init report."""
|
||||
console.print()
|
||||
console.print("[bold cyan]Step 12/12[/bold cyan] — Done!")
|
||||
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]")
|
||||
@@ -732,7 +724,7 @@ def stage_12_done(accumulated: Dict[str, Any] | None = None, dry_run: bool = Fal
|
||||
console.print()
|
||||
if accumulated:
|
||||
_write_init_report(accumulated.get("agent_path", ""), accumulated, dry_run=dry_run)
|
||||
_save_stage(12, dry_run=dry_run)
|
||||
_save_stage(10, dry_run=dry_run)
|
||||
return {}
|
||||
|
||||
|
||||
@@ -778,15 +770,14 @@ def run_init(
|
||||
name: str | None = None,
|
||||
cli: str | None = None,
|
||||
style: str | None = None,
|
||||
no_docker: bool = False,
|
||||
dry_run: bool = False,
|
||||
template: str | None = None,
|
||||
) -> int:
|
||||
"""Run the 12-stage init flow. Returns 0 on success."""
|
||||
"""Run the 10-stage init flow. Returns 0 on success."""
|
||||
# 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
|
||||
@@ -806,7 +797,9 @@ def run_init(
|
||||
from aipass.aipass.apps.handlers.init.bootstrap import init_project
|
||||
|
||||
if not dry_run:
|
||||
init_project(cwd)
|
||||
with activity_spinner("Building project scaffold…"):
|
||||
init_project(cwd)
|
||||
success("Project scaffold ready")
|
||||
else:
|
||||
console.print("[yellow]\\[dry-run][/yellow] would create project scaffold")
|
||||
|
||||
@@ -814,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
|
||||
|
||||
@@ -826,17 +819,15 @@ def run_init(
|
||||
stage_fns = [
|
||||
(1, lambda: stage_1_welcome(dry_run=dry_run)),
|
||||
(2, lambda: stage_2_system_detect(non_interactive, dry_run=dry_run)),
|
||||
(3, lambda: stage_3_doctor(non_interactive, dry_run=dry_run)),
|
||||
(4, lambda: stage_4_user_profile(non_interactive, name, accumulated, dry_run=dry_run)),
|
||||
(5, lambda: stage_5_style_questions(non_interactive, style, dry_run=dry_run)),
|
||||
(6, lambda: stage_6_tool_choice(non_interactive, cli, dry_run=dry_run)),
|
||||
(7, lambda: stage_7_docker_offer(non_interactive, no_docker, accumulated.get("has_docker"), dry_run=dry_run)),
|
||||
(8, lambda: stage_8_first_agent(non_interactive, dry_run=dry_run)),
|
||||
(9, lambda: stage_9_ping_sweep(non_interactive, dry_run=dry_run)),
|
||||
(10, lambda: stage_10_smoke_test(non_interactive, dry_run=dry_run)),
|
||||
(3, lambda: stage_3_user_profile(non_interactive, name, accumulated, dry_run=dry_run)),
|
||||
(4, lambda: stage_4_style_questions(non_interactive, style, dry_run=dry_run)),
|
||||
(5, lambda: stage_5_tool_choice(non_interactive, cli, dry_run=dry_run)),
|
||||
(6, lambda: stage_6_first_agent(non_interactive, dry_run=dry_run)),
|
||||
(7, lambda: stage_7_ping_sweep(non_interactive, dry_run=dry_run)),
|
||||
(8, lambda: stage_8_smoke_test(non_interactive, dry_run=dry_run)),
|
||||
(
|
||||
11,
|
||||
lambda: stage_11_handoff(
|
||||
9,
|
||||
lambda: stage_9_handoff(
|
||||
accumulated.get("cli", "claude"),
|
||||
accumulated.get("flag_variant", "default"),
|
||||
accumulated.get("agent_path", "src/my_agent"),
|
||||
@@ -845,7 +836,7 @@ def run_init(
|
||||
accumulated=accumulated,
|
||||
),
|
||||
),
|
||||
(12, lambda: stage_12_done(accumulated=accumulated, dry_run=dry_run)),
|
||||
(10, lambda: stage_10_done(accumulated=accumulated, dry_run=dry_run)),
|
||||
]
|
||||
|
||||
for stage_num, fn in stage_fns:
|
||||
@@ -868,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
|
||||
@@ -881,12 +872,12 @@ def print_introspection() -> None:
|
||||
last = progress.get("last_completed_stage", 0)
|
||||
console.print()
|
||||
console.print("[bold cyan]init_flow Module[/bold cyan]")
|
||||
console.print("12-stage guided first-run setup, resumable")
|
||||
console.print("10-stage guided first-run setup, resumable")
|
||||
console.print()
|
||||
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]")
|
||||
@@ -904,11 +895,10 @@ def print_help() -> None:
|
||||
console.print(" [green]aipass init run --name YourName[/green] [dim]# pre-fill name[/dim]")
|
||||
console.print(" [green]aipass init run --cli claude[/green] [dim]# pre-fill CLI[/dim]")
|
||||
console.print(" [green]aipass init run --template <name>[/green] [dim]# select template[/dim]")
|
||||
console.print(" [green]aipass init run --no-docker[/green] [dim]# skip docker offer[/dim]")
|
||||
console.print(" [green]aipass init run --dry-run[/green] [dim]# walk all stages, no writes[/dim]")
|
||||
console.print(" [green]aipass init --list[/green] [dim]# list available templates[/dim]")
|
||||
console.print()
|
||||
console.print("[yellow]STAGES:[/yellow] 12 stages, each saved — resume on ctrl-C")
|
||||
console.print("[yellow]STAGES:[/yellow] 10 stages, each saved — resume on ctrl-C")
|
||||
console.print()
|
||||
|
||||
|
||||
@@ -921,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
|
||||
@@ -948,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
|
||||
|
||||
|
||||
@@ -962,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)
|
||||
|
||||
@@ -986,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
|
||||
@@ -1059,7 +1065,6 @@ def handle_command(command: str, args: list[str]) -> bool:
|
||||
cli = _flag_value("--cli")
|
||||
style = _flag_value("--style")
|
||||
template = _flag_value("--template")
|
||||
no_docker = "--no-docker" in run_args
|
||||
dry_run = "--dry-run" in run_args
|
||||
|
||||
result = run_init(
|
||||
@@ -1067,7 +1072,6 @@ def handle_command(command: str, args: list[str]) -> bool:
|
||||
name=name,
|
||||
cli=cli,
|
||||
style=style,
|
||||
no_docker=no_docker,
|
||||
dry_run=dry_run,
|
||||
template=template,
|
||||
)
|
||||
@@ -1087,11 +1091,21 @@ 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
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Windows terminals/pipes default to cp1252, which can't encode the Unicode
|
||||
# Rich emits (✓/✗, box-drawing). Reconfigure live streams to UTF-8 in place
|
||||
# so a direct `python -m ...init_flow` run doesn't crash printing its banner.
|
||||
# Guarded to win32 — mirrors apps/aipass.py (S190 / gap #1).
|
||||
if sys.platform == "win32":
|
||||
os.environ.setdefault("PYTHONUTF8", "1")
|
||||
for _stream in (sys.stdout, sys.stderr):
|
||||
if hasattr(_stream, "reconfigure"):
|
||||
_stream.reconfigure(encoding="utf-8", errors="replace")
|
||||
|
||||
handle_command("init", sys.argv[1:])
|
||||
|
||||
@@ -0,0 +1,450 @@
|
||||
# =================== AIPass ====================
|
||||
# Name: install.py
|
||||
# Description: aipass install — one-command PyPI bootstrap (clone + setup + handoff)
|
||||
# Version: 1.0.0
|
||||
# Created: 2026-07-05
|
||||
# Modified: 2026-07-05
|
||||
# =============================================
|
||||
|
||||
"""
|
||||
aipass install — one-command bootstrap of the whole framework
|
||||
|
||||
The missing half of `pip install aipass`. pip lands the *code* in site-packages;
|
||||
this command materializes a working, writable AIPass home and wires it up:
|
||||
|
||||
1. Resolve where AIPass should live (default ~/AIPass; --here / --path to steer).
|
||||
2. Fetch the framework there (git clone of the public repo) if not already present.
|
||||
3. Run the canonical setup.sh (venv, editable install, provider-hook wiring, binaries).
|
||||
4. Verify drone/aipass are on PATH, then hand off into `aipass init run` to
|
||||
scaffold a first project (interactive default; --no-init to skip, --with-init
|
||||
to force even headless). Init targets a sibling dir, never the engine tree.
|
||||
|
||||
Each step prints a Step k/N progress header. Streaming subprocesses (git, setup.sh)
|
||||
show a header + their own output + a result line — no spinner (the two renderers
|
||||
fight, per ui/progress.activity_spinner).
|
||||
|
||||
Usage:
|
||||
aipass install # interactive, then launches init
|
||||
aipass install --non-interactive # CI/headless (~/AIPass), stops before init
|
||||
aipass install --with-init # headless AND chain into init --non-interactive
|
||||
aipass install --no-init # install the engine only, skip the handoff
|
||||
aipass install --path ~/tools/aipass # explicit home
|
||||
aipass install --project ~/proj # where the first project scaffolds
|
||||
aipass install --here # install into the current directory
|
||||
aipass install --dry-run # walk all steps, no clone/setup/launch
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Dict
|
||||
|
||||
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
|
||||
|
||||
COMMAND = "install"
|
||||
TOTAL_STEPS = 4
|
||||
REPO_URL = "https://github.com/AIOSAI/AIPass.git"
|
||||
DEFAULT_HOME = Path.home() / "AIPass"
|
||||
# `aipass init` refuses to run inside the engine tree (its pre-flight blocks on a
|
||||
# parent registry), so the auto-handoff scaffolds the first project in a sibling.
|
||||
DEFAULT_PROJECT = Path.home() / "aipass-project"
|
||||
|
||||
# Clone can be slow on a cold network; setup.sh compiles a venv + installs deps.
|
||||
_CLONE_TIMEOUT = 600
|
||||
_SETUP_TIMEOUT = 1800
|
||||
|
||||
|
||||
def _prompt(msg: str, default: str = "") -> str:
|
||||
"""Simple input prompt with optional default (raises on Ctrl-C/EOF)."""
|
||||
display = f"{msg} [{default}]: " if default else f"{msg}: "
|
||||
try:
|
||||
val = input(display).strip()
|
||||
return val if val else default
|
||||
except (KeyboardInterrupt, EOFError):
|
||||
raise KeyboardInterrupt
|
||||
|
||||
|
||||
def _looks_like_aipass_tree(home: Path) -> bool:
|
||||
"""True if `home` already holds an AIPass source tree (idempotent re-install)."""
|
||||
if not home.is_dir():
|
||||
return False
|
||||
if (home / "setup.sh").is_file():
|
||||
return True
|
||||
return bool(list(home.glob("*_REGISTRY.json")))
|
||||
|
||||
|
||||
def _resolve_home(path: str | None, here: bool, non_interactive: bool) -> Path:
|
||||
"""Decide where AIPass lives — --here / --path / $AIPASS_HOME / prompt / default."""
|
||||
if here:
|
||||
return Path.cwd().resolve()
|
||||
if path:
|
||||
return Path(path).expanduser().resolve()
|
||||
env_home = os.environ.get("AIPASS_HOME", "").strip()
|
||||
if env_home and _looks_like_aipass_tree(Path(env_home).expanduser()):
|
||||
return Path(env_home).expanduser().resolve()
|
||||
if non_interactive:
|
||||
return DEFAULT_HOME.resolve()
|
||||
raw = _prompt("Where should AIPass live?", str(DEFAULT_HOME))
|
||||
return Path(raw).expanduser().resolve()
|
||||
|
||||
|
||||
def _clone_repo(home: Path, dry_run: bool) -> bool:
|
||||
"""git clone the public AIPass repo into `home`. Returns True on success."""
|
||||
if dry_run:
|
||||
console.print(f"[yellow]\\[dry-run][/yellow] would run: git clone --depth 1 {REPO_URL} {home}")
|
||||
return True
|
||||
if home.exists() and any(home.iterdir()):
|
||||
warning(f"{home} exists and is not empty — pass an empty --path, or remove it first.")
|
||||
return False
|
||||
if shutil.which("git") is None:
|
||||
warning("git not found — the installer needs git to fetch AIPass. Install git and retry.")
|
||||
return False
|
||||
home.parent.mkdir(parents=True, exist_ok=True)
|
||||
console.print("[cyan]Downloading AIPass[/cyan] [dim](git clone — this can take a minute)…[/dim]")
|
||||
try:
|
||||
proc = subprocess.run(["git", "clone", "--depth", "1", REPO_URL, str(home)], timeout=_CLONE_TIMEOUT)
|
||||
if proc.returncode == 0:
|
||||
return True
|
||||
logger.warning("[install] git clone exited %s", proc.returncode)
|
||||
except subprocess.TimeoutExpired as exc:
|
||||
logger.warning("[install] git clone timed out: %s", exc)
|
||||
warning("git clone timed out.")
|
||||
return False
|
||||
|
||||
|
||||
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: {' '.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:
|
||||
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)
|
||||
warning("setup.sh reported errors — see output above.")
|
||||
except (FileNotFoundError, subprocess.TimeoutExpired) as exc:
|
||||
logger.warning("[install] setup.sh failed: %s", exc)
|
||||
warning(f"setup failed: {exc}")
|
||||
return False
|
||||
|
||||
|
||||
def _resolve_aipass_bin(home: Path) -> str | None:
|
||||
"""Locate the aipass binary post-setup — PATH, then home/.venv/bin, then ~/.local/bin."""
|
||||
found = shutil.which("aipass")
|
||||
if found:
|
||||
return found
|
||||
for candidate in (home / ".venv" / "bin" / "aipass", Path.home() / ".local" / "bin" / "aipass"):
|
||||
if candidate.is_file():
|
||||
return str(candidate)
|
||||
return None
|
||||
|
||||
|
||||
def _verify_binaries(home: Path) -> Dict[str, str | None]:
|
||||
"""Report drone/aipass resolution after setup (PATH may lag in the live shell)."""
|
||||
drone = shutil.which("drone") or (
|
||||
str(home / ".venv" / "bin" / "drone") if (home / ".venv" / "bin" / "drone").is_file() else None
|
||||
)
|
||||
aipass = _resolve_aipass_bin(home)
|
||||
if drone:
|
||||
success(f"drone: {drone}")
|
||||
else:
|
||||
warning("drone not found after setup — check the setup output above.")
|
||||
if aipass:
|
||||
success(f"aipass: {aipass}")
|
||||
else:
|
||||
warning("aipass not found after setup — check the setup output above.")
|
||||
return {"drone": drone, "aipass": aipass}
|
||||
|
||||
|
||||
def _should_run_init(non_interactive: bool, with_init: bool, no_init: bool) -> bool:
|
||||
"""Decide whether to auto-launch init. --no-init wins; --with-init forces on.
|
||||
|
||||
Default: interactive flows chain into init ("one command, done"); headless
|
||||
flows stop at a wired engine and print the next command (safe for CI/Docker).
|
||||
"""
|
||||
if no_init:
|
||||
return False
|
||||
if with_init:
|
||||
return True
|
||||
return not non_interactive
|
||||
|
||||
|
||||
def _handoff_to_init(
|
||||
home: Path,
|
||||
aipass_bin: str | None,
|
||||
non_interactive: bool,
|
||||
dry_run: bool,
|
||||
project: str | None,
|
||||
run_it: bool,
|
||||
) -> None:
|
||||
"""Print the installed banner, then (optionally) launch init for a first project.
|
||||
|
||||
`aipass init` scaffolds a *new* project and refuses to run inside the engine
|
||||
tree (pre-flight blocks on a parent registry), so init targets a sibling
|
||||
directory (``--project`` or DEFAULT_PROJECT), never ``home``. When install ran
|
||||
headless, init is launched headless too so the whole chain stays non-blocking.
|
||||
"""
|
||||
console.print()
|
||||
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]")
|
||||
console.print(" [cyan]aipass init run[/cyan] [dim]# scaffold your first project on AIPass[/dim]")
|
||||
console.print()
|
||||
|
||||
if not run_it:
|
||||
console.print("[dim]Run 'aipass init run' in a fresh directory to start your first project.[/dim]")
|
||||
return
|
||||
|
||||
project_dir = _resolve_project_dir(project, non_interactive)
|
||||
if project_dir is None:
|
||||
return
|
||||
|
||||
if dry_run:
|
||||
console.print(f"[yellow]\\[dry-run][/yellow] would launch: aipass init run in {project_dir}")
|
||||
return
|
||||
if not aipass_bin:
|
||||
warning("Can't find the aipass binary yet — open a new terminal and run 'aipass init run'.")
|
||||
return
|
||||
|
||||
project_dir.mkdir(parents=True, exist_ok=True)
|
||||
console.print(f"[cyan]Launching guided setup[/cyan] [dim]in {project_dir}…[/dim]")
|
||||
cmd = [aipass_bin, "init", "run"]
|
||||
if non_interactive:
|
||||
cmd.append("--non-interactive")
|
||||
try:
|
||||
subprocess.run(cmd, cwd=str(project_dir))
|
||||
except (FileNotFoundError, OSError) as exc:
|
||||
logger.warning("[install] could not launch init: %s", exc)
|
||||
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:
|
||||
return Path(project).expanduser().resolve()
|
||||
if non_interactive:
|
||||
return DEFAULT_PROJECT.resolve()
|
||||
try:
|
||||
raw = _prompt("Project directory for your first project", str(DEFAULT_PROJECT))
|
||||
except KeyboardInterrupt:
|
||||
logger.info("[install] init handoff cancelled by user")
|
||||
console.print()
|
||||
return None
|
||||
return Path(raw).expanduser().resolve()
|
||||
|
||||
|
||||
def run_install(
|
||||
non_interactive: bool = False,
|
||||
path: str | None = None,
|
||||
here: bool = False,
|
||||
dry_run: bool = False,
|
||||
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()
|
||||
console.print("[bold cyan]AIPass — one-command install[/bold cyan]")
|
||||
if dry_run:
|
||||
console.print("[yellow]\\[dry-run][/yellow] No clone, no setup, no launch — walking the steps only.")
|
||||
|
||||
# Step 1 — resolve + fetch the framework home
|
||||
console.print()
|
||||
console.print(render_step_header(1, TOTAL_STEPS, "Preparing AIPass home"))
|
||||
try:
|
||||
home = _resolve_home(path, here, non_interactive)
|
||||
except KeyboardInterrupt:
|
||||
logger.info("[install] cancelled at home resolution by user")
|
||||
console.print()
|
||||
warning("Cancelled.")
|
||||
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):
|
||||
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:
|
||||
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, no_symlink=no_symlink, force_symlink=force_symlink):
|
||||
warning("Environment build failed — aborting install.")
|
||||
return 1
|
||||
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"))
|
||||
run_it = _should_run_init(non_interactive, with_init, no_init)
|
||||
_handoff_to_init(home, bins.get("aipass"), non_interactive, dry_run, project, run_it)
|
||||
|
||||
json_handler.log_operation(
|
||||
"aipass_install",
|
||||
{"home": str(home), "non_interactive": non_interactive, "dry_run": dry_run, "init": run_it},
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
def print_help() -> None:
|
||||
"""Print usage help for the install command."""
|
||||
console.print()
|
||||
console.print("[bold cyan]aipass install[/bold cyan] — one-command bootstrap of AIPass")
|
||||
console.print()
|
||||
console.print("[yellow]USAGE:[/yellow]")
|
||||
console.print(" [green]aipass install[/green] [dim]# interactive, then launches init[/dim]")
|
||||
console.print(" [green]aipass install --non-interactive[/green] [dim]# CI/headless (~/AIPass), no init[/dim]")
|
||||
console.print(" [green]aipass install --path DIR[/green] [dim]# explicit home[/dim]")
|
||||
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")
|
||||
console.print()
|
||||
|
||||
|
||||
def print_introspection() -> None:
|
||||
"""Show module info for install."""
|
||||
console.print()
|
||||
console.print("[bold cyan]install Module[/bold cyan]")
|
||||
console.print("One-command bootstrap: clone + setup.sh + verify + handoff")
|
||||
console.print()
|
||||
console.print(f"[dim]Default home: {DEFAULT_HOME}[/dim]")
|
||||
console.print(f"[dim]Source: {REPO_URL}[/dim]")
|
||||
console.print()
|
||||
|
||||
|
||||
def handle_command(command: str, args: list[str]) -> bool:
|
||||
"""Route install subcommands. Returns True if handled, False otherwise."""
|
||||
if command != COMMAND:
|
||||
return False
|
||||
|
||||
if args and args[0] in ("--help", "-h", "help"):
|
||||
print_help()
|
||||
return True
|
||||
if args and args[0] in ("--info", "info"):
|
||||
print_introspection()
|
||||
return True
|
||||
|
||||
# `aipass install` runs directly; `run` is accepted as an optional verb.
|
||||
run_args = args[1:] if args and args[0] == "run" else args
|
||||
|
||||
def _flag_value(flag: str) -> str | None:
|
||||
"""Extract the value after a named flag, or None if absent."""
|
||||
if flag not in run_args:
|
||||
return None
|
||||
idx = run_args.index(flag)
|
||||
return run_args[idx + 1] if idx + 1 < len(run_args) else None
|
||||
|
||||
non_interactive = "--non-interactive" in run_args
|
||||
dry_run = "--dry-run" in run_args
|
||||
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")
|
||||
|
||||
result = run_install(
|
||||
non_interactive=non_interactive,
|
||||
path=path,
|
||||
here=here,
|
||||
dry_run=dry_run,
|
||||
with_init=with_init,
|
||||
no_init=no_init,
|
||||
project=project,
|
||||
no_symlink=no_symlink,
|
||||
force_symlink=force_symlink,
|
||||
)
|
||||
json_handler.log_operation(
|
||||
"install_run",
|
||||
{"non_interactive": non_interactive, "dry_run": dry_run, "with_init": with_init, "exit": result},
|
||||
)
|
||||
sys.exit(result)
|
||||
@@ -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", {})
|
||||
|
||||
@@ -0,0 +1,839 @@
|
||||
# =================== AIPass ====================
|
||||
# Name: test_cross_os.py
|
||||
# Description: Tests for cross-OS gap registry parser + doctor/init integration
|
||||
# Version: 1.0.0
|
||||
# Created: 2026-07-02
|
||||
# Modified: 2026-07-02
|
||||
# =============================================
|
||||
|
||||
"""Tests for the cross-OS gap registry (TDPLAN-0011 slice 1).
|
||||
|
||||
Covers: parser happy path, platform filtering (win32/darwin/linux),
|
||||
fail-to-error (missing/malformed doc), _check_cross_os() row shape, the
|
||||
doctor --cross-os subcommand, and the init stage-2 heads-up wiring.
|
||||
"""
|
||||
|
||||
import contextlib
|
||||
import platform
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest # pyright: ignore[reportMissingImports]
|
||||
|
||||
from aipass.aipass.apps.handlers.cross_os import (
|
||||
CrossOsGap,
|
||||
CrossOsGapError,
|
||||
PreflightResult,
|
||||
RunRecordError,
|
||||
build_run_record,
|
||||
check_hookstatus,
|
||||
check_routing,
|
||||
check_versions,
|
||||
default_record_path,
|
||||
find_e2e_dir,
|
||||
find_gap_doc,
|
||||
gaps_for_platform,
|
||||
generate_run_record,
|
||||
load_gaps,
|
||||
os_matches,
|
||||
parse_gap_registry,
|
||||
run_e2e,
|
||||
)
|
||||
from aipass.aipass.apps.handlers.cross_os.preflight import E2E_UNRUNNABLE_PREFIX
|
||||
from aipass.aipass.apps.handlers.ui.progress import GLYPH_FAIL, GLYPH_PASS, GLYPH_WARN
|
||||
from aipass.aipass.apps.modules.doctor import (
|
||||
_check_cross_os,
|
||||
_cross_os_gap_rows,
|
||||
run_cross_os,
|
||||
run_cross_os_record,
|
||||
)
|
||||
|
||||
_HANDLER_MOD = "aipass.aipass.apps.handlers.cross_os.gap_registry"
|
||||
_PREFLIGHT_MOD = "aipass.aipass.apps.handlers.cross_os.preflight"
|
||||
_RECORD_MOD = "aipass.aipass.apps.handlers.cross_os.run_record"
|
||||
_DOCTOR_MOD = "aipass.aipass.apps.modules.doctor"
|
||||
_INIT_MOD = "aipass.aipass.apps.modules.init_flow"
|
||||
|
||||
|
||||
def _completed(returncode: int = 0, stdout: str = "", stderr: str = "") -> MagicMock:
|
||||
"""Build a fake subprocess.CompletedProcess for patching subprocess.run."""
|
||||
proc = MagicMock()
|
||||
proc.returncode = returncode
|
||||
proc.stdout = stdout
|
||||
proc.stderr = stderr
|
||||
return proc
|
||||
|
||||
|
||||
# A minimal but structurally-faithful copy of the registry section.
|
||||
SAMPLE_DOC = """# Some doc
|
||||
|
||||
## Known cross-OS gap registry (living — update as fixed)
|
||||
|
||||
Source of truth: DPLAN-0194.
|
||||
|
||||
| # | Gap | OS | Symptom | Owner | Status |
|
||||
|---|-----|----|---------| ------|--------|
|
||||
| 1 | cp1252 stdout | Win | UnicodeEncodeError on banner | aipass | fixed |
|
||||
| 7 | linux-only audio player | Win/mac | hook sound silent | hooks | suspected |
|
||||
| 9 | route masks errors | all | printed as Unknown command | aipass | recommended |
|
||||
|
||||
> a trailing footnote that is not a table row
|
||||
|
||||
## Run Record
|
||||
|
||||
should not be parsed
|
||||
"""
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _stub_handler_json():
|
||||
"""Suppress json_handler.log_operation side effects in the handler."""
|
||||
with patch(f"{_HANDLER_MOD}.json_handler") as mock:
|
||||
mock.log_operation = MagicMock()
|
||||
yield mock
|
||||
|
||||
|
||||
def _write_doc(root: Path, text: str = SAMPLE_DOC) -> Path:
|
||||
"""Create root/tests/CROSS_OS_TESTING.md with the given text."""
|
||||
doc = root / "tests" / "CROSS_OS_TESTING.md"
|
||||
doc.parent.mkdir(parents=True, exist_ok=True)
|
||||
doc.write_text(text, encoding="utf-8")
|
||||
return doc
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Parser
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class TestParseGapRegistry:
|
||||
def test_happy_path_row_count_and_fields(self) -> None:
|
||||
gaps = parse_gap_registry(SAMPLE_DOC)
|
||||
assert len(gaps) == 3
|
||||
first = gaps[0]
|
||||
assert isinstance(first, CrossOsGap)
|
||||
assert first.number == "1"
|
||||
assert first.gap == "cp1252 stdout"
|
||||
assert first.os == "Win"
|
||||
assert first.symptom == "UnicodeEncodeError on banner"
|
||||
assert first.owner == "aipass"
|
||||
assert first.status == "fixed"
|
||||
|
||||
def test_skips_header_separator_and_footnote(self) -> None:
|
||||
"""Only digit-led data rows are captured; the Run Record section is excluded."""
|
||||
gaps = parse_gap_registry(SAMPLE_DOC)
|
||||
numbers = [g.number for g in gaps]
|
||||
assert numbers == ["1", "7", "9"]
|
||||
|
||||
def test_missing_section_raises(self) -> None:
|
||||
with pytest.raises(CrossOsGapError):
|
||||
parse_gap_registry("# No registry here\n\njust prose\n")
|
||||
|
||||
def test_section_without_data_rows_raises(self) -> None:
|
||||
text = (
|
||||
"## Known cross-OS gap registry\n\n| # | Gap | OS | Symptom | Owner | Status |\n|---|---|---|---|---|---|\n"
|
||||
)
|
||||
with pytest.raises(CrossOsGapError):
|
||||
parse_gap_registry(text)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# os_matches — platform mapping
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class TestOsMatches:
|
||||
def test_all_matches_every_platform(self) -> None:
|
||||
for plat in ("win32", "darwin", "linux", "freebsd"):
|
||||
assert os_matches("all", plat) is True
|
||||
|
||||
def test_win_only(self) -> None:
|
||||
assert os_matches("Win", "win32") is True
|
||||
assert os_matches("Win", "darwin") is False
|
||||
assert os_matches("Win", "linux") is False
|
||||
|
||||
def test_win_mac_matches_both(self) -> None:
|
||||
assert os_matches("Win/mac", "win32") is True
|
||||
assert os_matches("Win/mac", "darwin") is True
|
||||
assert os_matches("Win/mac", "linux") is False
|
||||
|
||||
def test_case_insensitive(self) -> None:
|
||||
assert os_matches("WIN", "win32") is True
|
||||
assert os_matches("ALL", "linux") is True
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# find_gap_doc + load/filter (end-to-end via a temp doc)
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class TestFindAndFilter:
|
||||
def test_find_gap_doc_walks_up(self, tmp_path) -> None:
|
||||
doc = _write_doc(tmp_path)
|
||||
nested = tmp_path / "a" / "b" / "c"
|
||||
nested.mkdir(parents=True)
|
||||
found = find_gap_doc(nested)
|
||||
assert found == doc
|
||||
|
||||
def test_find_gap_doc_uses_live_repo_by_default(self) -> None:
|
||||
"""With no start arg, walks up from the handler file to the real repo doc."""
|
||||
doc = find_gap_doc()
|
||||
assert doc.name == "CROSS_OS_TESTING.md"
|
||||
assert doc.is_file()
|
||||
|
||||
def test_load_gaps_from_temp_doc(self, tmp_path) -> None:
|
||||
_write_doc(tmp_path)
|
||||
gaps = load_gaps(start=tmp_path)
|
||||
assert [g.number for g in gaps] == ["1", "7", "9"]
|
||||
|
||||
def test_filter_linux_only_all_rows(self, tmp_path) -> None:
|
||||
_write_doc(tmp_path)
|
||||
gaps = gaps_for_platform("linux", start=tmp_path)
|
||||
assert [g.number for g in gaps] == ["9"]
|
||||
|
||||
def test_filter_win32_gets_win_and_all(self, tmp_path) -> None:
|
||||
_write_doc(tmp_path)
|
||||
gaps = gaps_for_platform("win32", start=tmp_path)
|
||||
assert [g.number for g in gaps] == ["1", "7", "9"]
|
||||
|
||||
def test_filter_darwin_gets_mac_and_all(self, tmp_path) -> None:
|
||||
_write_doc(tmp_path)
|
||||
gaps = gaps_for_platform("darwin", start=tmp_path)
|
||||
assert [g.number for g in gaps] == ["7", "9"]
|
||||
|
||||
def test_default_platform_uses_sys_platform(self, tmp_path) -> None:
|
||||
_write_doc(tmp_path)
|
||||
with patch(f"{_HANDLER_MOD}.sys") as mock_sys:
|
||||
mock_sys.platform = "win32"
|
||||
gaps = gaps_for_platform(start=tmp_path)
|
||||
assert [g.number for g in gaps] == ["1", "7", "9"]
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Fail-to-error (never silently empty)
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class TestFailToError:
|
||||
def test_missing_doc_raises(self, tmp_path) -> None:
|
||||
with pytest.raises(CrossOsGapError):
|
||||
gaps_for_platform("linux", start=tmp_path)
|
||||
|
||||
def test_malformed_doc_raises(self, tmp_path) -> None:
|
||||
_write_doc(tmp_path, text="# no registry section at all\n")
|
||||
with pytest.raises(CrossOsGapError):
|
||||
load_gaps(start=tmp_path)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# _cross_os_gap_rows — OS-gap cross-reference row shape (slice 1 logic)
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class TestCrossOsGapRows:
|
||||
def test_gaps_become_warn_preflight_rows(self) -> None:
|
||||
fake = [
|
||||
CrossOsGap("2", ".venv symlink", "Win", "WinError 1314", "aipass", "untested"),
|
||||
]
|
||||
with patch(f"{_DOCTOR_MOD}.gaps_for_platform", return_value=fake):
|
||||
results = _cross_os_gap_rows()
|
||||
assert len(results) == 1
|
||||
row = results[0]
|
||||
assert row.glyph == GLYPH_WARN
|
||||
assert "gap #2" in row.label
|
||||
assert "pre-flight" in row.label
|
||||
assert row.detail.startswith("pre-flight:")
|
||||
assert "WinError 1314" in row.detail
|
||||
assert "aipass" in row.remediation
|
||||
|
||||
def test_no_gaps_emits_single_pass(self) -> None:
|
||||
with patch(f"{_DOCTOR_MOD}.gaps_for_platform", return_value=[]):
|
||||
results = _cross_os_gap_rows()
|
||||
assert len(results) == 1
|
||||
assert results[0].glyph == GLYPH_PASS
|
||||
assert "no tracked cross-OS gaps" in results[0].detail
|
||||
|
||||
def test_registry_error_emits_warn_not_silent(self) -> None:
|
||||
with patch(f"{_DOCTOR_MOD}.gaps_for_platform", side_effect=CrossOsGapError("doc gone")):
|
||||
results = _cross_os_gap_rows()
|
||||
assert len(results) == 1
|
||||
assert results[0].glyph == GLYPH_WARN
|
||||
assert "unavailable" in results[0].detail
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Pre-flight runners — routing / versions / hookstatus (mocked subprocess)
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class TestPreflightRunners:
|
||||
def test_routing_ok_both_routes_exit_zero(self) -> None:
|
||||
with patch(f"{_PREFLIGHT_MOD}.subprocess.run", return_value=_completed(0, "systems ok")):
|
||||
result = check_routing()
|
||||
assert isinstance(result, PreflightResult)
|
||||
assert result.ok is True
|
||||
assert "exit 0" in result.detail
|
||||
|
||||
def test_routing_fails_when_route_nonzero(self) -> None:
|
||||
# drone systems exits 0, @ai_mail route exits 1.
|
||||
with patch(f"{_PREFLIGHT_MOD}.subprocess.run", side_effect=[_completed(0), _completed(1, stderr="boom")]):
|
||||
result = check_routing()
|
||||
assert result.ok is False
|
||||
assert "@ai_mail" in result.detail
|
||||
|
||||
def test_routing_fails_to_error_on_missing_binary(self) -> None:
|
||||
with patch(f"{_PREFLIGHT_MOD}.subprocess.run", side_effect=FileNotFoundError("no drone")):
|
||||
result = check_routing()
|
||||
assert result.ok is False # never crashes
|
||||
|
||||
def test_versions_ok_captures_strings(self) -> None:
|
||||
with patch(
|
||||
f"{_PREFLIGHT_MOD}.subprocess.run",
|
||||
side_effect=[_completed(0, "drone v1.1.0"), _completed(0, "aipass 0.1.0")],
|
||||
):
|
||||
result = check_versions()
|
||||
assert result.ok is True
|
||||
assert "drone v1.1.0" in result.detail
|
||||
assert "aipass 0.1.0" in result.detail
|
||||
|
||||
def test_versions_fail_nonzero(self) -> None:
|
||||
with patch(
|
||||
f"{_PREFLIGHT_MOD}.subprocess.run",
|
||||
side_effect=[_completed(0, "drone v1.1.0"), _completed(1, stderr="nope")],
|
||||
):
|
||||
result = check_versions()
|
||||
assert result.ok is False
|
||||
|
||||
def test_hookstatus_ok(self) -> None:
|
||||
with patch(f"{_PREFLIGHT_MOD}.subprocess.run", return_value=_completed(0, "hook config viewer")):
|
||||
result = check_hookstatus()
|
||||
assert result.ok is True
|
||||
assert "hook config" in result.detail
|
||||
|
||||
def test_hookstatus_fail_to_error_on_timeout(self) -> None:
|
||||
import subprocess as _sp
|
||||
|
||||
with patch(f"{_PREFLIGHT_MOD}.subprocess.run", side_effect=_sp.TimeoutExpired(cmd="drone", timeout=1)):
|
||||
result = check_hookstatus()
|
||||
assert result.ok is False
|
||||
assert "timed out" in result.detail
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# run_e2e — heavy suite runner (mocked subprocess); dir/pytest resolution
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def _stub_preflight_json():
|
||||
"""Suppress json_handler.log_operation side effects in the preflight module."""
|
||||
with patch(f"{_PREFLIGHT_MOD}.json_handler") as mock:
|
||||
mock.log_operation = MagicMock()
|
||||
yield mock
|
||||
|
||||
|
||||
class TestRunE2e:
|
||||
def test_find_e2e_dir_locates_live_repo(self) -> None:
|
||||
found = find_e2e_dir()
|
||||
assert found is not None
|
||||
assert found.name == "e2e"
|
||||
assert found.is_dir()
|
||||
|
||||
def test_missing_dir_returns_unrunnable_warn(self, _stub_preflight_json) -> None:
|
||||
with patch(f"{_PREFLIGHT_MOD}.find_e2e_dir", return_value=None):
|
||||
result = run_e2e()
|
||||
assert result.ok is False
|
||||
assert result.detail.startswith(E2E_UNRUNNABLE_PREFIX)
|
||||
|
||||
def test_passing_suite_ok(self, tmp_path, _stub_preflight_json) -> None:
|
||||
e2e_dir = tmp_path / "tests" / "e2e"
|
||||
e2e_dir.mkdir(parents=True)
|
||||
with (
|
||||
patch(f"{_PREFLIGHT_MOD}.find_e2e_dir", return_value=e2e_dir),
|
||||
patch(f"{_PREFLIGHT_MOD}.subprocess.run", return_value=_completed(0, "14 passed in 18.15s")),
|
||||
):
|
||||
result = run_e2e()
|
||||
assert result.ok is True
|
||||
assert "14 passed" in result.detail
|
||||
|
||||
def test_failing_suite_not_ok_and_not_unrunnable(self, tmp_path, _stub_preflight_json) -> None:
|
||||
e2e_dir = tmp_path / "tests" / "e2e"
|
||||
e2e_dir.mkdir(parents=True)
|
||||
with (
|
||||
patch(f"{_PREFLIGHT_MOD}.find_e2e_dir", return_value=e2e_dir),
|
||||
patch(f"{_PREFLIGHT_MOD}.subprocess.run", return_value=_completed(1, "2 failed, 12 passed in 3s")),
|
||||
):
|
||||
result = run_e2e()
|
||||
assert result.ok is False
|
||||
assert not result.detail.startswith(E2E_UNRUNNABLE_PREFIX)
|
||||
assert "failed" in result.detail
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# _check_cross_os — composed group (gap rows + pre-flight rows + optional e2e)
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class TestCheckCrossOsComposed:
|
||||
def _patch_preflight(self, routing=None, versions=None, hookstatus=None):
|
||||
"""Patch the three light pre-flight runners with given PreflightResults."""
|
||||
routing = routing or PreflightResult("routing", True, "ok")
|
||||
versions = versions or PreflightResult("versions", True, "drone v1; aipass 0.1")
|
||||
hookstatus = hookstatus or PreflightResult("hookstatus", True, "config ok")
|
||||
return (
|
||||
patch(f"{_DOCTOR_MOD}.gaps_for_platform", return_value=[]),
|
||||
patch(f"{_DOCTOR_MOD}.check_routing", return_value=routing),
|
||||
patch(f"{_DOCTOR_MOD}.check_versions", return_value=versions),
|
||||
patch(f"{_DOCTOR_MOD}.check_hookstatus", return_value=hookstatus),
|
||||
)
|
||||
|
||||
def test_preflight_rows_pass_when_ok(self) -> None:
|
||||
with contextlib.ExitStack() as stack:
|
||||
for p in self._patch_preflight():
|
||||
stack.enter_context(p)
|
||||
results = _check_cross_os()
|
||||
labels = {r.label: r for r in results}
|
||||
assert labels["routing (pre-flight)"].glyph == GLYPH_PASS
|
||||
assert labels["versions (pre-flight)"].glyph == GLYPH_PASS
|
||||
assert labels["hookstatus (pre-flight)"].glyph == GLYPH_PASS
|
||||
assert labels["routing (pre-flight)"].detail.startswith("pre-flight:")
|
||||
|
||||
def test_preflight_fail_maps_to_fail_glyph(self) -> None:
|
||||
bad = PreflightResult("routing", False, "drone systems -> 1")
|
||||
with contextlib.ExitStack() as stack:
|
||||
for p in self._patch_preflight(routing=bad):
|
||||
stack.enter_context(p)
|
||||
results = _check_cross_os()
|
||||
row = next(r for r in results if r.label == "routing (pre-flight)")
|
||||
assert row.glyph == GLYPH_FAIL
|
||||
assert row.remediation # fail rows carry remediation
|
||||
|
||||
def test_e2e_not_run_by_default(self) -> None:
|
||||
with contextlib.ExitStack() as stack:
|
||||
for p in self._patch_preflight():
|
||||
stack.enter_context(p)
|
||||
mock_e2e = stack.enter_context(patch(f"{_DOCTOR_MOD}.run_e2e_preflight"))
|
||||
results = _check_cross_os()
|
||||
mock_e2e.assert_not_called()
|
||||
assert not any("e2e" in r.label for r in results)
|
||||
|
||||
def test_e2e_runs_when_flag_set_and_pass_maps_pass(self) -> None:
|
||||
with contextlib.ExitStack() as stack:
|
||||
for p in self._patch_preflight():
|
||||
stack.enter_context(p)
|
||||
mock_e2e = stack.enter_context(
|
||||
patch(f"{_DOCTOR_MOD}.run_e2e_preflight", return_value=PreflightResult("e2e", True, "14 passed"))
|
||||
)
|
||||
results = _check_cross_os(run_e2e=True)
|
||||
mock_e2e.assert_called_once()
|
||||
row = next(r for r in results if r.label == "e2e suite (pre-flight)")
|
||||
assert row.glyph == GLYPH_PASS
|
||||
|
||||
def test_e2e_real_failure_maps_fail(self) -> None:
|
||||
with contextlib.ExitStack() as stack:
|
||||
for p in self._patch_preflight():
|
||||
stack.enter_context(p)
|
||||
stack.enter_context(
|
||||
patch(
|
||||
f"{_DOCTOR_MOD}.run_e2e_preflight",
|
||||
return_value=PreflightResult("e2e", False, "2 failed, 12 passed"),
|
||||
)
|
||||
)
|
||||
results = _check_cross_os(run_e2e=True)
|
||||
row = next(r for r in results if r.label == "e2e suite (pre-flight)")
|
||||
assert row.glyph == GLYPH_FAIL
|
||||
|
||||
def test_e2e_unrunnable_maps_warn(self) -> None:
|
||||
unrunnable = PreflightResult("e2e", False, f"{E2E_UNRUNNABLE_PREFIX}: e2e dir not found")
|
||||
with contextlib.ExitStack() as stack:
|
||||
for p in self._patch_preflight():
|
||||
stack.enter_context(p)
|
||||
stack.enter_context(patch(f"{_DOCTOR_MOD}.run_e2e_preflight", return_value=unrunnable))
|
||||
results = _check_cross_os(run_e2e=True)
|
||||
row = next(r for r in results if r.label == "e2e suite (pre-flight)")
|
||||
assert row.glyph == GLYPH_WARN
|
||||
|
||||
def test_run_cross_os_returns_int_no_errors(self) -> None:
|
||||
with contextlib.ExitStack() as stack:
|
||||
for p in self._patch_preflight():
|
||||
stack.enter_context(p)
|
||||
stack.enter_context(patch(f"{_DOCTOR_MOD}.console"))
|
||||
rc = run_cross_os()
|
||||
assert rc == 0
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# doctor --cross-os subcommand routing
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class TestDoctorCrossOsCommand:
|
||||
def test_cross_os_flag_routes_to_run_cross_os(self) -> None:
|
||||
from aipass.aipass.apps.modules.doctor import handle_command
|
||||
|
||||
with (
|
||||
patch(f"{_DOCTOR_MOD}.run_cross_os", return_value=0) as mock_run,
|
||||
patch(f"{_DOCTOR_MOD}.json_handler"),
|
||||
):
|
||||
handled = handle_command("doctor", ["--cross-os"])
|
||||
assert handled is True
|
||||
mock_run.assert_called_once()
|
||||
|
||||
def test_cross_os_does_not_run_full_doctor(self) -> None:
|
||||
"""The subcommand must not invoke the default full run."""
|
||||
from aipass.aipass.apps.modules.doctor import handle_command
|
||||
|
||||
with (
|
||||
patch(f"{_DOCTOR_MOD}.run_cross_os", return_value=0),
|
||||
patch(f"{_DOCTOR_MOD}.run_doctor") as mock_full,
|
||||
patch(f"{_DOCTOR_MOD}.json_handler"),
|
||||
):
|
||||
handle_command("doctor", ["--cross-os"])
|
||||
mock_full.assert_not_called()
|
||||
|
||||
def test_cross_os_alone_stays_light_no_e2e(self) -> None:
|
||||
"""`--cross-os` without `--e2e` threads run_e2e=False."""
|
||||
from aipass.aipass.apps.modules.doctor import handle_command
|
||||
|
||||
with (
|
||||
patch(f"{_DOCTOR_MOD}.run_cross_os", return_value=0) as mock_run,
|
||||
patch(f"{_DOCTOR_MOD}.json_handler"),
|
||||
):
|
||||
handle_command("doctor", ["--cross-os"])
|
||||
mock_run.assert_called_once_with(run_e2e=False)
|
||||
|
||||
def test_cross_os_with_e2e_flag_threads_run_e2e_true(self) -> None:
|
||||
"""Both `--cross-os` and `--e2e` present -> run_cross_os(run_e2e=True)."""
|
||||
from aipass.aipass.apps.modules.doctor import handle_command
|
||||
|
||||
with (
|
||||
patch(f"{_DOCTOR_MOD}.run_cross_os", return_value=0) as mock_run,
|
||||
patch(f"{_DOCTOR_MOD}.json_handler"),
|
||||
):
|
||||
handle_command("doctor", ["--cross-os", "--e2e"])
|
||||
mock_run.assert_called_once_with(run_e2e=True)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# init stage-2 heads-up wiring
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class TestInitStage2HeadsUp:
|
||||
def test_heads_up_prints_gaps(self) -> None:
|
||||
from aipass.aipass.apps.modules.init_flow import _print_os_gap_heads_up
|
||||
|
||||
fake = [CrossOsGap("9", "route masks", "all", "printed as Unknown command", "aipass", "rec")]
|
||||
with (
|
||||
patch("aipass.aipass.apps.handlers.cross_os.gaps_for_platform", return_value=fake),
|
||||
patch(f"{_INIT_MOD}.console") as mock_console,
|
||||
):
|
||||
_print_os_gap_heads_up()
|
||||
printed = " ".join(str(c.args[0]) for c in mock_console.print.call_args_list if c.args)
|
||||
assert "gap #9" in printed
|
||||
assert "Unknown command" in printed
|
||||
|
||||
def test_heads_up_no_gaps_prints_nothing(self) -> None:
|
||||
from aipass.aipass.apps.modules.init_flow import _print_os_gap_heads_up
|
||||
|
||||
with (
|
||||
patch("aipass.aipass.apps.handlers.cross_os.gaps_for_platform", return_value=[]),
|
||||
patch(f"{_INIT_MOD}.console") as mock_console,
|
||||
):
|
||||
_print_os_gap_heads_up()
|
||||
mock_console.print.assert_not_called()
|
||||
|
||||
def test_heads_up_error_warns_and_does_not_crash(self) -> None:
|
||||
from aipass.aipass.apps.modules.init_flow import _print_os_gap_heads_up
|
||||
|
||||
with (
|
||||
patch(
|
||||
"aipass.aipass.apps.handlers.cross_os.gaps_for_platform",
|
||||
side_effect=CrossOsGapError("doc missing"),
|
||||
),
|
||||
patch(f"{_INIT_MOD}.console"),
|
||||
patch(f"{_INIT_MOD}.warning") as mock_warning,
|
||||
):
|
||||
_print_os_gap_heads_up() # must not raise
|
||||
mock_warning.assert_called_once()
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Run Record generator (slice 3) — build_run_record
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def _line_starting(text: str, prefix: str) -> str:
|
||||
"""Return the first line in ``text`` starting with ``prefix`` (or "")."""
|
||||
for line in text.splitlines():
|
||||
if line.startswith(prefix):
|
||||
return line
|
||||
return ""
|
||||
|
||||
|
||||
class TestBuildRunRecord:
|
||||
def _patch(self, stack, gaps=None, routing=None, hooks=None):
|
||||
"""Patch the record's live inputs (gaps + light pre-flight runners)."""
|
||||
gaps = gaps if gaps is not None else []
|
||||
routing = routing or PreflightResult("routing", True, "drone systems exit 0; @ai_mail route exit 0")
|
||||
hooks = hooks or PreflightResult("hookstatus", True, "hook config viewer")
|
||||
stack.enter_context(patch(f"{_RECORD_MOD}.gaps_for_platform", return_value=gaps))
|
||||
stack.enter_context(patch(f"{_RECORD_MOD}.check_routing", return_value=routing))
|
||||
stack.enter_context(patch(f"{_RECORD_MOD}.check_hookstatus", return_value=hooks))
|
||||
|
||||
def test_env_fields_present_and_filled(self) -> None:
|
||||
with contextlib.ExitStack() as stack:
|
||||
self._patch(stack)
|
||||
text = build_run_record(platform_name="linux")
|
||||
for label in ("Machine/VM", "OS + version", "Arch", "Python", "Shell / term", "AIPASS_HOME", "Date :"):
|
||||
assert label in text
|
||||
# A real, machine-knowable fact is actually filled in (not left blank).
|
||||
assert platform.python_version() in text
|
||||
assert (platform.machine() or "unknown") in text
|
||||
|
||||
def test_header_marks_machine_preflight_draft(self) -> None:
|
||||
with contextlib.ExitStack() as stack:
|
||||
self._patch(stack)
|
||||
text = build_run_record(platform_name="linux")
|
||||
assert "pre-flight DRAFT" in text
|
||||
assert "human must complete" in text.lower()
|
||||
|
||||
def test_machine_rows_auto_ticked_pass(self) -> None:
|
||||
with contextlib.ExitStack() as stack:
|
||||
self._patch(stack)
|
||||
text = build_run_record(platform_name="linux")
|
||||
phase0 = _line_starting(text, "Phase 0")
|
||||
phase4 = _line_starting(text, "Phase 4")
|
||||
assert "✅" in phase0 and "machine" in phase0
|
||||
assert "✅" in phase4
|
||||
assert "drone systems exit 0" in phase4
|
||||
|
||||
def test_routing_fail_marks_fail_glyph(self) -> None:
|
||||
bad = PreflightResult("routing", False, "drone systems -> 1")
|
||||
with contextlib.ExitStack() as stack:
|
||||
self._patch(stack, routing=bad)
|
||||
text = build_run_record(platform_name="linux")
|
||||
phase4 = _line_starting(text, "Phase 4")
|
||||
assert "❌" in phase4
|
||||
assert "✅" not in phase4
|
||||
|
||||
def test_human_rows_marked_and_never_auto_ticked(self) -> None:
|
||||
"""Human-only rows must carry the human marker and NEVER the machine ✅."""
|
||||
with contextlib.ExitStack() as stack:
|
||||
self._patch(stack)
|
||||
text = build_run_record(platform_name="linux")
|
||||
for prefix in (
|
||||
"Phase 1 clean install",
|
||||
"Phase 3 aipass init",
|
||||
"Phase 5 daemons",
|
||||
"Phase 7 interactive",
|
||||
"Per-branch matrix",
|
||||
):
|
||||
line = _line_starting(text, prefix)
|
||||
assert line, f"missing row: {prefix}"
|
||||
assert "— human" in line
|
||||
assert "✅" not in line
|
||||
# Overall verdict stays human, never a machine tick.
|
||||
verdict = _line_starting(text, "Overall verdict")
|
||||
assert "— human" in verdict
|
||||
assert "✅" not in verdict
|
||||
|
||||
def test_commit_and_tester_left_blank_with_hint(self) -> None:
|
||||
with contextlib.ExitStack() as stack:
|
||||
self._patch(stack)
|
||||
text = build_run_record(platform_name="linux")
|
||||
commit = _line_starting(text, "Commit")
|
||||
# No value filled — just the hint on how a human fills it.
|
||||
assert "drone @git log -1" in commit
|
||||
tester = _line_starting(text, "Tester")
|
||||
# Tester side is blank; the Date side is machine-filled.
|
||||
assert tester.split("Date", 1)[0].replace("Tester", "").strip(" :") == ""
|
||||
|
||||
def test_phase6_hookstatus_machine_sound_human(self) -> None:
|
||||
with contextlib.ExitStack() as stack:
|
||||
self._patch(stack)
|
||||
text = build_run_record(platform_name="linux")
|
||||
phase6 = _line_starting(text, "Phase 6")
|
||||
assert "✅" in phase6 # hookstatus machine-proved
|
||||
assert "sound" in phase6 and "— human" in phase6 # audible cue stays human
|
||||
|
||||
def test_e2e_not_run_marked_by_default(self) -> None:
|
||||
with contextlib.ExitStack() as stack:
|
||||
self._patch(stack)
|
||||
mock_e2e = stack.enter_context(patch(f"{_RECORD_MOD}.run_e2e"))
|
||||
text = build_run_record(platform_name="linux", run_heavy_e2e=False)
|
||||
mock_e2e.assert_not_called()
|
||||
phase2 = _line_starting(text, "Phase 2")
|
||||
assert "not run" in phase2
|
||||
assert "— human" in phase2
|
||||
assert "✅" not in phase2
|
||||
|
||||
def test_e2e_recorded_when_flag_set(self) -> None:
|
||||
with contextlib.ExitStack() as stack:
|
||||
self._patch(stack)
|
||||
mock_e2e = stack.enter_context(
|
||||
patch(f"{_RECORD_MOD}.run_e2e", return_value=PreflightResult("e2e", True, "14 passed in 18.15s"))
|
||||
)
|
||||
text = build_run_record(platform_name="linux", run_heavy_e2e=True)
|
||||
mock_e2e.assert_called_once()
|
||||
phase2 = _line_starting(text, "Phase 2")
|
||||
assert "✅" in phase2
|
||||
assert "14 passed" in phase2
|
||||
|
||||
def test_e2e_unrunnable_marked_could_not_run(self) -> None:
|
||||
unrunnable = PreflightResult("e2e", False, f"{E2E_UNRUNNABLE_PREFIX}: e2e dir not found")
|
||||
with contextlib.ExitStack() as stack:
|
||||
self._patch(stack)
|
||||
stack.enter_context(patch(f"{_RECORD_MOD}.run_e2e", return_value=unrunnable))
|
||||
text = build_run_record(platform_name="linux", run_heavy_e2e=True)
|
||||
phase2 = _line_starting(text, "Phase 2")
|
||||
assert "could not run" in phase2
|
||||
assert "✅" not in phase2
|
||||
|
||||
def test_watch_items_list_platform_gaps(self) -> None:
|
||||
fake = [CrossOsGap("9", "route masks", "all", "printed as Unknown command", "aipass", "rec")]
|
||||
with contextlib.ExitStack() as stack:
|
||||
self._patch(stack, gaps=fake)
|
||||
text = build_run_record(platform_name="linux")
|
||||
assert "Watch items" in text
|
||||
assert "gap #9" in text
|
||||
assert "Unknown command" in text
|
||||
|
||||
def test_watch_items_none_tracked_note(self) -> None:
|
||||
with contextlib.ExitStack() as stack:
|
||||
self._patch(stack, gaps=[])
|
||||
text = build_run_record(platform_name="linux")
|
||||
assert "none tracked for linux" in text
|
||||
|
||||
def test_watch_items_registry_error_degrades_not_crash(self) -> None:
|
||||
with contextlib.ExitStack() as stack:
|
||||
stack.enter_context(patch(f"{_RECORD_MOD}.gaps_for_platform", side_effect=CrossOsGapError("doc gone")))
|
||||
stack.enter_context(
|
||||
patch(f"{_RECORD_MOD}.check_routing", return_value=PreflightResult("routing", True, "ok"))
|
||||
)
|
||||
stack.enter_context(
|
||||
patch(f"{_RECORD_MOD}.check_hookstatus", return_value=PreflightResult("hookstatus", True, "ok"))
|
||||
)
|
||||
text = build_run_record(platform_name="linux") # must not raise
|
||||
assert "registry unavailable" in text
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Run Record generator (slice 3) — generate_run_record (file writing)
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class TestGenerateRunRecord:
|
||||
def _patch(self, stack):
|
||||
"""Patch live inputs + json_handler side effect for the writing path."""
|
||||
stack.enter_context(patch(f"{_RECORD_MOD}.gaps_for_platform", return_value=[]))
|
||||
stack.enter_context(patch(f"{_RECORD_MOD}.check_routing", return_value=PreflightResult("routing", True, "ok")))
|
||||
stack.enter_context(
|
||||
patch(f"{_RECORD_MOD}.check_hookstatus", return_value=PreflightResult("hookstatus", True, "ok"))
|
||||
)
|
||||
stack.enter_context(patch(f"{_RECORD_MOD}.json_handler"))
|
||||
|
||||
def test_writes_to_given_path(self, tmp_path) -> None:
|
||||
target = tmp_path / "rr.txt"
|
||||
with contextlib.ExitStack() as stack:
|
||||
self._patch(stack)
|
||||
written = generate_run_record(str(target))
|
||||
assert written == target
|
||||
assert target.is_file()
|
||||
assert "AIPass Cross-OS Run Record" in target.read_text(encoding="utf-8")
|
||||
|
||||
def test_creates_missing_parent_dirs(self, tmp_path) -> None:
|
||||
target = tmp_path / "nested" / "deep" / "rr.txt"
|
||||
with contextlib.ExitStack() as stack:
|
||||
self._patch(stack)
|
||||
written = generate_run_record(str(target))
|
||||
assert written.is_file()
|
||||
|
||||
def test_default_path_in_cwd_when_none(self, tmp_path, monkeypatch) -> None:
|
||||
monkeypatch.chdir(tmp_path)
|
||||
with contextlib.ExitStack() as stack:
|
||||
self._patch(stack)
|
||||
written = generate_run_record(None)
|
||||
assert written.parent == tmp_path
|
||||
assert written.name.startswith("aipass-crossos-record-")
|
||||
assert written.is_file()
|
||||
|
||||
def test_default_record_path_helper_uses_cwd(self, tmp_path, monkeypatch) -> None:
|
||||
monkeypatch.chdir(tmp_path)
|
||||
path = default_record_path()
|
||||
assert path.parent == tmp_path
|
||||
assert path.name.startswith("aipass-crossos-record-")
|
||||
|
||||
def test_write_error_raises_run_record_error(self, tmp_path) -> None:
|
||||
# Target an existing directory → write_text raises OSError → RunRecordError.
|
||||
target = tmp_path / "adir"
|
||||
target.mkdir()
|
||||
with contextlib.ExitStack() as stack:
|
||||
self._patch(stack)
|
||||
with pytest.raises(RunRecordError):
|
||||
generate_run_record(str(target))
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# doctor --cross-os --record subcommand routing + thin wrapper
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class TestDoctorCrossOsRecordCommand:
|
||||
def test_record_flag_routes_to_run_cross_os_record(self) -> None:
|
||||
from aipass.aipass.apps.modules.doctor import handle_command
|
||||
|
||||
with (
|
||||
patch(f"{_DOCTOR_MOD}.run_cross_os_record", return_value=0) as mock_rec,
|
||||
patch(f"{_DOCTOR_MOD}.run_cross_os") as mock_plain,
|
||||
patch(f"{_DOCTOR_MOD}.json_handler"),
|
||||
):
|
||||
handled = handle_command("doctor", ["--cross-os", "--record", "record.txt"])
|
||||
assert handled is True
|
||||
mock_rec.assert_called_once_with("record.txt", run_e2e=False)
|
||||
mock_plain.assert_not_called() # record path does not also run the plain group
|
||||
|
||||
def test_record_default_path_when_no_value(self) -> None:
|
||||
from aipass.aipass.apps.modules.doctor import handle_command
|
||||
|
||||
with (
|
||||
patch(f"{_DOCTOR_MOD}.run_cross_os_record", return_value=0) as mock_rec,
|
||||
patch(f"{_DOCTOR_MOD}.json_handler"),
|
||||
):
|
||||
handle_command("doctor", ["--cross-os", "--record"])
|
||||
mock_rec.assert_called_once_with(None, run_e2e=False)
|
||||
|
||||
def test_record_with_e2e_threads_run_e2e_true(self) -> None:
|
||||
from aipass.aipass.apps.modules.doctor import handle_command
|
||||
|
||||
with (
|
||||
patch(f"{_DOCTOR_MOD}.run_cross_os_record", return_value=0) as mock_rec,
|
||||
patch(f"{_DOCTOR_MOD}.json_handler"),
|
||||
):
|
||||
handle_command("doctor", ["--cross-os", "--record", "--e2e"])
|
||||
# --e2e after --record is a flag, not the path -> path None, e2e threaded True.
|
||||
mock_rec.assert_called_once_with(None, run_e2e=True)
|
||||
|
||||
def test_record_write_failure_exits_nonzero(self) -> None:
|
||||
from aipass.aipass.apps.modules.doctor import handle_command
|
||||
|
||||
with (
|
||||
patch(f"{_DOCTOR_MOD}.run_cross_os_record", return_value=1),
|
||||
patch(f"{_DOCTOR_MOD}.json_handler"),
|
||||
):
|
||||
with pytest.raises(SystemExit):
|
||||
handle_command("doctor", ["--cross-os", "--record", "record.txt"])
|
||||
|
||||
def test_run_cross_os_record_returns_zero_on_success(self) -> None:
|
||||
with (
|
||||
patch(f"{_DOCTOR_MOD}.generate_run_record", return_value=Path("record.txt")),
|
||||
patch(f"{_DOCTOR_MOD}.console"),
|
||||
):
|
||||
assert run_cross_os_record("record.txt") == 0
|
||||
|
||||
def test_run_cross_os_record_returns_one_on_write_error(self) -> None:
|
||||
with (
|
||||
patch(f"{_DOCTOR_MOD}.generate_run_record", side_effect=RunRecordError("disk full")),
|
||||
patch(f"{_DOCTOR_MOD}.console"),
|
||||
):
|
||||
assert run_cross_os_record("record.txt") == 1
|
||||
@@ -28,8 +28,10 @@ from aipass.aipass.apps.handlers.ui.progress import (
|
||||
GLYPH_FAIL,
|
||||
GLYPH_PASS,
|
||||
GLYPH_WARN,
|
||||
activity_spinner,
|
||||
format_check,
|
||||
make_doctor_progress,
|
||||
render_step_header,
|
||||
)
|
||||
from aipass.aipass.apps.modules.doctor import handle_command, run_doctor
|
||||
|
||||
@@ -273,6 +275,36 @@ class TestProgressHelpers:
|
||||
prog = make_doctor_progress()
|
||||
assert isinstance(prog, Progress)
|
||||
|
||||
def test_render_step_header_shows_step_and_label(self) -> None:
|
||||
"""Header carries the step counter and the stage label."""
|
||||
line = render_step_header(3, 10, "User profile")
|
||||
assert "Step 3/10" in line
|
||||
assert "User profile" in line
|
||||
|
||||
def test_render_step_header_bar_fills_with_progress(self) -> None:
|
||||
"""The bar has more filled cells later in the flow, full at the end."""
|
||||
early = render_step_header(1, 10, "x", width=18)
|
||||
late = render_step_header(10, 10, "x", width=18)
|
||||
assert late.count("█") > early.count("█")
|
||||
assert late.count("█") == 18
|
||||
|
||||
def test_render_step_header_clamps_out_of_range(self) -> None:
|
||||
"""current > total and total <= 0 are clamped — no overflow, no div-by-zero."""
|
||||
over = render_step_header(15, 10, "x", width=18)
|
||||
assert over.count("█") == 18
|
||||
zero = render_step_header(0, 0, "x", width=18)
|
||||
assert "Step 0/1" in zero
|
||||
|
||||
def test_activity_spinner_yields_progress_and_runs_block(self) -> None:
|
||||
"""activity_spinner is a context manager yielding a Progress; block runs."""
|
||||
from rich.progress import Progress
|
||||
|
||||
ran = []
|
||||
with activity_spinner("doing a thing…") as prog:
|
||||
assert isinstance(prog, Progress)
|
||||
ran.append(True)
|
||||
assert ran == [True]
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# TestDoctorHandleCommand
|
||||
@@ -626,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 == []
|
||||
|
||||
@@ -640,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
|
||||
@@ -656,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
|
||||
@@ -674,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
|
||||
@@ -695,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
|
||||
@@ -712,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
|
||||
@@ -726,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
|
||||
@@ -738,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
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
# Description: Tests for aipass init_flow Phase 3
|
||||
# Version: 1.0.0
|
||||
# Created: 2026-04-16
|
||||
# Modified: 2026-04-16
|
||||
# Modified: 2026-07-04
|
||||
# =============================================
|
||||
|
||||
"""Tests for aipass init_flow module — Phase 3 (FPLAN-0188)."""
|
||||
@@ -31,16 +31,14 @@ from aipass.aipass.apps.modules.init_flow import (
|
||||
run_init,
|
||||
stage_1_welcome,
|
||||
stage_2_system_detect,
|
||||
stage_3_doctor,
|
||||
stage_4_user_profile,
|
||||
stage_5_style_questions,
|
||||
stage_6_tool_choice,
|
||||
stage_7_docker_offer,
|
||||
stage_8_first_agent,
|
||||
stage_9_ping_sweep,
|
||||
stage_10_smoke_test,
|
||||
stage_11_handoff,
|
||||
stage_12_done,
|
||||
stage_3_user_profile,
|
||||
stage_4_style_questions,
|
||||
stage_5_tool_choice,
|
||||
stage_6_first_agent,
|
||||
stage_7_ping_sweep,
|
||||
stage_8_smoke_test,
|
||||
stage_9_handoff,
|
||||
stage_10_done,
|
||||
)
|
||||
|
||||
|
||||
@@ -245,9 +243,9 @@ class TestHandleCommand:
|
||||
with patch("aipass.aipass.apps.modules.init_flow._preflight_check", return_value=None):
|
||||
with patch("aipass.aipass.apps.modules.init_flow._handle_init_scaffold", return_value=0) as mock_scaffold:
|
||||
with pytest.raises(SystemExit) as exc_info:
|
||||
handle_command("init", ["/tmp/test-proj"])
|
||||
handle_command("init", ["test-proj"])
|
||||
assert exc_info.value.code == 0
|
||||
mock_scaffold.assert_called_once_with(["/tmp/test-proj"])
|
||||
mock_scaffold.assert_called_once_with(["test-proj"])
|
||||
|
||||
|
||||
# =============================================================================
|
||||
@@ -263,21 +261,24 @@ 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 12 stage functions to no-ops."""
|
||||
"""Context manager that patches all 10 stage functions to no-ops."""
|
||||
stage_names = [
|
||||
"stage_1_welcome",
|
||||
"stage_2_system_detect",
|
||||
"stage_3_doctor",
|
||||
"stage_4_user_profile",
|
||||
"stage_5_style_questions",
|
||||
"stage_6_tool_choice",
|
||||
"stage_7_docker_offer",
|
||||
"stage_8_first_agent",
|
||||
"stage_9_ping_sweep",
|
||||
"stage_10_smoke_test",
|
||||
"stage_11_handoff",
|
||||
"stage_12_done",
|
||||
"stage_3_user_profile",
|
||||
"stage_4_style_questions",
|
||||
"stage_5_tool_choice",
|
||||
"stage_6_first_agent",
|
||||
"stage_7_ping_sweep",
|
||||
"stage_8_smoke_test",
|
||||
"stage_9_handoff",
|
||||
"stage_10_done",
|
||||
]
|
||||
patches = [patch(f"aipass.aipass.apps.modules.init_flow.{name}", return_value={}) for name in stage_names]
|
||||
return patches
|
||||
@@ -291,7 +292,7 @@ class TestRunInit:
|
||||
assert result == 0
|
||||
|
||||
def test_non_interactive_runs_all_stages(self, tmp_local_json) -> None:
|
||||
"""non_interactive=True with aipass_framework runs all 12 stages."""
|
||||
"""non_interactive=True with aipass_framework runs all 10 stages."""
|
||||
patches = self._patch_all_stages()
|
||||
mocks = []
|
||||
ctx = __import__("contextlib").ExitStack()
|
||||
@@ -327,16 +328,14 @@ class TestRunInit:
|
||||
_MOD,
|
||||
stage_1_welcome=MagicMock(side_effect=boom_once),
|
||||
stage_2_system_detect=MagicMock(return_value={}),
|
||||
stage_3_doctor=MagicMock(return_value={}),
|
||||
stage_4_user_profile=MagicMock(return_value={}),
|
||||
stage_5_style_questions=MagicMock(return_value={}),
|
||||
stage_6_tool_choice=MagicMock(return_value={}),
|
||||
stage_7_docker_offer=MagicMock(return_value={}),
|
||||
stage_8_first_agent=MagicMock(return_value={}),
|
||||
stage_9_ping_sweep=MagicMock(return_value={}),
|
||||
stage_10_smoke_test=MagicMock(return_value={}),
|
||||
stage_11_handoff=MagicMock(return_value={}),
|
||||
stage_12_done=MagicMock(return_value={}),
|
||||
stage_3_user_profile=MagicMock(return_value={}),
|
||||
stage_4_style_questions=MagicMock(return_value={}),
|
||||
stage_5_tool_choice=MagicMock(return_value={}),
|
||||
stage_6_first_agent=MagicMock(return_value={}),
|
||||
stage_7_ping_sweep=MagicMock(return_value={}),
|
||||
stage_8_smoke_test=MagicMock(return_value={}),
|
||||
stage_9_handoff=MagicMock(return_value={}),
|
||||
stage_10_done=MagicMock(return_value={}),
|
||||
warning=MagicMock(),
|
||||
console=MagicMock(),
|
||||
):
|
||||
@@ -352,16 +351,14 @@ class TestRunInit:
|
||||
_MOD,
|
||||
stage_1_welcome=stage_1_mock,
|
||||
stage_2_system_detect=MagicMock(return_value={}),
|
||||
stage_3_doctor=MagicMock(return_value={}),
|
||||
stage_4_user_profile=stage_4_mock,
|
||||
stage_5_style_questions=MagicMock(return_value={}),
|
||||
stage_6_tool_choice=MagicMock(return_value={}),
|
||||
stage_7_docker_offer=MagicMock(return_value={}),
|
||||
stage_8_first_agent=MagicMock(return_value={}),
|
||||
stage_9_ping_sweep=MagicMock(return_value={}),
|
||||
stage_10_smoke_test=MagicMock(return_value={}),
|
||||
stage_11_handoff=MagicMock(return_value={}),
|
||||
stage_12_done=MagicMock(return_value={}),
|
||||
stage_3_user_profile=MagicMock(return_value={}),
|
||||
stage_4_style_questions=stage_4_mock,
|
||||
stage_5_tool_choice=MagicMock(return_value={}),
|
||||
stage_6_first_agent=MagicMock(return_value={}),
|
||||
stage_7_ping_sweep=MagicMock(return_value={}),
|
||||
stage_8_smoke_test=MagicMock(return_value={}),
|
||||
stage_9_handoff=MagicMock(return_value={}),
|
||||
stage_10_done=MagicMock(return_value={}),
|
||||
warning=MagicMock(),
|
||||
console=MagicMock(),
|
||||
):
|
||||
@@ -402,38 +399,13 @@ class TestStages:
|
||||
detect_install_method=MagicMock(return_value="pip"),
|
||||
detect_tmux=MagicMock(return_value=True),
|
||||
detect_wt=MagicMock(return_value=False),
|
||||
detect_docker=MagicMock(return_value=True),
|
||||
):
|
||||
result = stage_2_system_detect(non_interactive=True)
|
||||
assert result["os"] == "Linux"
|
||||
assert result["python"] == "3.12.0"
|
||||
assert result["shell"] == "bash"
|
||||
assert result["has_docker"] is True
|
||||
|
||||
def test_stage_3_doctor_no_errors(self, tmp_local_json) -> None:
|
||||
"""stage_3_doctor with 0 errors returns doctor_errors=0."""
|
||||
with patch(f"{_MOD}.console"):
|
||||
with patch("aipass.aipass.apps.modules.doctor.run_doctor", return_value=0):
|
||||
result = stage_3_doctor(non_interactive=True)
|
||||
assert result["doctor_errors"] == 0
|
||||
|
||||
def test_stage_3_doctor_with_errors(self, tmp_local_json) -> None:
|
||||
"""stage_3_doctor with errors emits warning but continues."""
|
||||
with patch(f"{_MOD}.console"):
|
||||
with patch(f"{_MOD}.warning"):
|
||||
with patch("aipass.aipass.apps.modules.doctor.run_doctor", return_value=2):
|
||||
result = stage_3_doctor(non_interactive=True)
|
||||
assert result["doctor_errors"] == 2
|
||||
|
||||
def test_stage_3_doctor_import_failure(self, tmp_local_json) -> None:
|
||||
"""stage_3_doctor handles run_doctor exception gracefully."""
|
||||
with patch(f"{_MOD}.console"):
|
||||
with patch(f"{_MOD}.warning"):
|
||||
with patch("aipass.aipass.apps.modules.doctor.run_doctor", side_effect=Exception("fail")):
|
||||
result = stage_3_doctor(non_interactive=True)
|
||||
assert result["doctor_errors"] == 0
|
||||
|
||||
def test_stage_4_non_interactive_uses_default_name(self, tmp_local_json) -> None:
|
||||
def test_stage_3_non_interactive_uses_default_name(self, tmp_local_json) -> None:
|
||||
"""non_interactive=True sets name to 'User'."""
|
||||
mock_profile_mod = MagicMock()
|
||||
mock_profile_mod.get_user_profile.return_value = {
|
||||
@@ -442,10 +414,10 @@ class TestStages:
|
||||
with patch(f"{_MOD}.console"):
|
||||
with patch(f"{_MOD}.detect_os", return_value={"os_name": "Linux", "release": "6.0", "machine": "x86"}):
|
||||
with patch.dict("sys.modules", {"aipass.aipass.apps.modules.profile": mock_profile_mod}):
|
||||
result = stage_4_user_profile(non_interactive=True)
|
||||
result = stage_3_user_profile(non_interactive=True)
|
||||
assert result["name"] == "User"
|
||||
|
||||
def test_stage_4_name_override(self, tmp_local_json) -> None:
|
||||
def test_stage_3_name_override(self, tmp_local_json) -> None:
|
||||
"""name_override parameter is used when provided."""
|
||||
mock_profile_mod = MagicMock()
|
||||
mock_profile_mod.get_user_profile.return_value = {
|
||||
@@ -454,45 +426,45 @@ class TestStages:
|
||||
with patch(f"{_MOD}.console"):
|
||||
with patch(f"{_MOD}.detect_os", return_value={"os_name": "Linux", "release": "6.0", "machine": "x86"}):
|
||||
with patch.dict("sys.modules", {"aipass.aipass.apps.modules.profile": mock_profile_mod}):
|
||||
result = stage_4_user_profile(non_interactive=True, name_override="Alice")
|
||||
assert result["name"] == "Alice"
|
||||
result = stage_3_user_profile(non_interactive=True, name_override="user")
|
||||
assert result["name"] == "user"
|
||||
|
||||
def test_stage_5_non_interactive_returns_first_choice(self, tmp_local_json) -> None:
|
||||
def test_stage_4_non_interactive_returns_first_choice(self, tmp_local_json) -> None:
|
||||
"""non_interactive=True selects first STYLE_CHOICES entry."""
|
||||
with patch(f"{_MOD}.console"):
|
||||
result = stage_5_style_questions(non_interactive=True)
|
||||
result = stage_4_style_questions(non_interactive=True)
|
||||
assert "style" in result
|
||||
assert result["style"] is not None
|
||||
|
||||
def test_stage_5_style_override(self, tmp_local_json) -> None:
|
||||
def test_stage_4_style_override(self, tmp_local_json) -> None:
|
||||
"""style_override is honoured when it's a valid choice."""
|
||||
from aipass.aipass.apps.modules.init_flow import STYLE_CHOICES
|
||||
|
||||
override = STYLE_CHOICES[0]
|
||||
with patch(f"{_MOD}.console"):
|
||||
result = stage_5_style_questions(non_interactive=True, style_override=override)
|
||||
result = stage_4_style_questions(non_interactive=True, style_override=override)
|
||||
assert result["style"] == override
|
||||
|
||||
def test_stage_6_non_interactive_defaults_to_claude(self, tmp_local_json) -> None:
|
||||
def test_stage_5_non_interactive_defaults_to_claude(self, tmp_local_json) -> None:
|
||||
"""non_interactive=True selects 'claude' as CLI."""
|
||||
mock_profile_mod = MagicMock()
|
||||
mock_profile_mod.get_user_profile.return_value = {}
|
||||
with patch(f"{_MOD}.console"):
|
||||
with patch.dict("sys.modules", {"aipass.aipass.apps.modules.profile": mock_profile_mod}):
|
||||
result = stage_6_tool_choice(non_interactive=True)
|
||||
result = stage_5_tool_choice(non_interactive=True)
|
||||
assert result["cli"] == "claude"
|
||||
assert result["flag_variant"] == "default"
|
||||
|
||||
def test_stage_6_cli_override(self, tmp_local_json) -> None:
|
||||
def test_stage_5_cli_override(self, tmp_local_json) -> None:
|
||||
"""cli_override sets the CLI choice."""
|
||||
mock_profile_mod = MagicMock()
|
||||
mock_profile_mod.get_user_profile.return_value = {}
|
||||
with patch(f"{_MOD}.console"):
|
||||
with patch.dict("sys.modules", {"aipass.aipass.apps.modules.profile": mock_profile_mod}):
|
||||
result = stage_6_tool_choice(non_interactive=True, cli_override="codex")
|
||||
result = stage_5_tool_choice(non_interactive=True, cli_override="codex")
|
||||
assert result["cli"] == "codex"
|
||||
|
||||
def test_stage_6_claude_present_no_prompt(self, tmp_local_json) -> None:
|
||||
def test_stage_5_claude_present_no_prompt(self, tmp_local_json) -> None:
|
||||
"""When claude is on PATH, no install prompt is shown."""
|
||||
mock_profile_mod = MagicMock()
|
||||
mock_profile_mod.get_user_profile.return_value = {}
|
||||
@@ -500,7 +472,7 @@ class TestStages:
|
||||
with patch(f"{_MOD}.shutil.which", return_value="/usr/bin/claude"):
|
||||
with patch.dict("sys.modules", {"aipass.aipass.apps.modules.profile": mock_profile_mod}):
|
||||
with patch(f"{_MOD}._handle_missing_claude") as mock_handle:
|
||||
result = stage_6_tool_choice(non_interactive=True)
|
||||
result = stage_5_tool_choice(non_interactive=True)
|
||||
mock_handle.assert_not_called()
|
||||
assert result["cli"] == "claude"
|
||||
|
||||
@@ -509,14 +481,14 @@ class TestStages:
|
||||
@patch(f"{_MOD}._prompt", return_value="Y")
|
||||
@patch(f"{_MOD}.shutil.which", return_value=None)
|
||||
@patch(f"{_MOD}.console")
|
||||
def test_stage_6_claude_missing_interactive_yes(
|
||||
def test_stage_5_claude_missing_interactive_yes(
|
||||
self, _con, _which, _prompt, mock_install, _choose, tmp_local_json
|
||||
) -> None:
|
||||
"""Missing claude + interactive + yes → installer invoked."""
|
||||
mock_profile_mod = MagicMock()
|
||||
mock_profile_mod.get_user_profile.return_value = {}
|
||||
with patch.dict("sys.modules", {"aipass.aipass.apps.modules.profile": mock_profile_mod}):
|
||||
result = stage_6_tool_choice(non_interactive=False, cli_override="claude")
|
||||
result = stage_5_tool_choice(non_interactive=False, cli_override="claude")
|
||||
mock_install.assert_called_once()
|
||||
assert result["cli"] == "claude"
|
||||
|
||||
@@ -525,14 +497,14 @@ class TestStages:
|
||||
@patch(f"{_MOD}._prompt", return_value="n")
|
||||
@patch(f"{_MOD}.shutil.which", return_value=None)
|
||||
@patch(f"{_MOD}.console")
|
||||
def test_stage_6_claude_missing_interactive_no(
|
||||
def test_stage_5_claude_missing_interactive_no(
|
||||
self, _con, _which, _prompt, mock_install, _choose, tmp_local_json
|
||||
) -> None:
|
||||
"""Missing claude + interactive + no → no install, continues."""
|
||||
mock_profile_mod = MagicMock()
|
||||
mock_profile_mod.get_user_profile.return_value = {}
|
||||
with patch.dict("sys.modules", {"aipass.aipass.apps.modules.profile": mock_profile_mod}):
|
||||
result = stage_6_tool_choice(non_interactive=False, cli_override="claude")
|
||||
result = stage_5_tool_choice(non_interactive=False, cli_override="claude")
|
||||
mock_install.assert_not_called()
|
||||
assert result["cli"] == "claude"
|
||||
|
||||
@@ -540,105 +512,143 @@ class TestStages:
|
||||
@patch(f"{_MOD}._install_claude_code")
|
||||
@patch(f"{_MOD}.shutil.which", return_value=None)
|
||||
@patch(f"{_MOD}.console")
|
||||
def test_stage_6_claude_missing_non_interactive_warns(
|
||||
def test_stage_5_claude_missing_non_interactive_warns(
|
||||
self, _con, _which, mock_install, mock_warn, tmp_local_json
|
||||
) -> None:
|
||||
"""Missing claude + non-interactive → warning, no install."""
|
||||
mock_profile_mod = MagicMock()
|
||||
mock_profile_mod.get_user_profile.return_value = {}
|
||||
with patch.dict("sys.modules", {"aipass.aipass.apps.modules.profile": mock_profile_mod}):
|
||||
result = stage_6_tool_choice(non_interactive=True)
|
||||
result = stage_5_tool_choice(non_interactive=True)
|
||||
mock_install.assert_not_called()
|
||||
mock_warn.assert_called_once()
|
||||
assert result["cli"] == "claude"
|
||||
|
||||
def test_stage_7_skipped_when_no_docker(self, tmp_local_json) -> None:
|
||||
"""Docker offer is skipped when has_docker=False."""
|
||||
with patch(f"{_MOD}.console"):
|
||||
result = stage_7_docker_offer(non_interactive=False, has_docker=False)
|
||||
assert result["docker"] == "skipped"
|
||||
|
||||
def test_stage_7_skipped_when_no_docker_flag(self, tmp_local_json) -> None:
|
||||
"""Docker offer is skipped when no_docker=True."""
|
||||
with patch(f"{_MOD}.console"):
|
||||
result = stage_7_docker_offer(non_interactive=False, no_docker=True, has_docker=True)
|
||||
assert result["docker"] == "skipped"
|
||||
|
||||
def test_stage_7_skipped_when_non_interactive(self, tmp_local_json) -> None:
|
||||
"""Docker offer is skipped in non-interactive mode."""
|
||||
with patch(f"{_MOD}.console"):
|
||||
result = stage_7_docker_offer(non_interactive=True, has_docker=True)
|
||||
assert result["docker"] == "skipped"
|
||||
|
||||
def test_stage_8_non_interactive_creates_my_agent(self, tmp_local_json) -> None:
|
||||
def test_stage_6_non_interactive_creates_my_agent(self, tmp_local_json) -> None:
|
||||
"""non_interactive=True uses 'my_agent' as default name."""
|
||||
mock_proc = MagicMock(returncode=0)
|
||||
with patch(f"{_MOD}.console"):
|
||||
with patch(f"{_MOD}.subprocess.run", return_value=mock_proc):
|
||||
with patch(f"{_MOD}._resolve_package_dir", return_value=None):
|
||||
result = stage_8_first_agent(non_interactive=True)
|
||||
result = stage_6_first_agent(non_interactive=True)
|
||||
assert result["agent_name"] == "my_agent"
|
||||
assert result["agent_path"] == "src/my_agent"
|
||||
|
||||
def test_stage_8_drone_not_found(self, tmp_local_json) -> None:
|
||||
def test_stage_6_drone_not_found(self, tmp_local_json) -> None:
|
||||
"""FileNotFoundError from drone is handled gracefully."""
|
||||
with patch(f"{_MOD}.console"):
|
||||
with patch(f"{_MOD}.warning"):
|
||||
with patch(f"{_MOD}.subprocess.run", side_effect=FileNotFoundError):
|
||||
result = stage_8_first_agent(non_interactive=True)
|
||||
result = stage_6_first_agent(non_interactive=True)
|
||||
assert "agent_name" in result
|
||||
|
||||
def test_stage_9_ping_sweep_calls_sweep(self, tmp_local_json) -> None:
|
||||
"""stage_9_ping_sweep calls sweep_all_branches and returns results."""
|
||||
def test_stage_7_ping_sweep_calls_sweep(self, tmp_local_json) -> None:
|
||||
"""stage_7_ping_sweep calls sweep_all_branches and returns results."""
|
||||
mock_ps = MagicMock()
|
||||
mock_ps.sweep_all_branches.return_value = {"drone": "ack", "prax": "timeout"}
|
||||
mock_ps.sweep_summary.return_value = "1 ack / 1 timeout / 0 error"
|
||||
with patch(f"{_MOD}.console"):
|
||||
with patch.dict("sys.modules", {"aipass.aipass.apps.handlers.ping_sweep": mock_ps}):
|
||||
result = stage_9_ping_sweep(non_interactive=True)
|
||||
result = stage_7_ping_sweep(non_interactive=True)
|
||||
assert "ping_results" in result
|
||||
|
||||
def test_stage_10_smoke_test_both_found(self, tmp_local_json) -> None:
|
||||
def test_stage_8_smoke_test_both_found(self, tmp_local_json) -> None:
|
||||
"""smoke test passes when both drone and aipass are on PATH."""
|
||||
with patch(f"{_MOD}.console"):
|
||||
with patch(f"{_MOD}.shutil.which", return_value="/usr/bin/drone"):
|
||||
result = stage_10_smoke_test()
|
||||
result = stage_8_smoke_test()
|
||||
assert result["drone"] == "/usr/bin/drone"
|
||||
|
||||
def test_stage_10_smoke_test_missing(self, tmp_local_json) -> None:
|
||||
def test_stage_8_smoke_test_missing(self, tmp_local_json) -> None:
|
||||
"""Warnings emitted when binaries not found."""
|
||||
with patch(f"{_MOD}.console"):
|
||||
with patch(f"{_MOD}.warning"):
|
||||
with patch(f"{_MOD}.shutil.which", return_value=None):
|
||||
result = stage_10_smoke_test()
|
||||
result = stage_8_smoke_test()
|
||||
assert result["drone"] is None
|
||||
assert result["aipass"] is None
|
||||
|
||||
def test_stage_11_default_variant_no_flag(self, tmp_local_json) -> None:
|
||||
def test_stage_9_default_variant_no_flag(self, tmp_local_json) -> None:
|
||||
"""Default flag variant does not append --dangerously-skip-permissions."""
|
||||
with patch(f"{_MOD}.console"):
|
||||
result = stage_11_handoff(cli_choice="claude", flag_variant="default", non_interactive=True)
|
||||
result = stage_9_handoff(cli_choice="claude", flag_variant="default", non_interactive=True)
|
||||
assert "--dangerously-skip-permissions" not in result["handoff_command"]
|
||||
|
||||
def test_stage_11_skip_permissions_variant(self, tmp_local_json) -> None:
|
||||
def test_stage_9_skip_permissions_variant(self, tmp_local_json) -> None:
|
||||
"""skip-permissions variant appends the flag for claude."""
|
||||
with patch(f"{_MOD}.console"):
|
||||
result = stage_11_handoff(cli_choice="claude", flag_variant="skip-permissions", non_interactive=True)
|
||||
result = stage_9_handoff(cli_choice="claude", flag_variant="skip-permissions", non_interactive=True)
|
||||
assert "--dangerously-skip-permissions" in result["handoff_command"]
|
||||
|
||||
def test_stage_11_handoff_command_contains_path(self, tmp_local_json) -> None:
|
||||
def test_stage_9_handoff_command_contains_path(self, tmp_local_json) -> None:
|
||||
"""Handoff command includes the agent path."""
|
||||
with patch(f"{_MOD}.console"):
|
||||
result = stage_11_handoff(agent_path="src/mybot", non_interactive=True)
|
||||
result = stage_9_handoff(agent_path="src/mybot", non_interactive=True)
|
||||
assert "src/mybot" in result["handoff_command"]
|
||||
|
||||
def test_stage_12_done_returns_empty(self, tmp_local_json) -> None:
|
||||
"""stage_12_done returns {} and marks stage 12 complete."""
|
||||
def test_stage_10_done_returns_empty(self, tmp_local_json) -> None:
|
||||
"""stage_10_done returns {} and marks stage 10 complete."""
|
||||
with patch(f"{_MOD}.console"):
|
||||
result = stage_12_done()
|
||||
result = stage_10_done()
|
||||
assert result == {}
|
||||
stored = json.loads(tmp_local_json.read_text())
|
||||
assert stored["setup_progress"]["last_completed_stage"] == 12
|
||||
assert stored["setup_progress"]["last_completed_stage"] == 10
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# TestProviderGaps — provider-manifest surfacing, decoupled from the doctor stage
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class TestProviderGaps:
|
||||
"""Provider gaps reach the init report without running the full doctor."""
|
||||
|
||||
def test_collect_provider_gaps_reports_missing(self) -> None:
|
||||
"""Non-pass manifest results are collected; run_doctor is never called."""
|
||||
from aipass.aipass.apps.modules import doctor
|
||||
from aipass.aipass.apps.modules.init_flow import _collect_provider_gaps
|
||||
|
||||
gap = MagicMock(glyph="WARN", label="hooks", detail="wire the hook")
|
||||
passing = MagicMock(glyph=doctor.GLYPH_PASS, label="env", detail="")
|
||||
with patch.object(doctor, "_check_provider_manifest", return_value=[gap, passing]):
|
||||
with patch.object(doctor, "run_doctor") as mock_run:
|
||||
gaps = _collect_provider_gaps()
|
||||
assert gaps == {"hooks": "wire the hook"}
|
||||
mock_run.assert_not_called()
|
||||
|
||||
def test_collect_provider_gaps_swallows_errors(self) -> None:
|
||||
"""A failing manifest check degrades to an empty dict, not a crash."""
|
||||
from aipass.aipass.apps.modules import doctor
|
||||
from aipass.aipass.apps.modules.init_flow import _collect_provider_gaps
|
||||
|
||||
with patch.object(doctor, "_check_provider_manifest", side_effect=RuntimeError("boom")):
|
||||
with patch(f"{_MOD}.logger"):
|
||||
gaps = _collect_provider_gaps()
|
||||
assert gaps == {}
|
||||
|
||||
def test_init_report_includes_provider_gaps(self, tmp_path: Path) -> None:
|
||||
"""_write_init_report embeds provider gaps + action when the manifest reports them."""
|
||||
from aipass.aipass.apps.modules.init_flow import _write_init_report
|
||||
|
||||
agent_dir = tmp_path / "src" / "bot"
|
||||
agent_dir.mkdir(parents=True)
|
||||
with patch(f"{_MOD}._collect_provider_gaps", return_value={"hooks": "missing"}):
|
||||
_write_init_report(str(agent_dir), {"agent_name": "BOT"})
|
||||
report = json.loads((agent_dir / "dropbox" / "init_report.json").read_text())
|
||||
assert report["provider_gaps"] == {"hooks": "missing"}
|
||||
assert "provider_action" in report
|
||||
|
||||
def test_init_report_omits_provider_gaps_when_clean(self, tmp_path: Path) -> None:
|
||||
"""No provider keys are written when the manifest is fully satisfied."""
|
||||
from aipass.aipass.apps.modules.init_flow import _write_init_report
|
||||
|
||||
agent_dir = tmp_path / "src" / "bot"
|
||||
agent_dir.mkdir(parents=True)
|
||||
with patch(f"{_MOD}._collect_provider_gaps", return_value={}):
|
||||
_write_init_report(str(agent_dir), {"agent_name": "BOT"})
|
||||
report = json.loads((agent_dir / "dropbox" / "init_report.json").read_text())
|
||||
assert "provider_gaps" not in report
|
||||
assert "provider_action" not in report
|
||||
|
||||
|
||||
# =============================================================================
|
||||
@@ -650,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."""
|
||||
@@ -700,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."""
|
||||
@@ -735,25 +770,23 @@ class TestTemplateSelector:
|
||||
|
||||
@staticmethod
|
||||
def _stage_patches():
|
||||
"""Return patches for all 12 stage functions as no-ops."""
|
||||
"""Return patches for all 10 stage functions as no-ops."""
|
||||
stage_names = [
|
||||
"stage_1_welcome",
|
||||
"stage_2_system_detect",
|
||||
"stage_3_doctor",
|
||||
"stage_4_user_profile",
|
||||
"stage_5_style_questions",
|
||||
"stage_6_tool_choice",
|
||||
"stage_7_docker_offer",
|
||||
"stage_8_first_agent",
|
||||
"stage_9_ping_sweep",
|
||||
"stage_10_smoke_test",
|
||||
"stage_11_handoff",
|
||||
"stage_12_done",
|
||||
"stage_3_user_profile",
|
||||
"stage_4_style_questions",
|
||||
"stage_5_tool_choice",
|
||||
"stage_6_first_agent",
|
||||
"stage_7_ping_sweep",
|
||||
"stage_8_smoke_test",
|
||||
"stage_9_handoff",
|
||||
"stage_10_done",
|
||||
]
|
||||
return {name: MagicMock(return_value={}) for name in stage_names}
|
||||
|
||||
def test_empty_project_default_skips_scaffold(self, tmp_local_json) -> None:
|
||||
"""empty project (default) = no scaffold, stages 8,9,11,12 skipped."""
|
||||
"""empty project (default) = no scaffold; framework-only stages 6,7,9,10 skipped."""
|
||||
mocks = self._stage_patches()
|
||||
with patch.multiple(_MOD, console=MagicMock(), warning=MagicMock(), **mocks):
|
||||
result = run_init(non_interactive=True, template=TEMPLATE_EMPTY)
|
||||
@@ -761,19 +794,17 @@ class TestTemplateSelector:
|
||||
for name in (
|
||||
"stage_1_welcome",
|
||||
"stage_2_system_detect",
|
||||
"stage_3_doctor",
|
||||
"stage_4_user_profile",
|
||||
"stage_5_style_questions",
|
||||
"stage_6_tool_choice",
|
||||
"stage_7_docker_offer",
|
||||
"stage_10_smoke_test",
|
||||
"stage_3_user_profile",
|
||||
"stage_4_style_questions",
|
||||
"stage_5_tool_choice",
|
||||
"stage_8_smoke_test",
|
||||
):
|
||||
assert mocks[name].called, f"{name} should have been called"
|
||||
for name in ("stage_8_first_agent", "stage_9_ping_sweep", "stage_11_handoff", "stage_12_done"):
|
||||
for name in ("stage_6_first_agent", "stage_7_ping_sweep", "stage_9_handoff", "stage_10_done"):
|
||||
assert not mocks[name].called, f"{name} should NOT have been called"
|
||||
|
||||
def test_aipass_framework_runs_full_scaffold(self, tmp_local_json) -> None:
|
||||
"""aipass_framework = full scaffold + all 12 stages."""
|
||||
"""aipass_framework = full scaffold + all 10 stages."""
|
||||
mocks = self._stage_patches()
|
||||
with patch.multiple(_MOD, console=MagicMock(), warning=MagicMock(), **mocks):
|
||||
with patch(
|
||||
@@ -817,20 +848,20 @@ class TestTemplateSelector:
|
||||
with patch(f"{_MOD}._preflight_check", return_value=None):
|
||||
with patch(f"{_MOD}._handle_init_scaffold", return_value=0) as mock_scaffold:
|
||||
with pytest.raises(SystemExit):
|
||||
handle_command("init", ["/tmp/test-proj"])
|
||||
mock_scaffold.assert_called_once_with(["/tmp/test-proj"])
|
||||
handle_command("init", ["test-proj"])
|
||||
mock_scaffold.assert_called_once_with(["test-proj"])
|
||||
|
||||
def test_pip_hints_say_clone(self, tmp_local_json) -> None:
|
||||
"""in-product hints say clone/setup.sh, not pip."""
|
||||
with patch(f"{_MOD}.console"):
|
||||
with patch(f"{_MOD}.warning") as mock_warn:
|
||||
with patch(f"{_MOD}.shutil.which", return_value=None):
|
||||
stage_10_smoke_test()
|
||||
stage_8_smoke_test()
|
||||
for call in mock_warn.call_args_list:
|
||||
msg = call[0][0].lower()
|
||||
assert "setup.sh" in msg
|
||||
assert "pip" not in msg
|
||||
|
||||
def test_aipass_specific_stages_constant(self) -> None:
|
||||
"""AIPASS_SPECIFIC_STAGES contains exactly {8, 9, 11, 12}."""
|
||||
assert AIPASS_SPECIFIC_STAGES == {8, 9, 11, 12}
|
||||
"""AIPASS_SPECIFIC_STAGES contains exactly {6, 7, 9, 10}."""
|
||||
assert AIPASS_SPECIFIC_STAGES == {6, 7, 9, 10}
|
||||
|
||||
@@ -0,0 +1,449 @@
|
||||
# =================== AIPass ====================
|
||||
# Name: test_install.py
|
||||
# Description: Tests for aipass install — one-command bootstrap (DPLAN-0233)
|
||||
# Version: 1.0.0
|
||||
# Created: 2026-07-05
|
||||
# Modified: 2026-07-05
|
||||
# =============================================
|
||||
|
||||
"""Tests for the aipass install module (DPLAN-0233)."""
|
||||
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from aipass.aipass.apps.modules.install import (
|
||||
DEFAULT_HOME,
|
||||
DEFAULT_PROJECT,
|
||||
TOTAL_STEPS,
|
||||
_clone_repo,
|
||||
_handoff_to_init,
|
||||
_looks_like_aipass_tree,
|
||||
_resolve_home,
|
||||
_resolve_project_dir,
|
||||
_run_setup,
|
||||
_should_run_init,
|
||||
handle_command,
|
||||
print_help,
|
||||
print_introspection,
|
||||
run_install,
|
||||
)
|
||||
|
||||
_MOD = "aipass.aipass.apps.modules.install"
|
||||
|
||||
|
||||
class TestLooksLikeAipassTree:
|
||||
"""Detecting whether a directory already holds an AIPass source tree."""
|
||||
|
||||
def test_setup_sh_present(self, tmp_path: Path) -> None:
|
||||
"""A directory with setup.sh reads as an AIPass tree."""
|
||||
(tmp_path / "setup.sh").write_text("#!/usr/bin/env bash\n", encoding="utf-8")
|
||||
assert _looks_like_aipass_tree(tmp_path) is True
|
||||
|
||||
def test_registry_present(self, tmp_path: Path) -> None:
|
||||
"""A directory with a *_REGISTRY.json reads as an AIPass tree."""
|
||||
(tmp_path / "MYPROJ_REGISTRY.json").write_text("{}", encoding="utf-8")
|
||||
assert _looks_like_aipass_tree(tmp_path) is True
|
||||
|
||||
def test_empty_dir(self, tmp_path: Path) -> None:
|
||||
"""An empty directory is not an AIPass tree."""
|
||||
assert _looks_like_aipass_tree(tmp_path) is False
|
||||
|
||||
def test_missing_dir(self, tmp_path: Path) -> None:
|
||||
"""A non-existent path is not an AIPass tree."""
|
||||
assert _looks_like_aipass_tree(tmp_path / "nope") is False
|
||||
|
||||
|
||||
class TestResolveHome:
|
||||
"""Resolving the install home from flags, env, and defaults."""
|
||||
|
||||
def test_here_returns_cwd(self, tmp_path: Path) -> None:
|
||||
"""--here resolves to the current working directory."""
|
||||
with patch(f"{_MOD}.Path.cwd", return_value=tmp_path):
|
||||
assert _resolve_home(None, here=True, non_interactive=False) == tmp_path.resolve()
|
||||
|
||||
def test_explicit_path(self, tmp_path: Path) -> None:
|
||||
"""An explicit --path is expanded and resolved."""
|
||||
target = tmp_path / "tools" / "aipass"
|
||||
assert _resolve_home(str(target), here=False, non_interactive=False) == target.resolve()
|
||||
|
||||
def test_non_interactive_defaults(self) -> None:
|
||||
"""With no AIPASS_HOME, non-interactive falls back to DEFAULT_HOME."""
|
||||
with patch.dict("os.environ", {"AIPASS_HOME": ""}, clear=False):
|
||||
assert _resolve_home(None, here=False, non_interactive=True) == DEFAULT_HOME.resolve()
|
||||
|
||||
def test_uses_valid_env(self, tmp_path: Path) -> None:
|
||||
"""A valid AIPASS_HOME pointing at a real tree is honoured."""
|
||||
(tmp_path / "setup.sh").write_text("#!/usr/bin/env bash\n", encoding="utf-8")
|
||||
with patch.dict("os.environ", {"AIPASS_HOME": str(tmp_path)}, clear=False):
|
||||
assert _resolve_home(None, here=False, non_interactive=True) == tmp_path.resolve()
|
||||
|
||||
def test_ignores_invalid_env(self, tmp_path: Path) -> None:
|
||||
"""An AIPASS_HOME that is not an AIPass tree is ignored for the default."""
|
||||
with patch.dict("os.environ", {"AIPASS_HOME": str(tmp_path)}, clear=False):
|
||||
assert _resolve_home(None, here=False, non_interactive=True) == DEFAULT_HOME.resolve()
|
||||
|
||||
|
||||
class TestCloneRepo:
|
||||
"""Fetching the framework into the home via git clone."""
|
||||
|
||||
def test_dry_run_no_subprocess(self, tmp_path: Path) -> None:
|
||||
"""Dry-run reports success without shelling out."""
|
||||
with patch(f"{_MOD}.subprocess.run") as run:
|
||||
assert _clone_repo(tmp_path / "home", dry_run=True) is True
|
||||
run.assert_not_called()
|
||||
|
||||
def test_refuses_non_empty_dir(self, tmp_path: Path) -> None:
|
||||
"""A non-empty target is refused rather than clobbered."""
|
||||
(tmp_path / "existing.txt").write_text("x", encoding="utf-8")
|
||||
with patch(f"{_MOD}.subprocess.run") as run:
|
||||
assert _clone_repo(tmp_path, dry_run=False) is False
|
||||
run.assert_not_called()
|
||||
|
||||
def test_missing_git(self, tmp_path: Path) -> None:
|
||||
"""Absent git means clone fails cleanly without calling subprocess."""
|
||||
with patch(f"{_MOD}.shutil.which", return_value=None), patch(f"{_MOD}.subprocess.run") as run:
|
||||
assert _clone_repo(tmp_path / "home", dry_run=False) is False
|
||||
run.assert_not_called()
|
||||
|
||||
def test_success(self, tmp_path: Path) -> None:
|
||||
"""A zero-exit git clone returns success."""
|
||||
with (
|
||||
patch(f"{_MOD}.shutil.which", return_value="/usr/bin/git"),
|
||||
patch(f"{_MOD}.subprocess.run", return_value=MagicMock(returncode=0)) as run,
|
||||
):
|
||||
assert _clone_repo(tmp_path / "home", dry_run=False) is True
|
||||
run.assert_called_once()
|
||||
|
||||
def test_nonzero_exit(self, tmp_path: Path) -> None:
|
||||
"""A non-zero git clone exit reports failure."""
|
||||
with (
|
||||
patch(f"{_MOD}.shutil.which", return_value="/usr/bin/git"),
|
||||
patch(f"{_MOD}.subprocess.run", return_value=MagicMock(returncode=1)),
|
||||
):
|
||||
assert _clone_repo(tmp_path / "home", dry_run=False) is False
|
||||
|
||||
|
||||
class TestRunSetup:
|
||||
"""Running the repo setup.sh."""
|
||||
|
||||
def test_dry_run_no_subprocess(self, tmp_path: Path) -> None:
|
||||
"""Dry-run reports success without running setup.sh."""
|
||||
(tmp_path / "setup.sh").write_text("#!/usr/bin/env bash\n", encoding="utf-8")
|
||||
with patch(f"{_MOD}.subprocess.run") as run:
|
||||
assert _run_setup(tmp_path, dry_run=True) is True
|
||||
run.assert_not_called()
|
||||
|
||||
def test_missing_script(self, tmp_path: Path) -> None:
|
||||
"""A missing setup.sh reports failure."""
|
||||
assert _run_setup(tmp_path, dry_run=False) is False
|
||||
|
||||
def test_success(self, tmp_path: Path) -> None:
|
||||
"""A zero-exit setup.sh returns success."""
|
||||
(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
|
||||
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."""
|
||||
|
||||
def test_dry_run_is_side_effect_free(self) -> None:
|
||||
"""Dry-run walks all steps and touches no subprocess."""
|
||||
with patch(f"{_MOD}.subprocess.run") as run:
|
||||
rc = run_install(non_interactive=True, dry_run=True)
|
||||
assert rc == 0
|
||||
run.assert_not_called()
|
||||
|
||||
def test_aborts_when_clone_fails(self, tmp_path: Path) -> None:
|
||||
"""A failed fetch aborts before the setup step runs."""
|
||||
home = tmp_path / "AIPass"
|
||||
with (
|
||||
patch(f"{_MOD}._resolve_home", return_value=home),
|
||||
patch(f"{_MOD}._clone_repo", return_value=False),
|
||||
patch(f"{_MOD}._run_setup") as setup,
|
||||
):
|
||||
rc = run_install(non_interactive=True, dry_run=False)
|
||||
assert rc == 1
|
||||
setup.assert_not_called()
|
||||
|
||||
def test_full_happy_path(self, tmp_path: Path) -> None:
|
||||
"""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)
|
||||
assert rc == 0
|
||||
nxt.assert_called_once()
|
||||
|
||||
|
||||
class TestShouldRunInit:
|
||||
"""Deciding whether the install chains into init."""
|
||||
|
||||
def test_no_init_wins(self) -> None:
|
||||
"""--no-init disables the handoff even alongside --with-init."""
|
||||
assert _should_run_init(non_interactive=False, with_init=True, no_init=True) is False
|
||||
|
||||
def test_with_init_forces_headless(self) -> None:
|
||||
"""--with-init runs init even when the install was headless."""
|
||||
assert _should_run_init(non_interactive=True, with_init=True, no_init=False) is True
|
||||
|
||||
def test_headless_defaults_off(self) -> None:
|
||||
"""A plain headless install stops before init."""
|
||||
assert _should_run_init(non_interactive=True, with_init=False, no_init=False) is False
|
||||
|
||||
def test_interactive_defaults_on(self) -> None:
|
||||
"""A plain interactive install chains into init."""
|
||||
assert _should_run_init(non_interactive=False, with_init=False, no_init=False) is True
|
||||
|
||||
|
||||
class TestResolveProjectDir:
|
||||
"""Resolving where the first project scaffolds."""
|
||||
|
||||
def test_explicit_project(self, tmp_path: Path) -> None:
|
||||
"""An explicit --project is expanded and resolved."""
|
||||
target = tmp_path / "proj"
|
||||
assert _resolve_project_dir(str(target), non_interactive=True) == target.resolve()
|
||||
|
||||
def test_headless_defaults(self) -> None:
|
||||
"""Headless with no --project falls back to DEFAULT_PROJECT."""
|
||||
assert _resolve_project_dir(None, non_interactive=True) == DEFAULT_PROJECT.resolve()
|
||||
|
||||
|
||||
class TestHandoffToInit:
|
||||
"""The init handoff step."""
|
||||
|
||||
def test_skips_when_not_running(self, tmp_path: Path) -> None:
|
||||
"""run_it=False prints next steps and launches nothing."""
|
||||
with patch(f"{_MOD}.subprocess.run") as run:
|
||||
_handoff_to_init(tmp_path, "/x/aipass", non_interactive=True, dry_run=False, project=None, run_it=False)
|
||||
run.assert_not_called()
|
||||
|
||||
def test_dry_run_no_subprocess(self, tmp_path: Path) -> None:
|
||||
"""Dry-run announces the launch but does not spawn init."""
|
||||
with patch(f"{_MOD}.subprocess.run") as run:
|
||||
_handoff_to_init(
|
||||
tmp_path, "/x/aipass", non_interactive=True, dry_run=True, project=str(tmp_path / "p"), run_it=True
|
||||
)
|
||||
run.assert_not_called()
|
||||
|
||||
def test_launches_init_headless(self, tmp_path: Path) -> None:
|
||||
"""A real headless handoff launches `aipass init run --non-interactive` in the project dir."""
|
||||
project = tmp_path / "proj"
|
||||
with patch(f"{_MOD}.subprocess.run") as run:
|
||||
_handoff_to_init(
|
||||
tmp_path, "/x/aipass", non_interactive=True, dry_run=False, project=str(project), run_it=True
|
||||
)
|
||||
run.assert_called_once()
|
||||
cmd = run.call_args.args[0]
|
||||
assert cmd == ["/x/aipass", "init", "run", "--non-interactive"]
|
||||
assert run.call_args.kwargs["cwd"] == str(project)
|
||||
|
||||
def test_missing_binary_warns_not_crashes(self, tmp_path: Path) -> None:
|
||||
"""No aipass binary → warn, don't launch, don't raise."""
|
||||
with patch(f"{_MOD}.subprocess.run") as run:
|
||||
_handoff_to_init(
|
||||
tmp_path, None, non_interactive=True, dry_run=False, project=str(tmp_path / "p"), run_it=True
|
||||
)
|
||||
run.assert_not_called()
|
||||
|
||||
|
||||
class TestHandleCommand:
|
||||
"""Command routing for `aipass install`."""
|
||||
|
||||
def test_ignores_other_commands(self) -> None:
|
||||
"""A non-install command is not handled here."""
|
||||
assert handle_command("doctor", []) is False
|
||||
|
||||
def test_help(self) -> None:
|
||||
"""--help is handled without exiting."""
|
||||
assert handle_command("install", ["--help"]) is True
|
||||
|
||||
def test_info(self) -> None:
|
||||
"""--info is handled without exiting."""
|
||||
assert handle_command("install", ["--info"]) is True
|
||||
|
||||
def test_runs_and_exits(self) -> None:
|
||||
"""A run request calls run_install and exits with its code."""
|
||||
with patch(f"{_MOD}.run_install", return_value=0) as run:
|
||||
with pytest.raises(SystemExit) as exc:
|
||||
handle_command("install", ["--dry-run", "--non-interactive"])
|
||||
assert exc.value.code == 0
|
||||
run.assert_called_once()
|
||||
|
||||
def test_passes_path_flag(self) -> None:
|
||||
"""--path and --non-interactive are threaded into run_install."""
|
||||
target = str(Path.home() / "custom-aipass-home")
|
||||
with patch(f"{_MOD}.run_install", return_value=0) as run:
|
||||
with pytest.raises(SystemExit):
|
||||
handle_command("install", ["--path", target, "--non-interactive"])
|
||||
_, kwargs = run.call_args
|
||||
assert kwargs["path"] == target
|
||||
assert kwargs["non_interactive"] is True
|
||||
|
||||
def test_passes_init_flags(self) -> None:
|
||||
"""--with-init / --no-init / --project are threaded into run_install."""
|
||||
with patch(f"{_MOD}.run_install", return_value=0) as run:
|
||||
with pytest.raises(SystemExit):
|
||||
handle_command("install", ["--with-init", "--no-init", "--project", "/x/proj"])
|
||||
_, kwargs = run.call_args
|
||||
assert kwargs["with_init"] is True
|
||||
assert kwargs["no_init"] is True
|
||||
assert kwargs["project"] == "/x/proj"
|
||||
|
||||
|
||||
class TestSmoke:
|
||||
"""Help/introspection render and constants hold."""
|
||||
|
||||
def test_print_help_runs(self) -> None:
|
||||
"""print_help renders without error."""
|
||||
print_help()
|
||||
|
||||
def test_print_introspection_runs(self) -> None:
|
||||
"""print_introspection renders without error."""
|
||||
print_introspection()
|
||||
|
||||
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)
|
||||
@@ -0,0 +1,203 @@
|
||||
# =================== AIPass ====================
|
||||
# Name: test_launcher.py
|
||||
# Description: Tests for the repo-root ./aipass cold-clone launcher
|
||||
# Version: 1.0.0
|
||||
# Created: 2026-07-05
|
||||
# Modified: 2026-07-05
|
||||
# =============================================
|
||||
|
||||
"""Tests for the repo-root ./aipass cold-clone launcher (bash script)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
pytestmark = pytest.mark.skipif(sys.platform == "win32", reason="bash launcher requires Unix")
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[4]
|
||||
LAUNCHER = REPO_ROOT / "aipass"
|
||||
|
||||
_EXEC_BITS = 0o111
|
||||
|
||||
|
||||
def _make_executable(path: Path) -> None:
|
||||
"""Set executable bits on a file."""
|
||||
path.chmod(path.stat().st_mode | _EXEC_BITS)
|
||||
|
||||
|
||||
def _isolated_launcher(tmp_path: Path) -> Path:
|
||||
"""Copy the launcher to a temp dir (isolated from real .venv)."""
|
||||
dest = tmp_path / "aipass"
|
||||
shutil.copy2(LAUNCHER, dest)
|
||||
_make_executable(dest)
|
||||
return dest
|
||||
|
||||
|
||||
def _fake_venv_binary(tmp_path: Path, body: str) -> Path:
|
||||
"""Create a fake .venv/bin/aipass that runs the given bash body."""
|
||||
venv_bin = tmp_path / ".venv" / "bin"
|
||||
venv_bin.mkdir(parents=True)
|
||||
fake = venv_bin / "aipass"
|
||||
fake.write_text(f"#!/usr/bin/env bash\n{body}\n")
|
||||
_make_executable(fake)
|
||||
return fake
|
||||
|
||||
|
||||
class TestLauncherProperties:
|
||||
"""Verify the launcher file's basic properties."""
|
||||
|
||||
def test_exists(self):
|
||||
"""Launcher file exists at repo root."""
|
||||
assert LAUNCHER.is_file()
|
||||
|
||||
def test_executable(self):
|
||||
"""Launcher has executable permission."""
|
||||
assert os.access(LAUNCHER, os.X_OK)
|
||||
|
||||
def test_shebang(self):
|
||||
"""Launcher starts with bash shebang."""
|
||||
first_line = LAUNCHER.read_text().splitlines()[0]
|
||||
assert first_line == "#!/usr/bin/env bash"
|
||||
|
||||
def test_no_python_imports(self):
|
||||
"""Launcher contains no Python imports (stdlib-only bash)."""
|
||||
content = LAUNCHER.read_text()
|
||||
assert "import " not in content
|
||||
assert "from " not in content
|
||||
|
||||
|
||||
class TestPreSetupHelp:
|
||||
"""Pre-setup (no venv): bare ./aipass prints help text."""
|
||||
|
||||
def test_no_args_shows_help(self, tmp_path):
|
||||
"""No args and no venv prints setup instructions."""
|
||||
launcher = _isolated_launcher(tmp_path)
|
||||
result = subprocess.run(
|
||||
["bash", str(launcher)],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5,
|
||||
)
|
||||
assert "not set up yet" in result.stdout
|
||||
assert "./aipass install" in result.stdout
|
||||
|
||||
def test_unknown_verb_shows_help(self, tmp_path):
|
||||
"""Unknown verb pre-setup prints help, not an error."""
|
||||
launcher = _isolated_launcher(tmp_path)
|
||||
result = subprocess.run(
|
||||
["bash", str(launcher), "doctor"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5,
|
||||
)
|
||||
assert "not set up yet" in result.stdout
|
||||
|
||||
def test_help_mentions_quick_start(self, tmp_path):
|
||||
"""Help text includes the Quick start snippet."""
|
||||
launcher = _isolated_launcher(tmp_path)
|
||||
result = subprocess.run(
|
||||
["bash", str(launcher)],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5,
|
||||
)
|
||||
assert "Quick start" in result.stdout
|
||||
assert "git clone" in result.stdout
|
||||
|
||||
|
||||
class TestPreSetupInstall:
|
||||
"""Pre-setup: ./aipass install delegates to setup.sh."""
|
||||
|
||||
def test_install_calls_setup_sh(self, tmp_path):
|
||||
"""./aipass install execs setup.sh in the same directory."""
|
||||
launcher = _isolated_launcher(tmp_path)
|
||||
setup = tmp_path / "setup.sh"
|
||||
setup.write_text('#!/usr/bin/env bash\necho "SETUP_CALLED $@"\n')
|
||||
_make_executable(setup)
|
||||
|
||||
result = subprocess.run(
|
||||
["bash", str(launcher), "install"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5,
|
||||
)
|
||||
assert "SETUP_CALLED" in result.stdout
|
||||
|
||||
def test_install_passes_flags(self, tmp_path):
|
||||
"""Flags after 'install' pass through to setup.sh."""
|
||||
launcher = _isolated_launcher(tmp_path)
|
||||
setup = tmp_path / "setup.sh"
|
||||
setup.write_text('#!/usr/bin/env bash\necho "FLAGS:$@"\n')
|
||||
_make_executable(setup)
|
||||
|
||||
result = subprocess.run(
|
||||
["bash", str(launcher), "install", "--no-init"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5,
|
||||
)
|
||||
assert "--no-init" in result.stdout
|
||||
|
||||
def test_install_passes_project_flag(self, tmp_path):
|
||||
"""--project and its value pass through to setup.sh."""
|
||||
launcher = _isolated_launcher(tmp_path)
|
||||
setup = tmp_path / "setup.sh"
|
||||
setup.write_text('#!/usr/bin/env bash\necho "FLAGS:$@"\n')
|
||||
_make_executable(setup)
|
||||
|
||||
result = subprocess.run(
|
||||
["bash", str(launcher), "install", "--project", str(tmp_path / "proj")],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5,
|
||||
)
|
||||
assert "--project" in result.stdout
|
||||
|
||||
|
||||
class TestPostSetupForwarding:
|
||||
"""Post-setup: forwards to the venv binary."""
|
||||
|
||||
def test_forwards_to_venv_binary(self, tmp_path):
|
||||
"""With venv binary present, verbs forward to it."""
|
||||
launcher = _isolated_launcher(tmp_path)
|
||||
_fake_venv_binary(tmp_path, 'echo "VENV_AIPASS $@"')
|
||||
|
||||
result = subprocess.run(
|
||||
["bash", str(launcher), "doctor"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5,
|
||||
)
|
||||
assert "VENV_AIPASS doctor" in result.stdout
|
||||
|
||||
def test_forwards_install_post_setup(self, tmp_path):
|
||||
"""Post-setup ./aipass install goes to venv binary, not setup.sh."""
|
||||
launcher = _isolated_launcher(tmp_path)
|
||||
_fake_venv_binary(tmp_path, 'echo "VENV_AIPASS $@"')
|
||||
|
||||
result = subprocess.run(
|
||||
["bash", str(launcher), "install"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5,
|
||||
)
|
||||
assert "VENV_AIPASS install" in result.stdout
|
||||
|
||||
def test_no_args_forwards_post_setup(self, tmp_path):
|
||||
"""Post-setup bare ./aipass forwards to venv binary."""
|
||||
launcher = _isolated_launcher(tmp_path)
|
||||
_fake_venv_binary(tmp_path, 'echo "VENV_AIPASS_NOARGS"')
|
||||
|
||||
result = subprocess.run(
|
||||
["bash", str(launcher)],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5,
|
||||
)
|
||||
assert "VENV_AIPASS_NOARGS" in result.stdout
|
||||
@@ -94,9 +94,9 @@ class TestSaveProfile:
|
||||
def test_saves_profile_to_disk(self, tmp_local_json) -> None:
|
||||
"""Profile dict is written to user section of local.json."""
|
||||
with patch("aipass.aipass.apps.modules.profile.json_handler"):
|
||||
save_profile({"name": "Alice", "os": "Linux"})
|
||||
save_profile({"name": "user", "os": "Linux"})
|
||||
stored = json.loads(tmp_local_json.read_text())
|
||||
assert stored["user"]["name"] == "Alice"
|
||||
assert stored["user"]["name"] == "user"
|
||||
|
||||
def test_preserves_other_sections(self, tmp_local_json) -> None:
|
||||
"""Existing keys outside 'user' are not overwritten."""
|
||||
@@ -199,12 +199,12 @@ class TestHandleCommand:
|
||||
assert handle_command("profile", ["help"]) is True
|
||||
|
||||
def test_set_valid_field(self, tmp_local_json) -> None:
|
||||
"""'set name Alice' stores value and returns True."""
|
||||
"""'set name user' stores value and returns True."""
|
||||
with patch("aipass.aipass.apps.modules.profile.json_handler.log_operation"):
|
||||
result = handle_command("profile", ["set", "name", "Alice"])
|
||||
result = handle_command("profile", ["set", "name", "user"])
|
||||
assert result is True
|
||||
stored = json.loads(tmp_local_json.read_text())
|
||||
assert stored["user"]["name"] == "Alice"
|
||||
assert stored["user"]["name"] == "user"
|
||||
|
||||
def test_set_invalid_field_returns_true(self, tmp_local_json) -> None:
|
||||
"""Setting an unknown field returns True (handled with error msg)."""
|
||||
|
||||
@@ -14,9 +14,17 @@ Main handles routing, modules implement functionality.
|
||||
"""
|
||||
|
||||
# INFRASTRUCTURE IMPORT PATTERN
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
if sys.platform == "win32":
|
||||
os.environ.setdefault("PYTHONUTF8", "1")
|
||||
for _stream in (sys.stdout, sys.stderr):
|
||||
_reconfigure = getattr(_stream, "reconfigure", None)
|
||||
if _reconfigure is not None:
|
||||
_reconfigure(encoding="utf-8", errors="replace")
|
||||
|
||||
# Standard library imports
|
||||
import importlib
|
||||
from typing import Any, List
|
||||
@@ -141,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]")
|
||||
@@ -279,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)})
|
||||
|
||||
|
||||
@@ -6,12 +6,23 @@
|
||||
# Modified: 2025-11-21
|
||||
# =============================================
|
||||
|
||||
"""JSON auto-creating handler — read, write, and log structured data."""
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from datetime import datetime
|
||||
from typing import Dict, Any, Optional
|
||||
import inspect
|
||||
|
||||
if sys.platform == "win32":
|
||||
os.environ.setdefault("PYTHONUTF8", "1")
|
||||
for _stream in (sys.stdout, sys.stderr):
|
||||
_reconfigure = getattr(_stream, "reconfigure", None)
|
||||
if _reconfigure is not None:
|
||||
_reconfigure(encoding="utf-8", errors="replace")
|
||||
|
||||
# Logging
|
||||
from aipass.prax import logger
|
||||
|
||||
|
||||
@@ -15,9 +15,17 @@ Orchestrates API key and credential operations:
|
||||
- Initialize .env template
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
if sys.platform == "win32":
|
||||
os.environ.setdefault("PYTHONUTF8", "1")
|
||||
for _stream in (sys.stdout, sys.stderr):
|
||||
_reconfigure = getattr(_stream, "reconfigure", None)
|
||||
if _reconfigure is not None:
|
||||
_reconfigure(encoding="utf-8", errors="replace")
|
||||
|
||||
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
|
||||
@@ -272,10 +280,10 @@ def print_help():
|
||||
console.print(" [cyan]drone @api get-secret telegram/bot[/cyan]")
|
||||
console.print()
|
||||
console.print(" [dim]# Write secret to a protected file[/dim]")
|
||||
console.print(" [cyan]drone @api get-secret telegram/bot --out /tmp/token.txt[/cyan]")
|
||||
console.print(" [cyan]drone @api get-secret telegram/bot --out token.txt[/cyan]")
|
||||
console.print()
|
||||
console.print(" [dim]# Write secret as JSON to a protected file[/dim]")
|
||||
console.print(" [cyan]drone @api get-secret telegram/bot --out /tmp/bot.json --json[/cyan]")
|
||||
console.print(" [cyan]drone @api get-secret telegram/bot --out bot.json --json[/cyan]")
|
||||
console.print()
|
||||
console.print(" [dim]# List secrets for a provider[/dim]")
|
||||
console.print(" [cyan]drone @api get-secret telegram --list[/cyan]")
|
||||
@@ -315,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)
|
||||
|
||||
@@ -28,7 +28,16 @@ Thread-safe pattern (for concurrent workers):
|
||||
service = get_drive_service(thread_safe=True)
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
if sys.platform == "win32":
|
||||
os.environ.setdefault("PYTHONUTF8", "1")
|
||||
for _stream in (sys.stdout, sys.stderr):
|
||||
_reconfigure = getattr(_stream, "reconfigure", None)
|
||||
if _reconfigure is not None:
|
||||
_reconfigure(encoding="utf-8", errors="replace")
|
||||
|
||||
from typing import List, Optional
|
||||
|
||||
from aipass.cli.apps.modules import console, header, success, error, warning
|
||||
@@ -348,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)
|
||||
|
||||
@@ -17,11 +17,19 @@ Orchestrates LLM API client operations:
|
||||
- Check status
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
if sys.platform == "win32":
|
||||
os.environ.setdefault("PYTHONUTF8", "1")
|
||||
for _stream in (sys.stdout, sys.stderr):
|
||||
_reconfigure = getattr(_stream, "reconfigure", None)
|
||||
if _reconfigure is not None:
|
||||
_reconfigure(encoding="utf-8", errors="replace")
|
||||
|
||||
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
|
||||
@@ -278,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}")
|
||||
|
||||
@@ -292,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()
|
||||
@@ -357,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)
|
||||
|
||||
@@ -19,12 +19,20 @@ Functions:
|
||||
handle_command() - Route CLI commands (seedgo module discovery)
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
if sys.platform == "win32":
|
||||
os.environ.setdefault("PYTHONUTF8", "1")
|
||||
for _stream in (sys.stdout, sys.stderr):
|
||||
_reconfigure = getattr(_stream, "reconfigure", None)
|
||||
if _reconfigure is not None:
|
||||
_reconfigure(encoding="utf-8", errors="replace")
|
||||
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
|
||||
|
||||
@@ -148,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)
|
||||
|
||||
@@ -16,9 +16,17 @@ Orchestrates API usage monitoring operations:
|
||||
- Cleanup old data
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
if sys.platform == "win32":
|
||||
os.environ.setdefault("PYTHONUTF8", "1")
|
||||
for _stream in (sys.stdout, sys.stderr):
|
||||
_reconfigure = getattr(_stream, "reconfigure", None)
|
||||
if _reconfigure is not None:
|
||||
_reconfigure(encoding="utf-8", errors="replace")
|
||||
|
||||
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, warning
|
||||
@@ -280,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)
|
||||
|
||||
@@ -20,10 +20,17 @@ import sys
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
if sys.platform == "win32":
|
||||
os.environ.setdefault("PYTHONUTF8", "1")
|
||||
for _stream in (sys.stdout, sys.stderr):
|
||||
_reconfigure = getattr(_stream, "reconfigure", None)
|
||||
if _reconfigure is not None:
|
||||
_reconfigure(encoding="utf-8", errors="replace")
|
||||
|
||||
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"
|
||||
@@ -131,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
|
||||
@@ -138,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"
|
||||
@@ -151,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 []
|
||||
@@ -161,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}")
|
||||
|
||||
|
||||
@@ -8,8 +8,16 @@
|
||||
|
||||
"""All Module — runs snapshot then versioned backup with shared scan, then drive sync."""
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
if sys.platform == "win32":
|
||||
os.environ.setdefault("PYTHONUTF8", "1")
|
||||
for _stream in (sys.stdout, sys.stderr):
|
||||
_reconfigure = getattr(_stream, "reconfigure", None)
|
||||
if _reconfigure is not None:
|
||||
_reconfigure(encoding="utf-8", errors="replace")
|
||||
|
||||
from aipass.prax import logger
|
||||
from aipass.cli.apps.modules import console
|
||||
|
||||
|
||||
@@ -8,6 +8,16 @@
|
||||
|
||||
"""Rich CLI rendering for backup — full output pipeline faithfully ported from gold source."""
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
if sys.platform == "win32":
|
||||
os.environ.setdefault("PYTHONUTF8", "1")
|
||||
for _stream in (sys.stdout, sys.stderr):
|
||||
_reconfigure = getattr(_stream, "reconfigure", None)
|
||||
if _reconfigure is not None:
|
||||
_reconfigure(encoding="utf-8", errors="replace")
|
||||
|
||||
from rich.progress import BarColumn, Progress, TextColumn, TimeRemainingColumn
|
||||
|
||||
from aipass.prax import logger
|
||||
|
||||
@@ -8,10 +8,18 @@
|
||||
|
||||
"""Drive Check Module — tests Drive auth through @api gateway."""
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
if sys.platform == "win32":
|
||||
os.environ.setdefault("PYTHONUTF8", "1")
|
||||
for _stream in (sys.stdout, sys.stderr):
|
||||
_reconfigure = getattr(_stream, "reconfigure", None)
|
||||
if _reconfigure is not None:
|
||||
_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
|
||||
|
||||
@@ -53,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(
|
||||
|
||||
@@ -8,10 +8,18 @@
|
||||
|
||||
"""Drive Clear Module — clears the Drive file tracker for a project."""
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
if sys.platform == "win32":
|
||||
os.environ.setdefault("PYTHONUTF8", "1")
|
||||
for _stream in (sys.stdout, sys.stderr):
|
||||
_reconfigure = getattr(_stream, "reconfigure", None)
|
||||
if _reconfigure is not None:
|
||||
_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
|
||||
|
||||
@@ -57,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",
|
||||
|
||||
@@ -8,10 +8,18 @@
|
||||
|
||||
"""Drive Stats Module — displays tracker statistics for a project."""
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
if sys.platform == "win32":
|
||||
os.environ.setdefault("PYTHONUTF8", "1")
|
||||
for _stream in (sys.stdout, sys.stderr):
|
||||
_reconfigure = getattr(_stream, "reconfigure", None)
|
||||
if _reconfigure is not None:
|
||||
_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
|
||||
|
||||
@@ -70,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
|
||||
|
||||
|
||||
|
||||
@@ -15,13 +15,22 @@ Flow: auth → store path → scan → tracker filter → upload_batch → save
|
||||
No pre-resolve — workers create folders on demand via the client's lock pattern.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
if sys.platform == "win32":
|
||||
os.environ.setdefault("PYTHONUTF8", "1")
|
||||
for _stream in (sys.stdout, sys.stderr):
|
||||
_reconfigure = getattr(_stream, "reconfigure", None)
|
||||
if _reconfigure is not None:
|
||||
_reconfigure(encoding="utf-8", errors="replace")
|
||||
|
||||
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
|
||||
@@ -108,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)
|
||||
|
||||
|
||||
@@ -8,11 +8,19 @@
|
||||
|
||||
"""Register Module — register a project for backup and scaffold its .backup/."""
|
||||
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
if sys.platform == "win32":
|
||||
os.environ.setdefault("PYTHONUTF8", "1")
|
||||
for _stream in (sys.stdout, sys.stderr):
|
||||
_reconfigure = getattr(_stream, "reconfigure", None)
|
||||
if _reconfigure is not None:
|
||||
_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
|
||||
@@ -83,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)
|
||||
|
||||
@@ -8,9 +8,17 @@
|
||||
|
||||
"""Restore Module — list versions and restore files from versioned store."""
|
||||
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
if sys.platform == "win32":
|
||||
os.environ.setdefault("PYTHONUTF8", "1")
|
||||
for _stream in (sys.stdout, sys.stderr):
|
||||
_reconfigure = getattr(_stream, "reconfigure", None)
|
||||
if _reconfigure is not None:
|
||||
_reconfigure(encoding="utf-8", errors="replace")
|
||||
|
||||
from aipass.prax import logger
|
||||
from aipass.cli.apps.modules import console
|
||||
|
||||
|
||||
@@ -11,8 +11,16 @@
|
||||
Stub scaffold awaiting Phase 3 handler implementations.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
if sys.platform == "win32":
|
||||
os.environ.setdefault("PYTHONUTF8", "1")
|
||||
for _stream in (sys.stdout, sys.stderr):
|
||||
_reconfigure = getattr(_stream, "reconfigure", None)
|
||||
if _reconfigure is not None:
|
||||
_reconfigure(encoding="utf-8", errors="replace")
|
||||
|
||||
from aipass.prax import logger
|
||||
from aipass.cli.apps.modules import console
|
||||
from aipass.backup.apps.handlers.json import json_handler
|
||||
|
||||
@@ -8,10 +8,18 @@
|
||||
|
||||
"""Share Module — upload a single file to Drive and return a shareable link."""
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
if sys.platform == "win32":
|
||||
os.environ.setdefault("PYTHONUTF8", "1")
|
||||
for _stream in (sys.stdout, sys.stderr):
|
||||
_reconfigure = getattr(_stream, "reconfigure", None)
|
||||
if _reconfigure is not None:
|
||||
_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
|
||||
|
||||
@@ -63,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)
|
||||
@@ -76,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"):
|
||||
|
||||
@@ -12,6 +12,13 @@ import os
|
||||
import sys
|
||||
import time
|
||||
|
||||
if sys.platform == "win32":
|
||||
os.environ.setdefault("PYTHONUTF8", "1")
|
||||
for _stream in (sys.stdout, sys.stderr):
|
||||
_reconfigure = getattr(_stream, "reconfigure", None)
|
||||
if _reconfigure is not None:
|
||||
_reconfigure(encoding="utf-8", errors="replace")
|
||||
|
||||
from aipass.prax import logger
|
||||
from aipass.cli.apps.modules import console
|
||||
|
||||
|
||||
@@ -8,9 +8,17 @@
|
||||
|
||||
"""Status Module — display backup info and recent history for a project."""
|
||||
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
if sys.platform == "win32":
|
||||
os.environ.setdefault("PYTHONUTF8", "1")
|
||||
for _stream in (sys.stdout, sys.stderr):
|
||||
_reconfigure = getattr(_stream, "reconfigure", None)
|
||||
if _reconfigure is not None:
|
||||
_reconfigure(encoding="utf-8", errors="replace")
|
||||
|
||||
from aipass.prax import logger
|
||||
from aipass.cli.apps.modules import console
|
||||
|
||||
|
||||
@@ -8,9 +8,17 @@
|
||||
|
||||
"""Versioned Module — per-file baseline + diff backup of a project directory."""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
|
||||
if sys.platform == "win32":
|
||||
os.environ.setdefault("PYTHONUTF8", "1")
|
||||
for _stream in (sys.stdout, sys.stderr):
|
||||
_reconfigure = getattr(_stream, "reconfigure", None)
|
||||
if _reconfigure is not None:
|
||||
_reconfigure(encoding="utf-8", errors="replace")
|
||||
|
||||
from aipass.prax import logger
|
||||
from aipass.cli.apps.modules import console
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -1,11 +1,65 @@
|
||||
# CLI Branch-Local Context
|
||||
# CLI — Branch Prompt
|
||||
<!-- File: src/aipass/cli/.aipass/aipass_local_prompt.md — Injected every prompt when in cli directory. -->
|
||||
|
||||
## Status: NEEDS CONFIGURATION
|
||||
Shared Rich display and formatting service for all AIPass branches. Provides consistent terminal output — headers, success/error/warning messages, section breaks, operation templates — so every branch renders the same without duplicating formatting code.
|
||||
|
||||
Injected into every AI conversation when working this branch directory. Configure:
|
||||
# Commands
|
||||
|
||||
- Branch identity (role, purpose)
|
||||
- Key commands + workflows
|
||||
- Architecture overview
|
||||
- Critical files + operational rules
|
||||
- Integration points, other branches
|
||||
```
|
||||
drone @cli --help # Full help + architecture overview
|
||||
drone @cli # Module discovery (introspection)
|
||||
drone @cli display demo # Display function showcase
|
||||
drone @cli templates demo # Operation template showcase
|
||||
```
|
||||
|
||||
# Public API
|
||||
|
||||
10 symbols exported from `apps/modules/__init__.py`:
|
||||
- `console`, `err_console` — Rich Console instances (stdout, stderr)
|
||||
- `header(title, details=None)` — Bordered section header with optional key-value pairs
|
||||
- `success(message, **kwargs)` — Green checkmark with metadata
|
||||
- `error(message, suggestion=None)` — Red error with optional suggestion
|
||||
- `warning(message, details=None)` — Yellow warning with optional details
|
||||
- `fatal(message, suggestion=None)` — Error then `sys.exit(1)`
|
||||
- `section(title)` — Visual section separator
|
||||
- `operation_start(operation, **details)` — Operation begin header
|
||||
- `operation_complete(**summary)` — Completion summary with timing
|
||||
|
||||
Import: `from aipass.cli import console, header, success, error, warning, section` (top-level, 6 symbols) or `from aipass.cli.apps.modules import ...` (full set, 10 symbols).
|
||||
|
||||
# Architecture
|
||||
|
||||
```
|
||||
cli/
|
||||
├── __init__.py # Top-level exports (6 symbols) + cli_entry()
|
||||
├── apps/
|
||||
│ ├── cli.py # Entry point (main, route_command)
|
||||
│ ├── modules/ # PUBLIC — import from here
|
||||
│ │ ├── display.py # header, success, error, warning, fatal, section
|
||||
│ │ └── templates.py # operation_start, operation_complete
|
||||
│ └── handlers/
|
||||
│ └── json/ # JSON lifecycle (CRUD, validation, rotation)
|
||||
└── tests/ # 127 tests across 5 files
|
||||
```
|
||||
|
||||
Two-tier design: `apps/modules/` is the public API. `apps/handlers/` is internal — don't import directly. See README for full tree.
|
||||
|
||||
# Critical Rules
|
||||
|
||||
- `apps/modules/` must not import `aipass.prax` — circular dependency (prax depends on cli). Bypassed in `.seedgo/bypass.json`.
|
||||
- `json_handler.py` must not import prax either — same circular chain. Callers log via prax.
|
||||
- Import json_handler as module: `from aipass.cli.apps.handlers.json import json_handler` then `json_handler.log_operation(...)`. Seedgo AST checker matches this exact pattern.
|
||||
- `error()` suggestion param must not include "Try:" prefix — `display.py` adds it automatically.
|
||||
- `handle_command()` accepts both direct command (`command='init'` from PATH) and prefixed command (`command='aipass'` from drone). Both paths must stay wired.
|
||||
|
||||
# Integration Points
|
||||
|
||||
- Depends on: `rich` (formatting), `aipass.prax` (logging, in cli.py only), Python stdlib
|
||||
- Provides to: all branches — display functions, operation templates, Rich console access
|
||||
- Cannot import in modules/: `aipass.prax` — see bypass rules above
|
||||
|
||||
# Entry Points
|
||||
|
||||
- `drone @cli [command]` — routes to `apps/cli.py:main()`
|
||||
- `python -m aipass.cli` — `__main__.py` calls `main()`
|
||||
- `aipass` on PATH — console_scripts via `cli_entry()` in `__init__.py`
|
||||
|
||||
@@ -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
|
||||
@@ -291,6 +298,10 @@ def main() -> int:
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
if hasattr(sys.stdout, "reconfigure"):
|
||||
sys.stdout.reconfigure(encoding="utf-8") # type: ignore[attr-defined]
|
||||
if hasattr(sys.stderr, "reconfigure"):
|
||||
sys.stderr.reconfigure(encoding="utf-8") # type: ignore[attr-defined]
|
||||
try:
|
||||
sys.exit(main())
|
||||
except KeyboardInterrupt:
|
||||
|
||||
@@ -201,6 +201,12 @@ def log_operation(operation: str, data: Dict[str, Any] | None = None, module_nam
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
|
||||
if hasattr(sys.stdout, "reconfigure"):
|
||||
sys.stdout.reconfigure(encoding="utf-8") # type: ignore[attr-defined]
|
||||
if hasattr(sys.stderr, "reconfigure"):
|
||||
sys.stderr.reconfigure(encoding="utf-8") # type: ignore[attr-defined]
|
||||
from rich.console import Console
|
||||
from rich.panel import Panel
|
||||
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
# ============================================================================
|
||||
@@ -418,6 +466,10 @@ __all__ = [
|
||||
# ============================================================================
|
||||
|
||||
if __name__ == "__main__":
|
||||
if hasattr(sys.stdout, "reconfigure"):
|
||||
sys.stdout.reconfigure(encoding="utf-8") # type: ignore[attr-defined]
|
||||
if hasattr(sys.stderr, "reconfigure"):
|
||||
sys.stderr.reconfigure(encoding="utf-8") # type: ignore[attr-defined]
|
||||
try:
|
||||
# Show introspection when run without arguments
|
||||
if len(sys.argv) == 1:
|
||||
|
||||
@@ -203,6 +203,10 @@ def operation_complete(**summary) -> None:
|
||||
# ============================================================================
|
||||
|
||||
if __name__ == "__main__":
|
||||
if hasattr(sys.stdout, "reconfigure"):
|
||||
sys.stdout.reconfigure(encoding="utf-8") # type: ignore[attr-defined]
|
||||
if hasattr(sys.stderr, "reconfigure"):
|
||||
sys.stderr.reconfigure(encoding="utf-8") # type: ignore[attr-defined]
|
||||
# Show introspection when run without arguments
|
||||
if len(sys.argv) == 1:
|
||||
print_introspection()
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user