chore(standards): all 17 branches to 100% — Windows-compat sweep + checker getattr fix
Windows-compat hardening across every branch to reach 100% on the seedgo standards audit: - UTF-8 stdout/stderr reconfigure guards (getattr form) on Rich/CLI entry points - platform-branched POSIX-only subprocess kwargs (start_new_session -> CREATE_NEW_PROCESS_GROUP on win32) - seedgo windows_compat checker: credit the getattr reconfigure form + locking test - commons: shared-init test-suite speedup - spawn: aipass_framework template — strip pytest.ini inline comments + add scaffold smoke test - includes in-progress cross-OS testing work (doctor/init_flow/cross_os handlers + tests) Verified: full audit 17/17 at 100%, pyright clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013uzDhtcZ6wT1T9e2AHPQig
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
8630cd90a3
commit
4105a7e8a7
@@ -13,6 +13,17 @@ PyPI version — not the changelog header.
|
||||
|
||||
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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
# =============================================================================
|
||||
|
||||
@@ -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."""
|
||||
@@ -415,7 +413,7 @@ def spawn_agent(
|
||||
monitor_cmd,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
start_new_session=True,
|
||||
start_new_session=(sys.platform != "win32"),
|
||||
cwd=str(branch_path),
|
||||
env=spawn_env,
|
||||
)
|
||||
|
||||
@@ -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]"
|
||||
|
||||
|
||||
|
||||
@@ -583,7 +583,7 @@ def wake_branch(
|
||||
stdin=subprocess.DEVNULL,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
start_new_session=True,
|
||||
start_new_session=(sys.platform != "win32"),
|
||||
cwd=str(branch_path),
|
||||
env=spawn_env,
|
||||
)
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
# =============================================
|
||||
|
||||
@@ -24,6 +24,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."""
|
||||
@@ -379,12 +386,13 @@ def _spawn_watchdog(target: str) -> None:
|
||||
if local_bin not in spawn_env.get("PATH", ""):
|
||||
spawn_env["PATH"] = local_bin + ":" + spawn_env.get("PATH", "")
|
||||
|
||||
_new_session = sys.platform != "win32"
|
||||
try:
|
||||
subprocess.Popen(
|
||||
cmd,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
start_new_session=True,
|
||||
start_new_session=_new_session,
|
||||
cwd=str(devpulse_dir),
|
||||
env=spawn_env,
|
||||
)
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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` (11 stages: welcome, system detect, profile, style questions, tool choice, docker offer, 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 # 11-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 # 11-stage guided setup, resumable
|
||||
│ ├── handoff.py # CLI handoff (tmux / wt.exe)
|
||||
│ └── profile.py # User profile read/write
|
||||
└── handlers/
|
||||
|
||||
@@ -276,6 +276,21 @@
|
||||
"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,15 @@ 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
|
||||
│ │ └── 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 +34,7 @@ aipass/
|
||||
│ │ ├── system_detect/ # OS, shell, Python, RAM, CPU
|
||||
│ │ └── ui/ # Progress bars, menus, banners
|
||||
│ └── plugins/
|
||||
├── tests/ # 432 passing
|
||||
├── tests/ # 569 passing
|
||||
├── requirements.project.txt # Project-specific Python dependencies
|
||||
├── .trinity/ # Identity + session history + observations
|
||||
└── README.md
|
||||
@@ -48,7 +49,10 @@ 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 profile` | Show/edit user profile |
|
||||
| `aipass --version` | Version |
|
||||
|
||||
@@ -70,7 +74,7 @@ Humans only. Nothing in AIPass depends on this branch.
|
||||
|
||||
## Tests
|
||||
|
||||
432 passing — `pytest src/aipass/aipass/tests/`
|
||||
569 passing — `pytest src/aipass/aipass/tests/`
|
||||
|
||||
## Known Issues
|
||||
|
||||
@@ -78,4 +82,4 @@ Humans only. Nothing in AIPass depends on this branch.
|
||||
|
||||
## Last Updated
|
||||
|
||||
Last Updated: 2026-06-05
|
||||
Last Updated: 2026-07-04
|
||||
|
||||
@@ -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
|
||||
@@ -22,6 +22,18 @@ 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,
|
||||
@@ -661,6 +673,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:
|
||||
console.print(f"[red]✗[/red] {exc}")
|
||||
logger.error("[doctor] cross-os run record failed: %s", exc)
|
||||
return 1
|
||||
|
||||
console.print(f"[green]✓[/green] Run Record written: [bold]{written}[/bold]")
|
||||
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 ---
|
||||
|
||||
|
||||
@@ -752,6 +955,12 @@ def print_help() -> None:
|
||||
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 --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]EXIT:[/yellow] 0 = pass/warn | 1 = errors found")
|
||||
@@ -782,6 +991,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
|
||||
|
||||
@@ -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
|
||||
"""
|
||||
|
||||
@@ -41,7 +41,6 @@ from aipass.prax import logger
|
||||
from aipass.aipass.apps.handlers.json import json_handler
|
||||
from aipass.aipass.apps.handlers.system_detect.system_detector import (
|
||||
detect_cpu,
|
||||
detect_docker,
|
||||
detect_git,
|
||||
detect_install_method,
|
||||
detect_os,
|
||||
@@ -62,7 +61,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 +94,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 ---
|
||||
@@ -233,15 +233,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("[bold cyan]Step 1/10[/bold cyan] — 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("[bold cyan]Step 2/10[/bold cyan] — System detection")
|
||||
|
||||
from rich.table import Table
|
||||
|
||||
@@ -254,7 +286,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 +300,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 +307,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 +329,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("[bold cyan]Step 3/10[/bold cyan] — User profile")
|
||||
|
||||
from aipass.aipass.apps.modules import profile as profile_mod
|
||||
|
||||
@@ -357,18 +358,18 @@ def stage_4_user_profile(
|
||||
profile_mod.save_profile(existing)
|
||||
|
||||
console.print(f"[green]✓[/green] Hello, {name}!")
|
||||
_save_stage(4, {"name": name}, dry_run=dry_run)
|
||||
_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("[bold cyan]Step 4/10[/bold cyan] — What brings you here?")
|
||||
|
||||
if style_override and style_override in STYLE_CHOICES:
|
||||
style = style_override
|
||||
@@ -378,7 +379,7 @@ def stage_5_style_questions(
|
||||
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)
|
||||
_save_stage(4, {"style": style}, dry_run=dry_run)
|
||||
return {"style": style}
|
||||
|
||||
|
||||
@@ -415,7 +416,7 @@ 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")
|
||||
@@ -427,17 +428,17 @@ def _handle_missing_claude(non_interactive: bool) -> None:
|
||||
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("[bold cyan]Step 5/10[/bold cyan] — CLI tool choice")
|
||||
|
||||
if cli_override and cli_override in CLI_CHOICES:
|
||||
cli_choice = cli_override
|
||||
@@ -459,7 +460,7 @@ def stage_6_tool_choice(
|
||||
)
|
||||
|
||||
console.print(f"[green]✓[/green] {cli_choice} ({flag_variant})")
|
||||
_save_stage(6, {"cli": cli_choice, "flag_variant": flag_variant}, dry_run=dry_run)
|
||||
_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 +477,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("[bold cyan]Step 6/10[/bold cyan] — Create your first agent")
|
||||
console.print("Let's create your first AI agent (citizen).")
|
||||
|
||||
if non_interactive:
|
||||
@@ -530,30 +504,30 @@ def stage_8_first_agent(non_interactive: bool = False, dry_run: bool = False) ->
|
||||
proc = subprocess.run(["drone", "@spawn", "create", agent_path], timeout=60)
|
||||
success = 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}")
|
||||
|
||||
_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": success}, 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("[bold cyan]Step 7/10[/bold cyan] — 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 +539,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]")
|
||||
@@ -587,14 +561,14 @@ 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("[bold cyan]Step 8/10[/bold cyan] — Smoke test")
|
||||
|
||||
drone_bin = shutil.which("drone")
|
||||
aipass_bin = shutil.which("aipass")
|
||||
@@ -609,11 +583,13 @@ def stage_10_smoke_test(non_interactive: bool = False, dry_run: bool = False) ->
|
||||
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 +599,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("[bold cyan]Step 9/10[/bold cyan] — Handoff")
|
||||
|
||||
init_prompt = "I just completed aipass init. I am ready to start. What should I do first?"
|
||||
|
||||
@@ -657,8 +633,8 @@ 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()
|
||||
@@ -679,10 +655,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 +697,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,10 +709,10 @@ 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("[bold cyan]Step 10/10[/bold cyan] — Done!")
|
||||
console.print()
|
||||
console.print("[bold green]✓ Setup complete![/bold green]")
|
||||
console.print()
|
||||
@@ -732,7 +722,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,11 +768,10 @@ 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:
|
||||
@@ -826,17 +815,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 +832,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:
|
||||
@@ -881,7 +868,7 @@ 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]")
|
||||
@@ -904,11 +891,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()
|
||||
|
||||
|
||||
@@ -1059,7 +1045,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 +1052,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,
|
||||
)
|
||||
@@ -1094,4 +1078,14 @@ def handle_command(command: str, args: list[str]) -> bool:
|
||||
|
||||
|
||||
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,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
|
||||
@@ -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"])
|
||||
|
||||
|
||||
# =============================================================================
|
||||
@@ -264,20 +262,18 @@ def _bypass_preflight():
|
||||
|
||||
class TestRunInit:
|
||||
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 +287,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 +323,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 +346,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 +394,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 +409,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 +421,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 +467,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 +476,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 +492,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 +507,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
|
||||
|
||||
|
||||
# =============================================================================
|
||||
@@ -735,25 +740,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 +764,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 +818,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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -17,8 +17,16 @@ 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
|
||||
|
||||
@@ -19,8 +19,16 @@ 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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -20,6 +20,13 @@ 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
|
||||
|
||||
@@ -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,8 +8,16 @@
|
||||
|
||||
"""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
|
||||
|
||||
|
||||
@@ -8,8 +8,16 @@
|
||||
|
||||
"""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
|
||||
|
||||
|
||||
@@ -8,8 +8,16 @@
|
||||
|
||||
"""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
|
||||
|
||||
|
||||
@@ -15,10 +15,18 @@ 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
|
||||
|
||||
|
||||
@@ -8,9 +8,17 @@
|
||||
|
||||
"""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
|
||||
|
||||
|
||||
@@ -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,8 +8,16 @@
|
||||
|
||||
"""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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -291,6 +291,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
|
||||
|
||||
|
||||
@@ -418,6 +418,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()
|
||||
|
||||
@@ -19,11 +19,19 @@ Auto-discovery architecture:
|
||||
"""
|
||||
|
||||
import importlib
|
||||
import os
|
||||
import signal
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import List, 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")
|
||||
|
||||
# Fix: When run as a script, Python adds apps/ to sys.path[0] which causes
|
||||
# this file (commons.py) to shadow the commons package. Remove it so the
|
||||
# installed package resolves correctly.
|
||||
@@ -36,8 +44,8 @@ if hasattr(signal, "SIGPIPE"):
|
||||
signal.signal(signal.SIGPIPE, signal.SIG_DFL)
|
||||
|
||||
# Cross-branch imports
|
||||
from aipass.prax.apps.modules.logger import system_logger as logger
|
||||
from aipass.cli.apps.modules import console, header, error, warning
|
||||
from aipass.prax.apps.modules.logger import system_logger as logger # noqa: E402
|
||||
from aipass.cli.apps.modules import console, header, error, warning # noqa: E402
|
||||
|
||||
|
||||
# =============================================================================
|
||||
|
||||
@@ -21,13 +21,39 @@ Tests the complete lifecycle of posts, comments, votes, rooms, and feeds.
|
||||
Uses a temporary SQLite database for each test class to ensure isolation.
|
||||
"""
|
||||
|
||||
import unittest
|
||||
import shutil
|
||||
import sqlite3
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from aipass.commons.apps.handlers.database.db import init_db, close_db
|
||||
|
||||
_TEMPLATE_DB = None
|
||||
|
||||
|
||||
def _get_template_db():
|
||||
global _TEMPLATE_DB
|
||||
if _TEMPLATE_DB is None:
|
||||
tmp = tempfile.NamedTemporaryFile(delete=False, suffix=".db")
|
||||
path = Path(tmp.name)
|
||||
tmp.close()
|
||||
conn = init_db(path)
|
||||
conn.execute("PRAGMA wal_checkpoint(TRUNCATE)")
|
||||
close_db(conn)
|
||||
_TEMPLATE_DB = path
|
||||
return _TEMPLATE_DB
|
||||
|
||||
|
||||
def _fast_db(db_path):
|
||||
shutil.copy2(str(_get_template_db()), str(db_path))
|
||||
conn = sqlite3.connect(str(db_path), timeout=30)
|
||||
conn.row_factory = sqlite3.Row
|
||||
conn.execute("PRAGMA foreign_keys = ON")
|
||||
conn.execute("PRAGMA journal_mode = MEMORY")
|
||||
conn.execute("PRAGMA synchronous = OFF")
|
||||
return conn
|
||||
|
||||
|
||||
class TestPostLifecycle(unittest.TestCase):
|
||||
"""Test creating, reading, and deleting posts."""
|
||||
@@ -38,7 +64,7 @@ class TestPostLifecycle(unittest.TestCase):
|
||||
self.db_path = Path(self.temp_db.name)
|
||||
self.temp_db.close()
|
||||
|
||||
self.conn = init_db(self.db_path)
|
||||
self.conn = _fast_db(self.db_path)
|
||||
|
||||
self.conn.execute(
|
||||
"INSERT OR IGNORE INTO agents (branch_name, display_name) VALUES (?, ?)", ("TEST_AGENT", "Test Agent")
|
||||
@@ -47,7 +73,7 @@ class TestPostLifecycle(unittest.TestCase):
|
||||
|
||||
def tearDown(self):
|
||||
"""Clean up test database."""
|
||||
close_db(self.conn)
|
||||
self.conn.close()
|
||||
if self.db_path.exists():
|
||||
self.db_path.unlink()
|
||||
|
||||
@@ -140,7 +166,7 @@ class TestCommentSystem(unittest.TestCase):
|
||||
self.db_path = Path(self.temp_db.name)
|
||||
self.temp_db.close()
|
||||
|
||||
self.conn = init_db(self.db_path)
|
||||
self.conn = _fast_db(self.db_path)
|
||||
|
||||
for agent in ["TEST_AGENT_1", "TEST_AGENT_2"]:
|
||||
self.conn.execute(
|
||||
@@ -157,7 +183,7 @@ class TestCommentSystem(unittest.TestCase):
|
||||
|
||||
def tearDown(self):
|
||||
"""Clean up test database."""
|
||||
close_db(self.conn)
|
||||
self.conn.close()
|
||||
if self.db_path.exists():
|
||||
self.db_path.unlink()
|
||||
|
||||
@@ -257,7 +283,7 @@ class TestVoteSystem(unittest.TestCase):
|
||||
self.db_path = Path(self.temp_db.name)
|
||||
self.temp_db.close()
|
||||
|
||||
self.conn = init_db(self.db_path)
|
||||
self.conn = _fast_db(self.db_path)
|
||||
|
||||
for agent in ["VOTER_1", "VOTER_2", "AUTHOR"]:
|
||||
self.conn.execute("INSERT OR IGNORE INTO agents (branch_name, display_name) VALUES (?, ?)", (agent, agent))
|
||||
@@ -277,7 +303,7 @@ class TestVoteSystem(unittest.TestCase):
|
||||
|
||||
def tearDown(self):
|
||||
"""Clean up test database."""
|
||||
close_db(self.conn)
|
||||
self.conn.close()
|
||||
if self.db_path.exists():
|
||||
self.db_path.unlink()
|
||||
|
||||
@@ -420,7 +446,7 @@ class TestFeedSorting(unittest.TestCase):
|
||||
self.db_path = Path(self.temp_db.name)
|
||||
self.temp_db.close()
|
||||
|
||||
self.conn = init_db(self.db_path)
|
||||
self.conn = _fast_db(self.db_path)
|
||||
|
||||
self.conn.execute(
|
||||
"INSERT OR IGNORE INTO agents (branch_name, display_name) VALUES (?, ?)", ("TEST_AGENT", "Test Agent")
|
||||
@@ -445,7 +471,7 @@ class TestFeedSorting(unittest.TestCase):
|
||||
|
||||
def tearDown(self):
|
||||
"""Clean up test database."""
|
||||
close_db(self.conn)
|
||||
self.conn.close()
|
||||
if self.db_path.exists():
|
||||
self.db_path.unlink()
|
||||
|
||||
@@ -481,7 +507,7 @@ class TestRoomManagement(unittest.TestCase):
|
||||
self.db_path = Path(self.temp_db.name)
|
||||
self.temp_db.close()
|
||||
|
||||
self.conn = init_db(self.db_path)
|
||||
self.conn = _fast_db(self.db_path)
|
||||
|
||||
self.conn.execute(
|
||||
"INSERT OR IGNORE INTO agents (branch_name, display_name) VALUES (?, ?)", ("TEST_AGENT", "Test Agent")
|
||||
@@ -490,7 +516,7 @@ class TestRoomManagement(unittest.TestCase):
|
||||
|
||||
def tearDown(self):
|
||||
"""Clean up test database."""
|
||||
close_db(self.conn)
|
||||
self.conn.close()
|
||||
if self.db_path.exists():
|
||||
self.db_path.unlink()
|
||||
|
||||
@@ -573,7 +599,7 @@ class TestDatabaseIntegrity(unittest.TestCase):
|
||||
self.db_path = Path(self.temp_db.name)
|
||||
self.temp_db.close()
|
||||
|
||||
self.conn = init_db(self.db_path)
|
||||
self.conn = _fast_db(self.db_path)
|
||||
|
||||
self.conn.execute(
|
||||
"INSERT OR IGNORE INTO agents (branch_name, display_name) VALUES (?, ?)", ("TEST_AGENT", "Test Agent")
|
||||
@@ -582,7 +608,7 @@ class TestDatabaseIntegrity(unittest.TestCase):
|
||||
|
||||
def tearDown(self):
|
||||
"""Clean up test database."""
|
||||
close_db(self.conn)
|
||||
self.conn.close()
|
||||
if self.db_path.exists():
|
||||
self.db_path.unlink()
|
||||
|
||||
@@ -672,7 +698,7 @@ class TestNotificationPreferences(unittest.TestCase):
|
||||
self.db_path = Path(self.temp_db.name)
|
||||
self.temp_db.close()
|
||||
|
||||
self.conn = init_db(self.db_path)
|
||||
self.conn = _fast_db(self.db_path)
|
||||
|
||||
for agent in ["AGENT_A", "AGENT_B", "AGENT_C"]:
|
||||
self.conn.execute(
|
||||
@@ -697,7 +723,7 @@ class TestNotificationPreferences(unittest.TestCase):
|
||||
|
||||
def tearDown(self):
|
||||
"""Clean up test database."""
|
||||
close_db(self.conn)
|
||||
self.conn.close()
|
||||
if self.db_path.exists():
|
||||
self.db_path.unlink()
|
||||
|
||||
@@ -821,7 +847,7 @@ class TestSocialProfiles(unittest.TestCase):
|
||||
self.db_path = Path(self.temp_db.name)
|
||||
self.temp_db.close()
|
||||
|
||||
self.conn = init_db(self.db_path)
|
||||
self.conn = _fast_db(self.db_path)
|
||||
|
||||
for agent in ["PROFILE_A", "PROFILE_B"]:
|
||||
self.conn.execute(
|
||||
@@ -850,7 +876,7 @@ class TestSocialProfiles(unittest.TestCase):
|
||||
|
||||
def tearDown(self):
|
||||
"""Clean up test database."""
|
||||
close_db(self.conn)
|
||||
self.conn.close()
|
||||
if self.db_path.exists():
|
||||
self.db_path.unlink()
|
||||
|
||||
@@ -873,6 +899,7 @@ class TestSocialProfiles(unittest.TestCase):
|
||||
self.assertTrue(result)
|
||||
|
||||
profile = self.get_profile(self.conn, "PROFILE_A")
|
||||
assert profile is not None
|
||||
self.assertEqual(profile["bio"], "I enforce code quality standards")
|
||||
|
||||
def test_update_status(self):
|
||||
@@ -881,6 +908,7 @@ class TestSocialProfiles(unittest.TestCase):
|
||||
self.assertTrue(result)
|
||||
|
||||
profile = self.get_profile(self.conn, "PROFILE_A")
|
||||
assert profile is not None
|
||||
self.assertEqual(profile["status"], "Auditing branches")
|
||||
|
||||
def test_update_role(self):
|
||||
@@ -889,6 +917,7 @@ class TestSocialProfiles(unittest.TestCase):
|
||||
self.assertTrue(result)
|
||||
|
||||
profile = self.get_profile(self.conn, "PROFILE_A")
|
||||
assert profile is not None
|
||||
self.assertEqual(profile["role"], "Standards Authority")
|
||||
|
||||
def test_increment_post_count(self):
|
||||
@@ -897,12 +926,14 @@ class TestSocialProfiles(unittest.TestCase):
|
||||
self.conn.commit()
|
||||
|
||||
stats = self.get_activity_stats(self.conn, "PROFILE_A")
|
||||
assert stats is not None
|
||||
self.assertEqual(stats["post_count"], 1)
|
||||
|
||||
self.increment_post_count(self.conn, "PROFILE_A")
|
||||
self.conn.commit()
|
||||
|
||||
stats = self.get_activity_stats(self.conn, "PROFILE_A")
|
||||
assert stats is not None
|
||||
self.assertEqual(stats["post_count"], 2)
|
||||
|
||||
def test_increment_comment_count(self):
|
||||
@@ -911,6 +942,7 @@ class TestSocialProfiles(unittest.TestCase):
|
||||
self.conn.commit()
|
||||
|
||||
stats = self.get_activity_stats(self.conn, "PROFILE_A")
|
||||
assert stats is not None
|
||||
self.assertEqual(stats["comment_count"], 1)
|
||||
|
||||
for _ in range(3):
|
||||
@@ -918,6 +950,7 @@ class TestSocialProfiles(unittest.TestCase):
|
||||
self.conn.commit()
|
||||
|
||||
stats = self.get_activity_stats(self.conn, "PROFILE_A")
|
||||
assert stats is not None
|
||||
self.assertEqual(stats["comment_count"], 4)
|
||||
|
||||
def test_get_profile_returns_all_fields(self):
|
||||
@@ -929,6 +962,7 @@ class TestSocialProfiles(unittest.TestCase):
|
||||
profile = self.get_profile(self.conn, "PROFILE_B")
|
||||
|
||||
self.assertIsNotNone(profile)
|
||||
assert profile is not None
|
||||
expected_keys = [
|
||||
"branch_name",
|
||||
"display_name",
|
||||
@@ -963,7 +997,7 @@ class TestWelcomeOnboarding(unittest.TestCase):
|
||||
self.db_path = Path(self.temp_db.name)
|
||||
self.temp_db.close()
|
||||
|
||||
self.conn = init_db(self.db_path)
|
||||
self.conn = _fast_db(self.db_path)
|
||||
|
||||
for agent in ["WELCOME_A", "WELCOME_B", "WELCOME_C"]:
|
||||
self.conn.execute(
|
||||
@@ -986,7 +1020,7 @@ class TestWelcomeOnboarding(unittest.TestCase):
|
||||
|
||||
def tearDown(self):
|
||||
"""Clean up test database."""
|
||||
close_db(self.conn)
|
||||
self.conn.close()
|
||||
if self.db_path.exists():
|
||||
self.db_path.unlink()
|
||||
|
||||
@@ -1041,6 +1075,7 @@ class TestWelcomeOnboarding(unittest.TestCase):
|
||||
nudge = self.get_onboarding_nudge(self.conn, "WELCOME_A")
|
||||
|
||||
self.assertIsNotNone(nudge)
|
||||
assert nudge is not None
|
||||
self.assertIn("haven't posted yet", nudge)
|
||||
self.assertIn("commons post", nudge)
|
||||
|
||||
@@ -1060,6 +1095,7 @@ class TestWelcomeOnboarding(unittest.TestCase):
|
||||
nudge = self.get_onboarding_nudge(self.conn, "WELCOME_C")
|
||||
|
||||
self.assertIsNotNone(nudge)
|
||||
assert nudge is not None
|
||||
self.assertIn("commenting but never posted", nudge)
|
||||
|
||||
def test_welcome_new_branches(self):
|
||||
@@ -1087,7 +1123,7 @@ class TestSearchAndLogs(unittest.TestCase):
|
||||
self.db_path = Path(self.temp_db.name)
|
||||
self.temp_db.close()
|
||||
|
||||
self.conn = init_db(self.db_path)
|
||||
self.conn = _fast_db(self.db_path)
|
||||
|
||||
for agent in ["SEARCH_A", "SEARCH_B", "SEARCH_C"]:
|
||||
self.conn.execute(
|
||||
@@ -1114,7 +1150,7 @@ class TestSearchAndLogs(unittest.TestCase):
|
||||
|
||||
def tearDown(self):
|
||||
"""Clean up test database."""
|
||||
close_db(self.conn)
|
||||
self.conn.close()
|
||||
if self.db_path.exists():
|
||||
self.db_path.unlink()
|
||||
|
||||
@@ -1124,6 +1160,7 @@ class TestSearchAndLogs(unittest.TestCase):
|
||||
"INSERT INTO posts (room_name, author, title, content) VALUES (?, ?, ?, ?)", (room, author, title, content)
|
||||
)
|
||||
post_id = cursor.lastrowid
|
||||
assert post_id is not None
|
||||
self.conn.commit()
|
||||
self.sync_post_to_fts(self.conn, post_id, title, content, author, room)
|
||||
self.conn.commit()
|
||||
@@ -1136,6 +1173,7 @@ class TestSearchAndLogs(unittest.TestCase):
|
||||
(post_id, parent_id, author, content),
|
||||
)
|
||||
comment_id = cursor.lastrowid
|
||||
assert comment_id is not None
|
||||
self.conn.commit()
|
||||
self.sync_comment_to_fts(self.conn, comment_id, content, author)
|
||||
self.conn.commit()
|
||||
@@ -1207,6 +1245,7 @@ class TestSearchAndLogs(unittest.TestCase):
|
||||
("general", "SEARCH_A", "Unsynced Post", "This is not yet indexed"),
|
||||
)
|
||||
post_id = cursor.lastrowid
|
||||
assert post_id is not None
|
||||
self.conn.commit()
|
||||
|
||||
results = self.search_posts(self.conn, "unsynced")
|
||||
@@ -1251,7 +1290,7 @@ class TestReactionsAndPins(unittest.TestCase):
|
||||
self.db_path = Path(self.temp_db.name)
|
||||
self.temp_db.close()
|
||||
|
||||
self.conn = init_db(self.db_path)
|
||||
self.conn = _fast_db(self.db_path)
|
||||
|
||||
for agent in ["REACT_A", "REACT_B", "REACT_C", "AUTHOR_X"]:
|
||||
self.conn.execute(
|
||||
@@ -1299,7 +1338,7 @@ class TestReactionsAndPins(unittest.TestCase):
|
||||
|
||||
def tearDown(self):
|
||||
"""Clean up test database."""
|
||||
close_db(self.conn)
|
||||
self.conn.close()
|
||||
if self.db_path.exists():
|
||||
self.db_path.unlink()
|
||||
|
||||
|
||||
@@ -15,9 +15,17 @@ Explicit module imports:
|
||||
"""
|
||||
|
||||
# Standard library imports
|
||||
import os
|
||||
import sys
|
||||
from typing import List, 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")
|
||||
|
||||
# Logger
|
||||
from aipass.prax.apps.modules.logger import system_logger as logger
|
||||
|
||||
|
||||
@@ -22,11 +22,19 @@ Flow:
|
||||
# IMPORTS
|
||||
# =============================================
|
||||
|
||||
import os
|
||||
import sys
|
||||
import json
|
||||
from pathlib import Path
|
||||
from datetime import datetime
|
||||
|
||||
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.apps.modules.logger import system_logger as logger
|
||||
from aipass.cli.apps.modules import console
|
||||
from aipass.daemon.apps.handlers.json import json_handler
|
||||
|
||||
@@ -13,11 +13,20 @@ Provides auto-creating JSON file management with templates.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from datetime import datetime
|
||||
from typing import Dict, List, 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")
|
||||
|
||||
from aipass.prax import logger
|
||||
|
||||
# Constants
|
||||
|
||||
@@ -18,10 +18,18 @@ This is a MODULE (orchestration layer) that coordinates:
|
||||
- red_flag_detector: Detects presence violations (code changed but memory not updated)
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
from typing import List
|
||||
|
||||
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
|
||||
# logger imported from aipass.prax
|
||||
|
||||
from aipass.cli.apps.modules import console, error
|
||||
from aipass.daemon.apps.handlers.json import json_handler
|
||||
|
||||
@@ -14,9 +14,17 @@ Returns digest of DAEMON activity for check-ins.
|
||||
# IMPORTS
|
||||
# =============================================
|
||||
|
||||
import os
|
||||
import sys
|
||||
from typing import Dict, Any, List
|
||||
|
||||
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, error
|
||||
|
||||
@@ -15,11 +15,25 @@ Auto-discovery architecture:
|
||||
- No manual imports or routing needed
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import importlib
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
# Windows terminals/pipes default to cp1252, which can't encode the Unicode
|
||||
# Rich emits (✓/✗, box-drawing, arrows). PYTHONUTF8 only affects child
|
||||
# interpreters, not this process's already-open stdout/stderr — so we also
|
||||
# reconfigure the live streams to UTF-8 in place (Python 3.7+). Without this,
|
||||
# the introspection/help banners crash with UnicodeEncodeError on Windows.
|
||||
# Mirrors aipass/apps/aipass.py and drone/cli.py.
|
||||
if sys.platform == "win32":
|
||||
os.environ.setdefault("PYTHONUTF8", "1") # for child subprocesses
|
||||
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 err_console
|
||||
|
||||
|
||||
@@ -19,6 +19,13 @@ import sys
|
||||
from pathlib import Path
|
||||
from typing import List
|
||||
|
||||
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.table import Table
|
||||
from rich.text import Text
|
||||
|
||||
|
||||
@@ -30,6 +30,7 @@ import json
|
||||
import os
|
||||
import secrets
|
||||
import socket
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
@@ -46,7 +47,7 @@ _SECRET_NAME = "broker_secret"
|
||||
|
||||
_DENYLIST_DIRS = frozenset((".git", ".trinity", ".aipass", ".codex", ".agents"))
|
||||
|
||||
_TMP_BASES = (Path("/tmp"), Path("/var/tmp"))
|
||||
_TMP_BASES = (Path(tempfile.gettempdir()),)
|
||||
|
||||
|
||||
def _find_project_root() -> Path | None:
|
||||
@@ -67,7 +68,7 @@ def _default_socket_path() -> Path:
|
||||
"""Return the default broker socket path under the repo root."""
|
||||
root = _find_project_root()
|
||||
if root is None:
|
||||
return Path("/tmp") / _SOCKET_NAME
|
||||
return Path(tempfile.gettempdir()) / _SOCKET_NAME
|
||||
return root / _DEFAULT_SOCKET_DIR / _SOCKET_NAME
|
||||
|
||||
|
||||
@@ -75,7 +76,7 @@ def _default_audit_path() -> Path:
|
||||
"""Return the default audit log path."""
|
||||
root = _find_project_root()
|
||||
if root is None:
|
||||
return Path("/tmp") / _AUDIT_LOG_NAME
|
||||
return Path(tempfile.gettempdir()) / _AUDIT_LOG_NAME
|
||||
return root / _DEFAULT_SOCKET_DIR / _AUDIT_LOG_NAME
|
||||
|
||||
|
||||
@@ -83,7 +84,7 @@ def _default_secret_path() -> Path:
|
||||
"""Return the default secret path."""
|
||||
root = _find_project_root()
|
||||
if root is None:
|
||||
return Path("/tmp") / _SECRET_NAME
|
||||
return Path(tempfile.gettempdir()) / _SECRET_NAME
|
||||
return root / _DEFAULT_SOCKET_DIR / _SECRET_NAME
|
||||
|
||||
|
||||
|
||||
@@ -17,11 +17,19 @@ from __future__ import annotations
|
||||
import inspect
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
from datetime import datetime
|
||||
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")
|
||||
|
||||
from aipass.prax import logger
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -21,6 +21,7 @@ from __future__ import annotations
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
@@ -47,8 +48,8 @@ def _find_project_root() -> Path | None:
|
||||
def get_allowed_roots() -> list[Path]:
|
||||
"""Return resolved roots under which deletion is permitted.
|
||||
|
||||
Union of: project root, ``/tmp``, and ``tempfile.gettempdir()`` (which
|
||||
honors ``$TMPDIR``). Deduplicated by resolved path.
|
||||
Union of: project root, system temp dir, and (on POSIX) the canonical
|
||||
temp path. Deduplicated by resolved path.
|
||||
"""
|
||||
seen: set[Path] = set()
|
||||
roots: list[Path] = []
|
||||
@@ -58,7 +59,12 @@ def get_allowed_roots() -> list[Path]:
|
||||
seen.add(project_root)
|
||||
roots.append(project_root)
|
||||
|
||||
for tmp_candidate in (Path("/tmp"), Path(tempfile.gettempdir())):
|
||||
tmp_candidates: list[Path] = []
|
||||
if sys.platform != "win32":
|
||||
tmp_candidates.append(Path("/tmp"))
|
||||
tmp_candidates.append(Path(tempfile.gettempdir()))
|
||||
|
||||
for tmp_candidate in tmp_candidates:
|
||||
resolved = tmp_candidate.resolve()
|
||||
if resolved not in seen:
|
||||
seen.add(resolved)
|
||||
@@ -129,8 +135,8 @@ def check_containment(path: Path, roots: list[Path]) -> tuple[bool, str]:
|
||||
"""Check if *path* (already resolved) is a strict child of any allowed root.
|
||||
|
||||
Returns ``(allowed, reason)``. Refuses the root directories themselves.
|
||||
When multiple roots are nested (e.g. /tmp and /tmp/claude-1000), the path
|
||||
must not equal ANY root — checked upfront before containment.
|
||||
When multiple roots are nested (e.g. temp dir and a subdirectory of it),
|
||||
the path must not equal ANY root — checked upfront before containment.
|
||||
"""
|
||||
root_set = frozenset(roots)
|
||||
if path in root_set:
|
||||
|
||||
@@ -14,6 +14,8 @@ checks and deletion. Provider-agnostic alternative to shell ``rm``.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import tempfile
|
||||
|
||||
from aipass.prax import logger
|
||||
from aipass.cli.apps.modules import console
|
||||
from aipass.drone.apps.handlers.json import json_handler
|
||||
@@ -107,7 +109,8 @@ def print_help() -> None:
|
||||
console.print(" • Symlinks are resolved; refuses if target escapes allowed roots")
|
||||
console.print(" • Nonexistent paths produce a clean error")
|
||||
console.print()
|
||||
_tmp = tempfile.gettempdir()
|
||||
console.print("[bold]Examples:[/bold]")
|
||||
console.print(" [green]drone rm /tmp/scratch_dir[/green]")
|
||||
console.print(f" [green]drone rm {_tmp}/scratch_dir[/green]")
|
||||
console.print(" [green]drone rm build/ dist/[/green]")
|
||||
console.print(" [green]drone rm /tmp/aipass_test_abc123[/green]")
|
||||
console.print(f" [green]drone rm {_tmp}/aipass_test_abc123[/green]")
|
||||
|
||||
@@ -15,8 +15,18 @@ Auto-discovery architecture:
|
||||
- No manual imports or routing needed
|
||||
"""
|
||||
|
||||
# ruff: noqa: E402
|
||||
# INFRASTRUCTURE IMPORT PATTERN
|
||||
import sys
|
||||
import os
|
||||
|
||||
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 pathlib import Path
|
||||
|
||||
_PKG_ROOT = Path(__file__).resolve().parents[2] # flow.py → apps/ → flow/ → aipass/
|
||||
|
||||
@@ -13,8 +13,18 @@ Handles default JSON files (config, data, log) for flow modules.
|
||||
Never manually create JSONs - they build themselves.
|
||||
"""
|
||||
|
||||
# ruff: noqa: E402
|
||||
import json
|
||||
import sys
|
||||
import os
|
||||
|
||||
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")
|
||||
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from datetime import datetime
|
||||
|
||||
@@ -37,22 +37,22 @@ def calculate_relative_location(target_dir: Path, ecosystem_root: Path) -> str:
|
||||
|
||||
Examples:
|
||||
>>> calculate_relative_location(
|
||||
... Path("/repo/src/aipass/flow"),
|
||||
... Path("/repo/src/aipass")
|
||||
... Path("repo/src/aipass/flow"),
|
||||
... Path("repo/src/aipass")
|
||||
... )
|
||||
"flow"
|
||||
|
||||
>>> calculate_relative_location(
|
||||
... Path("/repo/src/aipass"),
|
||||
... Path("/repo/src/aipass")
|
||||
... Path("repo/src/aipass"),
|
||||
... Path("repo/src/aipass")
|
||||
... )
|
||||
"root"
|
||||
|
||||
>>> calculate_relative_location(
|
||||
... Path("/tmp/somewhere"),
|
||||
... Path("/repo/src/aipass")
|
||||
... Path("other/somewhere"),
|
||||
... Path("repo/src/aipass")
|
||||
... )
|
||||
"/tmp/somewhere"
|
||||
"other/somewhere"
|
||||
"""
|
||||
try:
|
||||
relative_location = str(target_dir.relative_to(ecosystem_root))
|
||||
@@ -67,6 +67,7 @@ def calculate_relative_location(target_dir: Path, ecosystem_root: Path) -> str:
|
||||
except ValueError:
|
||||
# target_dir is outside ecosystem_root
|
||||
logger.warning(
|
||||
f"[calculate_relative_path] Target '{target_dir}' is outside ecosystem root '{ecosystem_root}', using absolute path"
|
||||
f"[calculate_relative_path] Target '{target_dir}' is outside ecosystem root"
|
||||
f" '{ecosystem_root}', using absolute path"
|
||||
)
|
||||
return str(target_dir)
|
||||
|
||||
@@ -256,6 +256,18 @@ def _self_heal_unregistered_plan(
|
||||
def _spawn_background_runner():
|
||||
"""Spawn post_close_runner.py as a fully detached background process"""
|
||||
bg_runner = FLOW_ROOT / "apps" / "modules" / "post_close_runner.py"
|
||||
subprocess.Popen(
|
||||
[sys.executable, str(bg_runner)], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, start_new_session=True
|
||||
)
|
||||
cmd = [sys.executable, str(bg_runner)]
|
||||
if sys.platform == "win32":
|
||||
subprocess.Popen(
|
||||
cmd,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
creationflags=subprocess.CREATE_NEW_PROCESS_GROUP,
|
||||
)
|
||||
else:
|
||||
subprocess.Popen(
|
||||
cmd,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
start_new_session=True,
|
||||
)
|
||||
|
||||
@@ -27,7 +27,7 @@ def create_plan_file(plan_file: Path, content: str) -> Tuple[bool, str]:
|
||||
content to the file using UTF-8 encoding.
|
||||
|
||||
Args:
|
||||
plan_file: Full path to plan file (e.g., /path/to/PLAN0001.md)
|
||||
plan_file: Full path to plan file (e.g., Path("plans/PLAN0001.md"))
|
||||
content: Formatted template content to write
|
||||
|
||||
Returns:
|
||||
@@ -40,7 +40,7 @@ def create_plan_file(plan_file: Path, content: str) -> Tuple[bool, str]:
|
||||
|
||||
Example:
|
||||
>>> success, error = create_plan_file(
|
||||
... Path("/tmp/PLAN0001.md"),
|
||||
... Path("plans/PLAN0001.md"),
|
||||
... "# PLAN 0001\\n\\nContent here"
|
||||
... )
|
||||
>>> if not success:
|
||||
|
||||
@@ -29,7 +29,17 @@ Standalone:
|
||||
drone @flow aggregate --heal
|
||||
"""
|
||||
|
||||
# ruff: noqa: E402
|
||||
import sys
|
||||
import os
|
||||
|
||||
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 pathlib import Path
|
||||
from typing import List
|
||||
|
||||
|
||||
@@ -19,7 +19,17 @@ Usage:
|
||||
Standalone: drone @flow close <number>
|
||||
"""
|
||||
|
||||
# ruff: noqa: E402
|
||||
import sys
|
||||
import os
|
||||
|
||||
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 pathlib import Path
|
||||
from typing import List, Dict, Any
|
||||
|
||||
|
||||
@@ -30,6 +30,15 @@ Usage:
|
||||
"""
|
||||
|
||||
import sys
|
||||
import os
|
||||
|
||||
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 pathlib import Path
|
||||
from typing import Any, Dict, Tuple, List
|
||||
|
||||
|
||||
@@ -28,7 +28,17 @@ Filters:
|
||||
list all - List all plans
|
||||
"""
|
||||
|
||||
# ruff: noqa: E402
|
||||
import sys
|
||||
import os
|
||||
|
||||
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 pathlib import Path
|
||||
from typing import List
|
||||
|
||||
|
||||
@@ -19,7 +19,17 @@ all unprocessed plans since it scans ALL of them).
|
||||
This script lives inside the flow branch so handler import guards allow it.
|
||||
"""
|
||||
|
||||
# ruff: noqa: E402
|
||||
import sys
|
||||
import os
|
||||
|
||||
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 pathlib import Path
|
||||
|
||||
from aipass.cli.apps.modules import console, error, warning
|
||||
|
||||
@@ -27,7 +27,17 @@ Commands:
|
||||
status - Show registry status
|
||||
"""
|
||||
|
||||
# ruff: noqa: E402
|
||||
import sys
|
||||
import os
|
||||
|
||||
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 pathlib import Path
|
||||
from typing import Dict, Any, List
|
||||
|
||||
@@ -156,7 +166,8 @@ def handle_command(command: str, args: List[str]) -> bool:
|
||||
if result["healing_performed"]:
|
||||
change_count = len(result["added"]) + len(result["updated"]) + len(result["removed"])
|
||||
warning(
|
||||
f"Registry scan found {change_count} mismatch(es) — trigger event handlers not wired, no changes applied"
|
||||
f"Registry scan found {change_count} mismatch(es) — "
|
||||
"trigger event handlers not wired, no changes applied"
|
||||
)
|
||||
else:
|
||||
console.print("\n[dim]No changes needed - registry is healthy[/dim]")
|
||||
|
||||
@@ -18,7 +18,17 @@ Usage:
|
||||
Standalone: drone @flow restore <number>
|
||||
"""
|
||||
|
||||
# ruff: noqa: E402
|
||||
import sys
|
||||
import os
|
||||
|
||||
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 pathlib import Path
|
||||
from typing import List, Dict, Any
|
||||
|
||||
|
||||
@@ -23,6 +23,15 @@ Usage:
|
||||
"""
|
||||
|
||||
import sys
|
||||
import os
|
||||
|
||||
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 pathlib import Path
|
||||
from typing import List
|
||||
|
||||
|
||||
@@ -366,6 +366,16 @@
|
||||
"standard": "unused_function",
|
||||
"reason": "play() called by handler files (stop_sound.py, announce.py) that are dynamically dispatched via importlib \u2014 static analysis cannot trace the call chain from hooks.json \u2192 engine \u2192 handler \u2192 sound.play()."
|
||||
},
|
||||
{
|
||||
"file": "apps/sound.py",
|
||||
"standard": "open_encoding",
|
||||
"reason": "NamedTemporaryFile creates binary WAV for Piper TTS \u2014 encoding not applicable to binary audio."
|
||||
},
|
||||
{
|
||||
"file": "apps/sound.py",
|
||||
"standard": "cli_flags",
|
||||
"reason": "Shared utility module imported by handlers \u2014 not a CLI entry point. Has print_introspection() for drone discovery but no handle_command() or user-facing CLI."
|
||||
},
|
||||
{
|
||||
"standard": "json_handler",
|
||||
"reason": "Hooks branch has no json_handler.py — hook engine uses its own JSONL diagnostic logging and stdlib json for hook protocol I/O. Does not follow the module JSON pattern by design."
|
||||
|
||||
@@ -12,11 +12,12 @@
|
||||
|
||||
import importlib
|
||||
import os
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
from aipass.prax.apps.modules.logger import system_logger as logger
|
||||
|
||||
_GUARD_DIR = Path("/tmp")
|
||||
_GUARD_DIR = Path(tempfile.gettempdir())
|
||||
|
||||
|
||||
def _session_guard_path() -> Path | None:
|
||||
|
||||
@@ -25,6 +25,13 @@ from typing import Any
|
||||
|
||||
os.environ.setdefault("AIPASS_BRANCH_NAME", "hooks")
|
||||
|
||||
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.apps.modules.logger import system_logger as logger # noqa: E402
|
||||
from aipass.cli.apps.modules import err_console # noqa: E402
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ advances exactly once per real user turn.
|
||||
|
||||
import json
|
||||
import os
|
||||
import tempfile
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
@@ -31,7 +32,7 @@ except ImportError:
|
||||
|
||||
CONSOLE = err_console
|
||||
|
||||
_GUARD_DIR = Path("/tmp")
|
||||
_GUARD_DIR = Path(tempfile.gettempdir())
|
||||
_BRANCH_ROOT = Path(__file__).resolve().parent.parent.parent
|
||||
_CONFIG_PATH = _BRANCH_ROOT / "hooks_json" / "custom_config" / "cadence_config.json"
|
||||
_DEBOUNCE_S = 2.0
|
||||
|
||||
@@ -125,9 +125,10 @@ def build_policy(branch_path: str | Path) -> dict:
|
||||
|
||||
allow_write.append(str(branch_path))
|
||||
|
||||
allow_write.append("/tmp")
|
||||
system_tmp = tempfile.gettempdir()
|
||||
allow_write.append(system_tmp)
|
||||
tmpdir = os.environ.get("TMPDIR")
|
||||
if tmpdir and tmpdir != "/tmp":
|
||||
if tmpdir and tmpdir != system_tmp:
|
||||
allow_write.append(tmpdir)
|
||||
|
||||
allow_write.extend(
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
"""Shared sound functions for hook handlers. Checks mute flag before playing."""
|
||||
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
@@ -18,10 +19,15 @@ from aipass.prax.apps.modules.logger import system_logger as logger
|
||||
from aipass.cli.apps.modules import err_console
|
||||
|
||||
CONSOLE = err_console
|
||||
MUTE_FLAG = Path("/tmp/aipass-hooks-muted")
|
||||
MUTE_FLAG = Path(tempfile.gettempdir()) / "aipass-hooks-muted"
|
||||
PIPER_BIN = Path.home() / ".local" / "share" / "piper" / "piper"
|
||||
PIPER_VOICE = Path.home() / ".local" / "share" / "piper-voices" / "en_US-amy-medium.onnx"
|
||||
|
||||
if sys.platform == "darwin":
|
||||
_PLAY_CMD: list[str] = ["afplay"]
|
||||
else:
|
||||
_PLAY_CMD = ["aplay", "-q"]
|
||||
|
||||
|
||||
def print_introspection():
|
||||
"""Print module structure for drone routing."""
|
||||
@@ -56,7 +62,7 @@ def speak(text: str) -> None:
|
||||
|
||||
if piper_result.returncode == 0 and Path(wav_path).exists():
|
||||
subprocess.Popen(
|
||||
["aplay", "-q", wav_path],
|
||||
_PLAY_CMD + [wav_path],
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
)
|
||||
@@ -67,7 +73,7 @@ def speak(text: str) -> None:
|
||||
|
||||
|
||||
def play(sound_path: Path) -> None:
|
||||
"""Play a WAV file via aplay. Skips if muted."""
|
||||
"""Play a WAV file. Skips if muted."""
|
||||
if is_muted():
|
||||
return
|
||||
|
||||
@@ -77,7 +83,7 @@ def play(sound_path: Path) -> None:
|
||||
|
||||
try:
|
||||
subprocess.Popen(
|
||||
["aplay", "-q", str(sound_path)],
|
||||
_PLAY_CMD + [str(sound_path)],
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
)
|
||||
|
||||
@@ -17,6 +17,7 @@ ARCHITECTURE:
|
||||
- Handlers implement domain-specific business logic
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import signal
|
||||
@@ -25,6 +26,13 @@ from pathlib import Path
|
||||
from typing import List, Any
|
||||
import importlib
|
||||
|
||||
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.panel import Panel
|
||||
from rich import box
|
||||
from rich.table import Table
|
||||
|
||||
@@ -21,8 +21,17 @@ Usage:
|
||||
drone @memory lint @devpulse # Scan one branch
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
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")
|
||||
|
||||
from aipass.prax import logger
|
||||
from aipass.cli.apps.modules import console, error, warning
|
||||
from aipass.memory.apps.handlers.json import json_handler
|
||||
|
||||
@@ -12,8 +12,17 @@ Pool Module — drone CLI routing for memory pool commands.
|
||||
Thin delegation layer. All implementation lives in handlers/intake/auto_process.py.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
from typing import List, 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")
|
||||
|
||||
from rich.panel import Panel
|
||||
from rich import box
|
||||
|
||||
|
||||
@@ -20,10 +20,18 @@ Purpose:
|
||||
All domain logic lives in handlers.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import List
|
||||
|
||||
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.panel import Panel
|
||||
from rich import box
|
||||
|
||||
|
||||
@@ -19,10 +19,18 @@ Purpose:
|
||||
All domain logic lives in handlers.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import List
|
||||
|
||||
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.panel import Panel
|
||||
from rich import box
|
||||
|
||||
@@ -210,7 +218,8 @@ def show_search_results(
|
||||
if not filtered_results and total_results == 0:
|
||||
warning(
|
||||
"No matching memories found",
|
||||
details="Try different search terms, broader query without filters, or check if memories have been rolled over (drone @memory status)",
|
||||
details="Try different search terms, broader query without filters, "
|
||||
"or check if memories have been rolled over (drone @memory status)",
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
@@ -17,11 +17,19 @@ Part of the Fragmented Memory implementation.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import List, Dict, 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")
|
||||
|
||||
# Service imports
|
||||
from aipass.prax import logger
|
||||
from aipass.cli.apps.modules import console, error, header, warning
|
||||
|
||||
@@ -19,10 +19,18 @@ Purpose:
|
||||
All domain logic lives in handlers (pusher.py, differ.py).
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import List
|
||||
|
||||
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.panel import Panel
|
||||
from rich import box
|
||||
|
||||
|
||||
@@ -23,6 +23,13 @@ import sys
|
||||
from pathlib import Path
|
||||
from typing import List
|
||||
|
||||
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, error
|
||||
from aipass.memory.apps.handlers.json import json_handler
|
||||
|
||||
@@ -21,12 +21,21 @@ Features:
|
||||
Based on: apps/handlers/discovery/watcher.py (production-ready log tailing)
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Optional, Dict, Any
|
||||
import re
|
||||
|
||||
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.apps.modules.logger import get_direct_logger
|
||||
from watchdog.observers import Observer as WatchdogObserver # type: ignore
|
||||
from watchdog.events import FileSystemEventHandler # type: ignore
|
||||
|
||||
@@ -33,10 +33,18 @@ __all__ = [
|
||||
]
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Dict, List
|
||||
|
||||
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.apps.modules.logger import system_logger as logger
|
||||
from aipass.cli.apps.modules import console, error, warning
|
||||
|
||||
|
||||
@@ -14,9 +14,17 @@ Scans the system_logs/ directory for oversized files, reports status,
|
||||
and optionally enforces size limits by truncating bloated logs.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
from typing import List
|
||||
|
||||
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.apps.modules.logger import system_logger as logger
|
||||
from aipass.cli.apps.modules import console, error
|
||||
from aipass.prax.apps.handlers.json import json_handler
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user