diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9319d26..71c7253 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -49,3 +49,37 @@ jobs: python-version: "3.12" - run: pip install ruff - run: ruff check src/ tests/ + + desktop: + # Exercises the real PySide6 stack (the main `test` job runs the stubbed + # smokes only) on both Linux (offscreen) and Windows. + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest] + env: + QT_QPA_PLATFORM: offscreen + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Install Qt offscreen runtime deps (Linux) + if: runner.os == 'Linux' + run: | + sudo apt-get update + sudo apt-get install -y \ + libegl1 libgl1 libxkbcommon0 libdbus-1-3 \ + libxcb-cursor0 libxcb-icccm4 libxcb-keysyms1 \ + libxcb-shape0 libxcb-xinerama0 + + - name: Install package with desktop extras + run: | + python -m pip install --upgrade pip + python -m pip install -e ".[dev,desktop]" + + - name: Desktop import + construct + launch smoke (real Qt) + run: python -m pytest tests/test_desktop_smoke.py -v --tb=short diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3d94f42..da13fa6 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -112,9 +112,88 @@ jobs: name: ${{ matrix.artifact }} path: dist/${{ matrix.artifact }}${{ matrix.ext }} + # 4b. Build the PySide6 desktop app (onedir) + Windows installer + desktop: + needs: test + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + include: + - os: windows-latest + artifact: parallel-agents-desktop-windows-x86_64 + - os: ubuntu-latest + artifact: parallel-agents-desktop-linux-x86_64 + - os: macos-latest + artifact: parallel-agents-desktop-macos-arm64 + env: + QT_QPA_PLATFORM: offscreen + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Install Qt offscreen runtime deps (Linux) + if: runner.os == 'Linux' + run: | + sudo apt-get update + sudo apt-get install -y \ + libegl1 libgl1 libxkbcommon0 libdbus-1-3 \ + libxcb-cursor0 libxcb-icccm4 libxcb-keysyms1 \ + libxcb-shape0 libxcb-xinerama0 + + - name: Install package with desktop extras + run: | + python -m pip install --upgrade pip + pip install -e ".[desktop]" + pip install pyinstaller + + - name: Build desktop onedir bundle + run: pyinstaller --noconfirm parallel-agents-desktop.spec + + - name: Smoke-execute the built binary (Windows) + if: runner.os == 'Windows' + run: dist\parallel-agents-desktop\parallel-agents-desktop.exe --smoke + + - name: Smoke-execute the built binary (Linux) + if: runner.os == 'Linux' + run: ./dist/parallel-agents-desktop/parallel-agents-desktop --smoke + + - name: Smoke-execute the built binary (macOS) + if: runner.os == 'macOS' + run: ./dist/parallel-agents-desktop/parallel-agents-desktop --smoke + + - name: Build Windows installer (Inno Setup) + if: runner.os == 'Windows' + run: | + choco install innosetup --no-progress -y + # Derive a numeric version from a vX.Y.Z tag; fall back for dispatch runs. + $ref = "${{ github.ref_name }}" + $version = $ref -replace '^v', '' + if ($version -notmatch '^\d+\.\d+\.\d+') { $version = '0.0.0' } + & "${env:ProgramFiles(x86)}\Inno Setup 6\ISCC.exe" ` + "/DMyAppVersion=$version" installer\parallel-agents-desktop.iss + shell: pwsh + + - name: Package onedir bundle (non-Windows) + if: runner.os != 'Windows' + run: | + cd dist + tar -czf "${{ matrix.artifact }}.tar.gz" parallel-agents-desktop + + - name: Upload desktop bundle + uses: actions/upload-artifact@v4 + with: + name: ${{ matrix.artifact }} + path: | + dist/${{ matrix.artifact }}.tar.gz + installer/Output/*.exe + if-no-files-found: ignore + # 5. Create GitHub Release with binaries release: - needs: [binaries, pypi, npm] + needs: [binaries, desktop, pypi, npm] runs-on: ubuntu-latest permissions: contents: write @@ -134,3 +213,6 @@ jobs: artifacts/parallel-agents-linux-x86_64/parallel-agents-linux-x86_64 artifacts/parallel-agents-macos-arm64/parallel-agents-macos-arm64 artifacts/parallel-agents-windows-x86_64/parallel-agents-windows-x86_64.exe + artifacts/parallel-agents-desktop-windows-x86_64/**/*.exe + artifacts/parallel-agents-desktop-linux-x86_64/*.tar.gz + artifacts/parallel-agents-desktop-macos-arm64/*.tar.gz diff --git a/.gitignore b/.gitignore index 9075914..986552d 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,9 @@ build/ .venv/ venv/ env/ +.uv-python/ +.uv-cache/ +uv.lock # IDE .vscode/ @@ -38,6 +41,11 @@ npm-wrapper/node_modules/ # pytest .pytest_cache/ +.coverage +htmlcov/ # local assistant workspace .claude/ + +# local connector/gateway scratch +tmp-slack-test/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 24bf7a6..0c46e6d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,10 @@ The format is based on Keep a Changelog and this project follows Semantic Versio - Added expanded benchmark fixture: `examples/public_benchmark_v2.json`. - Added `parallel-agents eval publish` for shareable public benchmark JSON/Markdown snapshots. - Desktop office improvements: + - first-run onboarding action shared with `parallel-agents office onboard` + - project-scoped local gateway start/stop/status controls + - runs page can route real pipeline execution through the local gateway control plane with fallback + - worker tiles now prefer structured pipeline trace events over status-string inference - project-home summary with recent project picker - runs page now executes real pipeline runs - live run activity stream and worker status updates during execution @@ -39,8 +43,18 @@ The format is based on Keep a Changelog and this project follows Semantic Versio - approvals queue bulk approve/reject actions (selected and visible rows) - approvals artifact preview with previous-run diff - approvals audit-event drilldown for run/approval history - - GitHub PR creation flow from desktop with run-linked PR summary artifact + - GitHub PR creation flow from desktop with run-linked PR summary artifact, branch suggestions, and generated-patch approval gate - desktop company flow `gh auth status` check for preflight GitHub readiness +- Gateway now supports core planner/worker/judge execution through `POST /runs/pipeline`, including persistent status/trace events and a `final-output` artifact. +- Gateway now exposes a local channel-adapter pairing boundary: + - `POST /channels/slack/events` with Slack signature verification and URL verification + - `POST /channels/inbound` + - `POST /channels/pairing/approve` + - `GET /channels/peers` + - `parallel-agents gateway channel inbound/approve/peers` + - unknown senders receive pairing codes and are not processed until approved +- `.gitignore` now excludes local `uv` caches/lockfiles, coverage output, and gateway connector scratch directories. +- Added `parallel-agents office onboard` for local workspace setup, model-readiness reporting, GitHub-readiness reporting, and suggested next actions. - Workspace knowledge layer v1: - `parallel-agents office memory add/list/search/policies` - project workspace memory store under `.parallel-agents/memory/` diff --git a/PROJECT_STATUS.md b/PROJECT_STATUS.md index 1240481..83cedf4 100644 --- a/PROJECT_STATUS.md +++ b/PROJECT_STATUS.md @@ -49,9 +49,13 @@ The current release line is focused on local, reviewable workflows and a project - workspace memory endpoints (`/memory/entries`, `/memory/search`, `/memory/policies`) - in-process queued execution with persistent run/job states - run listing plus per-run job inspection + - core pipeline run endpoint (`POST /runs/pipeline`) with persistent events and `final-output` artifacts - cancel and retry controls - optional API-key protection for non-local exposure - optional JWT HS256 auth with issuer/audience validation + - channel adapter endpoints with default pairing/allowlist behavior for unknown inbound senders + - signed Slack Events endpoint with URL verification, bot-message ignore rules, and local pairing enforcement + - CLI commands for channel inbound simulation, pairing approval, and approved peer listing - MCP product-surface foundation: - `tool_discovery` output with read/write access classes - approval-required metadata for write tools @@ -64,6 +68,7 @@ The current release line is focused on local, reviewable workflows and a project - Local desktop/project office foundation: - `.parallel-agents/` workspace inside a project folder - `office init`, `office status`, and `office home` commands + - `office onboard` command for first-run setup, model readiness, GitHub readiness, and next actions - `office doctor` diagnostics command for local readiness checks - `office fix-setup` CLI fallback for safe local setup remediation - `office artifacts` for run-linked artifact inspection @@ -71,9 +76,11 @@ The current release line is focused on local, reviewable workflows and a project - workspace memory files for decisions, lessons, and policies - `office memory add/list/search/policies` for local knowledge capture - desktop project-home summary with recent project picker + - desktop onboarding action for setup/model/GitHub readiness - desktop project actions for `Run Doctor` and `Fix Setup` - - desktop Runs page wired to execute real pipeline runs - - live run activity stream and per-worker status updates in desktop UI + - desktop project-scoped gateway start/stop/status controls + - desktop Runs page wired to execute real pipeline runs, using the local gateway when available + - live run activity stream and event-native per-worker status updates in desktop UI - artifact compare view against previous runs (inline unified diff) - artifact browser search/filter/sort controls by run and artifact type - artifact quick actions (open file, reveal folder, export copy) @@ -86,8 +93,8 @@ The current release line is focused on local, reviewable workflows and a project - comparison drill-down sections for workflow/project/case-level change drivers - case-row evidence navigation links (score/gate/breakdown/results and run artifacts) - desktop approvals queue filters and one-click approved issue-plan apply - - desktop GitHub PR creation flow with run-linked PR summary artifact - - PyInstaller spec support for the project-office module + - desktop GitHub PR creation flow with run-linked PR summary artifact, branch suggestions, and generated-patch approval gate + - PyInstaller spec support for the project-office, onboarding, and gateway modules ## Experimental @@ -109,6 +116,7 @@ parallel-agents company apply --run-id run-123 ``` ```bash +parallel-agents office onboard --project . --name "Project Name" parallel-agents office init --project . --name "Project Name" parallel-agents office status --project . parallel-agents office doctor --project . @@ -120,6 +128,11 @@ parallel-agents office artifacts --project . ```bash parallel-agents gateway start --host 127.0.0.1 --port 8733 +# enqueue a real planner/worker/judge run through the local job API +curl -X POST http://127.0.0.1:8733/runs/pipeline ^ + -H "Content-Type: application/json" ^ + -d "{\"run_id\":\"run-123\",\"task\":\"Review this repository\",\"repo_path\":\".\"}" + # optional auth for non-local use (API key) set PA_GATEWAY_API_KEY=my-secret parallel-agents gateway start --host 0.0.0.0 --port 8733 @@ -134,6 +147,8 @@ parallel-agents gateway start --host 0.0.0.0 --port 8733 ## Known Limitations - Desktop GUI is available but still maturing (single-user local workflow focus). +- Desktop gateway lifecycle is session-owned by the UI; OS daemon/tray install and auto-restart are not implemented yet. +- Slack is the only real external channel connector; Telegram and Discord remain planned connectors on top of the pairing adapter. - No hosted MCP endpoint or OAuth yet. - No distributed/remote worker execution beyond the current local in-process queue. - No hosted-grade OAuth/session model yet (local API key/JWT + gateway policy toggles are available). @@ -149,3 +164,4 @@ Expand the local desktop office into a full `.exe` product surface: - GitHub connect + repository integration - PR creation and review actions from the local office - richer release and productivity views +- OS daemon/tray gateway lifecycle plus Telegram and Discord channel connectors diff --git a/README.md b/README.md index 8deb9fc..2d03a60 100644 --- a/README.md +++ b/README.md @@ -121,6 +121,7 @@ parallel-agents company apply --run-id run-123 parallel-agents company apply --run-id run-123 --policy-file company/apply-policy.json # Initialize a project-folder office workspace +parallel-agents office onboard --project ./my-project --name "My Project" parallel-agents office init --project ./my-project --name "My Project" parallel-agents office status --project ./my-project parallel-agents office doctor --project ./my-project @@ -156,6 +157,15 @@ parallel-agents gateway start --jwt-secret replace-with-shared-secret --jwt-issu # Optional remote MCP write enablement over gateway /mcp endpoints set PA_GATEWAY_ALLOW_REMOTE_WRITE_TOOLS=1 parallel-agents gateway start --allow-remote-write-tools + +# Optional Slack Events connector +set PA_GATEWAY_SLACK_SIGNING_SECRET=xox-signing-secret +parallel-agents gateway start --slack-signing-secret xox-signing-secret + +# Channel pairing adapter +parallel-agents gateway channel inbound --channel slack --peer-id U123 --message "Review this repo" --execute +parallel-agents gateway channel approve --code ABC123 --approved-by operator +parallel-agents gateway channel peers --channel slack ``` ## CLI Commands @@ -172,6 +182,7 @@ parallel-agents gateway start --allow-remote-write-tools | `parallel-agents eval run/annotate/sync-pr/sync-ci/score/compare/breakdown/publish/gate` | Run, annotate, auto-sync PR acceptance and CI regressions, score, compare, break down cost/time, publish benchmark snapshots, and quality-gate productivity/effectiveness benchmarks | | `parallel-agents release verify` | Run consolidated release checklist checks (lint/tests/build/help/mcp/npm/version parity) | | `parallel-agents gateway start` | Start the local project/run/job API | +| `parallel-agents gateway channel inbound/approve/peers` | Exercise local inbound-channel pairing and peer allowlist workflows | ## Local Project Office @@ -192,6 +203,7 @@ my-project/ Initialize it with: ```bash +parallel-agents office onboard --project ./my-project --name "My Project" parallel-agents office init --project ./my-project --name "My Project" parallel-agents office status --project ./my-project parallel-agents office doctor --project ./my-project --strict @@ -203,7 +215,10 @@ parallel-agents office artifacts --project ./my-project Desktop Office (`parallel-agents-desktop`) now includes: - project home summary with recent project picker - workspace doctor status signal (healthy/attention + warning/failure counts) -- live pipeline run execution with activity streaming and worker status tiles +- first-run onboarding action for workspace setup, model readiness, and GitHub readiness +- desktop-owned local gateway controls for start/stop/status +- live pipeline run execution through the local gateway when available, with in-process fallback +- event-native worker status updates from structured pipeline traces, with text-status fallback - artifact compare against previous runs (inline unified diff) - artifact browser search/filter/sort controls by run and artifact type - artifact quick actions (open file, reveal folder, export copy) @@ -217,10 +232,11 @@ Desktop Office (`parallel-agents-desktop`) now includes: - case-row evidence links to underlying score/gate/breakdown/results/run artifacts - approvals queue with status filters, bulk approve/reject, artifact diff preview, and audit-event drilldown - company workflow steps through issue-plan apply -- GitHub PR creation from run context with generated PR summary markdown +- GitHub PR creation from run context with suggested branch names and generated PR summary markdown +- generated patches require approval of the exact `final-output` artifact before PR creation - built-in `gh auth status` check from the Company flow before live GitHub writes -The gateway remains an internal job API for local automation and future desktop shells. It is not the primary product UI. +The gateway remains an internal job API for local automation and desktop shells. It is not the primary product UI. The desktop can start/stop a project-scoped gateway for the current session. Set `PA_DESKTOP_GATEWAY_URL` or leave `PA_DESKTOP_USE_GATEWAY=auto` to let the desktop use `http://127.0.0.1:8733` when it is running; set `PA_DESKTOP_GATEWAY_REQUIRED=1` to fail instead of falling back to in-process runs. ## Gateway API @@ -236,6 +252,11 @@ The local gateway exposes a persistent API for projects, runs, jobs, artifacts, - `GET /memory/search` - `GET /memory/policies` - `PUT /memory/policies` +- `POST /runs/pipeline` +- `POST /channels/slack/events` +- `POST /channels/inbound` +- `POST /channels/pairing/approve` +- `GET /channels/peers` - `POST /runs/company/idea` - `POST /runs/company/roadmap` - `POST /runs/company/plan` diff --git a/ROADMAP.md b/ROADMAP.md index 7fbc0cc..966ae14 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -87,10 +87,11 @@ Completed: - Incremental run event stream for enqueue/start/status/complete/cancel. - Optional API-key protection for non-local gateway exposure. - Optional JWT HS256 auth mode with issuer/audience validation for non-local gateway exposure. +- Gateway-managed core pipeline runs via `POST /runs/pipeline`, with persistent run/job/event state and `final-output` artifacts. Remaining: -- Desktop/exe shell integration. +- Deeper desktop/exe shell integration on top of the gateway control plane. - Hosted-grade auth for multi-tenant remote deployments (OAuth/session model). ## Phase 4: Local Desktop Office @@ -103,12 +104,13 @@ Completed: - Project-folder workspace layout under `.parallel-agents/`. - `parallel-agents office init` to create local workspace metadata and directories. +- `parallel-agents office onboard` to create/repair workspace setup and report model/GitHub readiness for first use. - `parallel-agents office status` to inspect workspace health. - `parallel-agents office doctor` to run local readiness diagnostics and strict checks. - `parallel-agents office fix-setup` for safe CLI remediation fallback. - Desktop project-home summary and recent project picker. - Desktop project-home card now surfaces doctor health status and warning/failure counts. -- Desktop Projects page now provides one-click `Run Doctor` and `Fix Setup` actions. +- Desktop Projects page now provides one-click `Onboard`, `Run Doctor`, and `Fix Setup` actions. - Desktop approvals queue filters with approved issue-plan apply action. - Desktop GitHub PR creation with run-linked PR summary artifact. - Workspace knowledge layer v1: @@ -124,11 +126,22 @@ Completed: - Desktop cross-run benchmark comparison panel (baseline vs candidate) with delta report export. - Desktop drill-down analytics in comparison view (workflow/project/case-level change drivers). - Workflow navigation into detailed case evidence and artifact links from comparison rows. +- Desktop run execution can route through the local gateway control plane when available, with in-process fallback. +- Desktop can start, stop, and inspect a project-scoped local gateway process. +- Desktop worker tiles now consume structured pipeline trace events, with legacy status-string fallback. +- Local gateway channel-adapter boundary with default pairing/allowlist behavior for inbound senders. +- Signed Slack Events endpoint mapped onto the local pairing adapter. +- CLI operator commands for channel inbound simulation, pairing approval, and peer listing. +- Generated patches require final-output approval before desktop PR creation. +- Desktop PR branch suggestions avoid pushing directly from `main`/`master`. - PyInstaller spec includes project-office module for standalone binary builds. Remaining: - Desktop polish and usability hardening for production `.exe` workflows. +- Installable OS-level gateway daemon/tray lifecycle beyond the current desktop-owned session process. +- Telegram connector on top of the pairing adapter. +- Discord connector after Telegram, once channel/server routing rules are clearer. ## Phase 5: Remote MCP Product Surface diff --git a/docs/DISTRIBUTION.md b/docs/DISTRIBUTION.md new file mode 100644 index 0000000..36fe20e --- /dev/null +++ b/docs/DISTRIBUTION.md @@ -0,0 +1,86 @@ +# Distribution & Packaging + +How the Parallel Agents artifacts are built, smoke-tested, and shipped — and the +prep work still required before the desktop installer is consumer-ready +(code signing, auto-update, winget). + +## Artifacts + +| Artifact | Built by | Output | +| --- | --- | --- | +| Python package (PyPI) | `python -m build` | wheel + sdist | +| npm wrapper | `npm publish` | `npm-wrapper/` | +| CLI binary (3 OSes) | `pyinstaller parallel-agents.spec` | single executable | +| Desktop app (3 OSes) | `pyinstaller parallel-agents-desktop.spec` | **onedir** bundle | +| Windows installer | Inno Setup (`installer/parallel-agents-desktop.iss`) | `*-setup-*.exe` | + +The desktop app is built **onedir** (a launcher plus its unpacked Qt runtime) +rather than onefile: it starts faster, is far easier to code-sign (you sign the +files in place), and is what the Inno Setup installer packages. + +## CI gates + +- **`ci.yml` → `test`** runs the full pytest suite on Linux/macOS/Windows × + Python 3.11–3.13. The desktop smoke *scripts* run here too (they use a Qt + stub, so they need no PySide6). +- **`ci.yml` → `desktop`** installs the real PySide6 and runs + `tests/test_desktop_smoke.py` under an offscreen Qt platform on Linux and + Windows. This exercises the real Qt stack, including a headless launch of the + full window via `parallel-agents-desktop --smoke`. +- **`release.yml` → `desktop`** builds the onedir bundle on all three OSes, + **smoke-executes the built binary** (`--smoke` under offscreen Qt) so a broken + package fails the release, and builds the Windows installer. + +### The `--smoke` flag + +`parallel-agents-desktop --smoke` (or `PA_DESKTOP_SMOKE=1`) builds the entire +main window, starts and immediately stops the Qt event loop, and exits `0`. It +is the single mechanism behind both the pytest-qt offscreen test and the CI +binary smoke-execute, so "it imports" and "it actually launches packaged" are +both gated, not assumed. + +## Still required before consumer GA + +These are intentionally **not** wired up yet; this section is the runbook. + +### 1. Code signing + +Unsigned binaries trip SmartScreen (Windows) and Gatekeeper (macOS). + +- **Windows**: sign `dist/parallel-agents-desktop/*.exe` *and* the final + installer with `signtool` using an EV/OV certificate (ideally an Azure Trusted + Signing or HSM-backed key, never a PEM in the repo). Add a signing step after + the PyInstaller build and after `ISCC.exe`. Store the cert in + `secrets.WINDOWS_CODE_SIGN_*`. +- **macOS**: `codesign --deep --options runtime` the `.app`/bundle with a + Developer ID cert, then `notarytool submit --wait` and `stapler staple`. The + spec's `codesign_identity`/`entitlements_file` fields are the hooks for this. + +### 2. Auto-update (tufup) + +Ship updates without users re-downloading installers. + +- Adopt [`tufup`](https://github.com/dennisvang/tufup): maintain a TUF repository + of desktop archives, embed a `tufup.client.Client` check on startup (behind a + setting), and host the metadata + targets on a CDN. +- Generate and **offline-store** the TUF root/targets keys; the release job only + needs the targets-signing key. Bump the bundle version on each release so the + client can resolve a newer target. + +### 3. winget + +- Add a `manifests/` entry (installer + locale + version YAML) pointing at the + signed installer's release URL and its SHA256. +- Automate submission to `microsoft/winget-pkgs` via + [`winget-create`](https://github.com/microsoft/winget-create) (`wingetcreate + update`) as a release step, gated on the installer being **signed** (winget + validation rejects unsigned installers that trigger SmartScreen). + +## Local build + +```bash +pip install -e ".[desktop]" pyinstaller +pyinstaller --noconfirm parallel-agents-desktop.spec +# Windows installer (needs Inno Setup 6): +iscc installer\parallel-agents-desktop.iss +``` diff --git a/docs/workflows/gateway-api.md b/docs/workflows/gateway-api.md index da78613..c9be335 100644 --- a/docs/workflows/gateway-api.md +++ b/docs/workflows/gateway-api.md @@ -6,11 +6,11 @@ This document describes how to use the local gateway as an internal durable back The gateway stores project/run/job/event state in SQLite at: -- `.parallel-agents-output/gateway.sqlite` +- `.parallel-agents/gateway.sqlite` and stores artifact payloads under: -- `.parallel-agents-output//company/*.json` +- `.parallel-agents//company/*.json` ## Start Gateway @@ -73,6 +73,85 @@ When API key protection is enabled, all endpoints except `GET /health` require o - `GET /memory/policies` - `PUT /memory/policies` +### Channel Adapter + +- `POST /channels/slack/events` +- `POST /channels/inbound` +- `POST /channels/pairing/approve` +- `GET /channels/peers` + +`POST /channels/inbound` is the local adapter boundary for future Slack/Discord/Telegram-style connectors. Unknown senders are not processed. They receive a short pairing code: + +```json +{ + "channel": "slack", + "peer_id": "U123", + "message": "Review this repository", + "execute": true +} +``` + +Unknown sender response: + +```json +{ + "status": "pairing_required", + "processed": false, + "pairing_code": "A1B2C3" +} +``` + +Approve locally: + +```json +{ + "code": "A1B2C3", + "approved_by": "operator" +} +``` + +After approval, `execute=true` can enqueue a `pipeline.run`; if `execute` is omitted or false, the message is accepted but not processed. + +### Slack Events + +`POST /channels/slack/events` is the first real channel connector boundary. Configure Slack Event Subscriptions to point at this endpoint through a public HTTPS tunnel or deployment. The endpoint: + +- verifies `X-Slack-Signature` using `PA_GATEWAY_SLACK_SIGNING_SECRET` / `--slack-signing-secret` +- responds to Slack `url_verification` with the challenge value +- ignores bot/subtype messages +- maps message events to the local `slack` channel adapter +- still requires local pairing before a Slack sender can enqueue work + +For local tunnel testing only, `PA_GATEWAY_SLACK_ALLOW_UNSIGNED=1` or `--allow-unsigned-slack` skips signature verification. Do not use unsigned mode for an exposed gateway. + +Recommended Slack setup: + +```bash +set PA_GATEWAY_SLACK_SIGNING_SECRET= +parallel-agents gateway start --host 127.0.0.1 --port 8733 +``` + +Then expose `http://127.0.0.1:8733/channels/slack/events` through a trusted HTTPS tunnel and configure that public URL in Slack Event Subscriptions. Subscribe only to the message events needed for the first workflow, and keep the gateway bound to localhost unless the deployment has API key/JWT protection and Slack signature verification enabled. + +Connector priority: + +- Slack is the first real connector because it best matches software-company workflows. +- Telegram is the next recommended connector for personal-assistant usage. +- Discord should follow after Slack/Telegram because community-server routing and bot/subtype handling need more product decisions. + +CLI equivalents: + +```bash +parallel-agents gateway channel inbound \ + --channel slack \ + --peer-id U123 \ + --message "Review this repository" \ + --execute + +parallel-agents gateway channel approve --code A1B2C3 --approved-by operator +parallel-agents gateway channel peers --channel slack +``` + ### Company Workflows - `POST /runs/company/idea` @@ -81,6 +160,26 @@ When API key protection is enabled, all endpoints except `GET /health` require o - `POST /runs/company/approve` - `POST /runs/company/apply` +### Pipeline Runs + +- `POST /runs/pipeline` + +`POST /runs/pipeline` executes the core planner/worker/judge pipeline through the same local job system used by company workflows. Required payload: + +```json +{ + "run_id": "run-123", + "task": "Review this repository and propose a safe PR", + "repo_path": "./my-project" +} +``` + +Optional payload fields mirror the CLI: `workers`, `disable_workers`, `model`, `permission_mode`, `store_backend`, `max_parallel_workers`, `parse_retry_attempts`, `wait`, and `wait_timeout_seconds`. + +Pipeline status messages are appended to `GET /runs/{run_id}/events` as `pipeline_status`. Structured trace events are appended as `pipeline_trace` with payload fields such as `agent`, `phase`, `status`, `event`, `batch`, and `workers`. The final output is available as artifact `final-output`. + +The desktop office can submit runs through this endpoint when `PA_DESKTOP_USE_GATEWAY` is enabled, a gateway is detected locally, or the desktop starts its project-scoped gateway process. When a generated patch is present in `final-output`, the desktop creates a review approval and blocks PR creation until that exact artifact digest is approved. + ### Run Inspection and Control - `GET /runs` diff --git a/docs/workflows/local-desktop-office.md b/docs/workflows/local-desktop-office.md index 397d827..5a2737d 100644 --- a/docs/workflows/local-desktop-office.md +++ b/docs/workflows/local-desktop-office.md @@ -21,6 +21,7 @@ project/ ## Initialize ```bash +parallel-agents office onboard --project . --name "Project Name" parallel-agents office init --project . --name "Project Name" parallel-agents office status --project . parallel-agents office doctor --project . @@ -37,6 +38,7 @@ parallel-agents office memory policies --project . The standalone binary should support the same commands: ```bash +parallel-agents.exe office onboard --project . parallel-agents.exe office init --project . parallel-agents.exe office status --project . parallel-agents.exe office doctor --project . @@ -51,15 +53,18 @@ parallel-agents.exe office memory list --project . The desktop office should eventually provide: - project selection rooted in a local folder +- first-run onboarding that prepares workspace setup and reports model/GitHub readiness - immediate workspace-health diagnostics signal from `office doctor` - one-click setup remediation path from desktop (`Fix Setup`) and CLI (`office fix-setup`) +- desktop-owned project gateway start/stop/status controls - idea-to-release workflow controls -- run queue and worker status +- run queue and event-native worker status backed by the local gateway when available - approval queue before write actions, including bulk actions, artifact diff preview, and audit drilldown - artifact browser for brief, roadmap, RFC, issue plan, release checks - artifact-browser controls for search/filter/sort and quick open/export actions - local metrics and audit history - optional GitHub and MCP integrations (with explicit `gh` auth checks in desktop flow) +- local channel-adapter pairing/allowlist surface before real messaging connectors are enabled ## Non-Goals @@ -70,3 +75,19 @@ The desktop office should eventually provide: ## Gateway Role The gateway remains useful as an internal local job API and integration boundary. It should not define the user-facing product experience. + +Use `POST /runs/pipeline` when a desktop shell, automation script, or MCP host needs the same persistent run/job/event lifecycle for a real planner/worker/judge pipeline run. The desktop can still present a local-first product surface while the gateway acts as the shared control plane behind it. + +Desktop gateway behavior: + +- `PA_DESKTOP_USE_GATEWAY=auto` probes `http://127.0.0.1:8733` and falls back to in-process runs if unavailable. +- `PA_DESKTOP_GATEWAY_URL=http://host:port` points the desktop at a specific gateway. +- `PA_DESKTOP_GATEWAY_REQUIRED=1` fails fast when the gateway cannot be reached. +- `PA_DESKTOP_GATEWAY_HOST`, `PA_DESKTOP_GATEWAY_PORT`, and `PA_DESKTOP_GATEWAY_START_TIMEOUT` tune the desktop-owned gateway process. +- Generated patches create a pending `final-output` approval; desktop PR creation requires approval of the current artifact digest. + +The desktop-owned gateway is a session process, not an OS daemon. Closing the desktop stops owning the process; production installer work should add tray/daemon lifecycle and auto-restart. + +## Channel Adapter Role + +The gateway now exposes a local channel-adapter boundary for future messaging connectors. Unknown inbound senders receive a pairing code and are not processed until approved. This matches the required security posture for real DM/chat channels, but it is not a full Slack/Discord/Telegram connector yet. diff --git a/installer/parallel-agents-desktop.iss b/installer/parallel-agents-desktop.iss new file mode 100644 index 0000000..da91608 --- /dev/null +++ b/installer/parallel-agents-desktop.iss @@ -0,0 +1,59 @@ +; Inno Setup script for the Parallel Agents Office desktop app. +; +; Consumes the PyInstaller onedir bundle at dist\parallel-agents-desktop\ and +; produces a single Windows installer at installer\Output\. +; +; Build: +; pyinstaller --noconfirm parallel-agents-desktop.spec +; iscc installer\parallel-agents-desktop.iss +; +; Version can be overridden from CI: +; iscc /DMyAppVersion=1.2.3 installer\parallel-agents-desktop.iss + +#ifndef MyAppVersion + #define MyAppVersion "0.1.0" +#endif + +#define MyAppName "Parallel Agents Office" +#define MyAppPublisher "Parallel Agents" +#define MyAppExeName "parallel-agents-desktop.exe" +#define MyAppURL "https://github.com/ErenAri/parallel-agents" + +[Setup] +AppId={{8E2A6F4C-2B7D-4E2A-9C1F-PA0FFICE0001} +AppName={#MyAppName} +AppVersion={#MyAppVersion} +AppPublisher={#MyAppPublisher} +AppPublisherURL={#MyAppURL} +AppSupportURL={#MyAppURL}/issues +DefaultDirName={autopf}\Parallel Agents Office +DefaultGroupName={#MyAppName} +DisableProgramGroupPage=yes +; Per-user install needs no admin elevation. +PrivilegesRequired=lowest +PrivilegesRequiredOverridesAllowed=dialog +OutputDir=Output +OutputBaseFilename=parallel-agents-office-setup-{#MyAppVersion} +Compression=lzma2 +SolidCompression=yes +WizardStyle=modern +ArchitecturesAllowed=x64compatible +ArchitecturesInstallIn64BitMode=x64compatible + +[Languages] +Name: "english"; MessagesFile: "compiler:Default.isl" + +[Tasks] +Name: "desktopicon"; Description: "{cm:CreateDesktopIcon}"; GroupDescription: "{cm:AdditionalIcons}"; Flags: unchecked + +[Files] +; The entire PyInstaller onedir bundle. +Source: "..\dist\parallel-agents-desktop\*"; DestDir: "{app}"; Flags: ignoreversion recursesubdirs createallsubdirs + +[Icons] +Name: "{group}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}" +Name: "{group}\{cm:UninstallProgram,{#MyAppName}}"; Filename: "{uninstallexe}" +Name: "{autodesktop}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"; Tasks: desktopicon + +[Run] +Filename: "{app}\{#MyAppExeName}"; Description: "{cm:LaunchProgram,{#StringChange(MyAppName, '&', '&&')}}"; Flags: nowait postinstall skipifsilent diff --git a/parallel-agents-desktop.spec b/parallel-agents-desktop.spec index 2afb092..2954d49 100644 --- a/parallel-agents-desktop.spec +++ b/parallel-agents-desktop.spec @@ -2,10 +2,12 @@ # # Prereq: pip install parallel-agents[desktop] (installs PySide6) # Build: pyinstaller parallel-agents-desktop.spec -# Output: dist/parallel-agents-desktop[.exe] +# Output: dist/parallel-agents-desktop/ (onedir: launcher + bundled deps) # -# Produces a windowed binary (no console). For the headless CLI binary, -# use parallel-agents.spec instead. +# Produces a windowed onedir bundle (no console). Onedir is used so the Qt +# runtime is unpacked on disk — faster startup, easier to sign, and directly +# consumable by the Inno Setup installer. For the headless CLI binary, use +# parallel-agents.spec instead. from pathlib import Path @@ -26,6 +28,8 @@ a = Analysis( "parallel_agents.evidence_store", "parallel_agents.cost_tracker", "parallel_agents.project_office", + "parallel_agents.onboarding", + "parallel_agents.gateway", "parallel_agents.company_artifacts", "parallel_agents.company_policy", "parallel_agents.company_workflows", @@ -60,6 +64,9 @@ a = Analysis( "pydantic", "pydantic_settings", "anthropic", + "fastapi", + "starlette", + "uvicorn", # PySide6 — PyInstaller usually picks these up via its hook, # listed explicitly for clarity and to fail fast on missing extra. "PySide6", @@ -82,17 +89,14 @@ pyz = PYZ(a.pure, a.zipped_data, cipher=block_cipher) exe = EXE( pyz, a.scripts, - a.binaries, - a.zipfiles, - a.datas, [], + exclude_binaries=True, # onedir: binaries/datas are collected below name="parallel-agents-desktop", debug=False, bootloader_ignore_signals=False, strip=False, upx=True, upx_exclude=[], - runtime_tmpdir=None, console=False, # windowed app, no console disable_windowed_traceback=False, argv_emulation=False, @@ -100,3 +104,14 @@ exe = EXE( codesign_identity=None, entitlements_file=None, ) + +coll = COLLECT( + exe, + a.binaries, + a.zipfiles, + a.datas, + strip=False, + upx=True, + upx_exclude=[], + name="parallel-agents-desktop", +) diff --git a/parallel-agents.spec b/parallel-agents.spec index eb1aabd..edc723f 100644 --- a/parallel-agents.spec +++ b/parallel-agents.spec @@ -20,6 +20,8 @@ a = Analysis( "parallel_agents.evidence_store", "parallel_agents.cost_tracker", "parallel_agents.project_office", + "parallel_agents.onboarding", + "parallel_agents.gateway", "parallel_agents.agents", "parallel_agents.agents.base", "parallel_agents.agents.planner", diff --git a/pyproject.toml b/pyproject.toml index fae0cde..60a4ab2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -48,6 +48,9 @@ gateway = [ ] desktop = [ "PySide6>=6.7.0", + "fastapi>=0.115.0", + "starlette>=0.40.0,<0.42.0", + "uvicorn>=0.32.0", ] dev = [ "pytest>=8.0.0", diff --git a/scripts/smoke_desktop_construct.py b/scripts/smoke_desktop_construct.py new file mode 100644 index 0000000..94b2603 --- /dev/null +++ b/scripts/smoke_desktop_construct.py @@ -0,0 +1,44 @@ +"""Construction smoke: instantiates the full desktop window (and thus every page) +under a stubbed PySide6, so signature/wiring bugs in __init__ are caught without a +real Qt runtime. The import smoke only imports modules; this one constructs them. + +Run from repo root: + + python scripts/smoke_desktop_construct.py +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +# Reuse the exact PySide6 stub the import smoke installs. +sys.path.insert(0, str(Path(__file__).resolve().parent)) +from smoke_desktop_imports import _install_pyside6_stub # noqa: E402 + + +def main() -> int: + repo_src = Path(__file__).resolve().parent.parent / "src" + sys.path.insert(0, str(repo_src)) + _install_pyside6_stub() + + from parallel_agents.desktop.main_window import MainWindow + + # MainWindow.__init__ constructs every page (Projects, Company, Runs, + # Approvals, Artifacts, Settings) with a real EngineService that has no + # project open, so __init__ wiring runs end-to-end under the stub. + try: + MainWindow() + except Exception as exc: # noqa: BLE001 + import traceback + + traceback.print_exc() + print(f"\nFAIL: MainWindow construction raised {type(exc).__name__}: {exc}") + return 1 + + print("OK: MainWindow and all pages construct cleanly under the PySide6 stub.") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/smoke_desktop_engine.py b/scripts/smoke_desktop_engine.py index fec2bf2..e6c69bf 100644 --- a/scripts/smoke_desktop_engine.py +++ b/scripts/smoke_desktop_engine.py @@ -17,6 +17,8 @@ def main() -> int: sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "src")) + # Keep the smoke test hermetic: no live LLM calls regardless of environment. + os.environ.pop("ANTHROPIC_API_KEY", None) from parallel_agents.desktop.services.engine import EngineService # --- Slice 1+2: full pipeline --- @@ -62,14 +64,23 @@ def main() -> int: assert all("status" in i for i in result["issues"]) audit = Path(info.office_dir) / "audit" / "events.jsonl" - events = [ + entries = [ json.loads(line) for line in audit.read_text(encoding="utf-8").splitlines() if line.strip() ] - applied = [e for e in events if e.get("event") == "issue-plan.apply"] + # events.jsonl is now hash-chained: the event fields live under "payload". + payloads = [e["payload"] for e in entries] + applied = [p for p in payloads if p.get("event") == "issue-plan.apply"] assert len(applied) == 1 and applied[0]["mode"] == "dry-run" + # the governance log verifies as an intact hash chain + from parallel_agents.company_artifacts import verify_hash_chain + + chain = verify_hash_chain(audit, "governance-log") + assert chain.ok, f"governance chain broken: {chain.reason}" + print(f"governance chain verified: {chain.entry_count} entries") + # --- FU-1: LLM-flag fallback for brief, PRFAQ, RFC --- # All three flags on, no API key -> all three must fall back deterministically. with tempfile.TemporaryDirectory() as tmp: diff --git a/scripts/smoke_desktop_imports.py b/scripts/smoke_desktop_imports.py index bceafa4..c06761a 100644 --- a/scripts/smoke_desktop_imports.py +++ b/scripts/smoke_desktop_imports.py @@ -12,7 +12,13 @@ def _install_pyside6_stub() -> None: - class _Stub: + class _StubMeta(type): + # Resolve class-level enum access like QSizePolicy.Policy.Minimum or + # QComboBox.InsertPolicy.NoInsert to nested stub instances. + def __getattr__(cls, name): + return _Stub() + + class _Stub(metaclass=_StubMeta): def __init__(self, *a, **kw): pass @@ -25,6 +31,38 @@ def __getattr__(self, name): def __setattr__(self, name, value): object.__setattr__(self, name, value) + # Stubbed Qt accessors that return collections (selectedItems, findChildren, + # etc.) should behave as empty iterables rather than blowing up. + def __iter__(self): + return iter(()) + + def __len__(self): + return 0 + + def __bool__(self): + return False + + # Stubbed int-returning accessors (count(), currentRow(), width(), ...). + def __index__(self): + return 0 + + def __int__(self): + return 0 + + # Comparisons against ints (e.g. `if combo.findText(x) >= 0`) take the + # not-found / falsy branch under the stub. + def __lt__(self, other): + return False + + def __le__(self, other): + return False + + def __gt__(self, other): + return False + + def __ge__(self, other): + return False + class _SignalStub: def __init__(self, *a, **kw): pass @@ -44,6 +82,17 @@ def emit(self, *_a, **_kw): class _EnumMember: def __init__(self, name): self._name = name def __repr__(self): return f"Stub({self._name})" + def __getattr__(self, name): return _EnumMember(f"{self._name}.{name}") + def __eq__(self, other): return isinstance(other, _EnumMember) and other._name == self._name + def __hash__(self): return hash(self._name) + # Qt enum/flag values support bitwise composition. + def __invert__(self): return _EnumMember(f"~{self._name}") + def __and__(self, other): return _EnumMember(f"{self._name}&") + def __rand__(self, other): return _EnumMember(f"&{self._name}") + def __or__(self, other): return _EnumMember(f"{self._name}|") + def __ror__(self, other): return _EnumMember(f"|{self._name}") + def __int__(self): return 0 + def __index__(self): return 0 class _NamespaceMeta(type): def __getattr__(cls, item): @@ -71,8 +120,8 @@ def _make_module(name: str, attrs: dict) -> types.ModuleType: } # QtGui qt_gui = {n: _Stub for n in [ - "QAction", "QColor", "QFont", "QIcon", "QPalette", - "QTextCharFormat", "QTextCursor", + "QAction", "QColor", "QFont", "QIcon", "QPainter", "QPalette", + "QPen", "QPixmap", "QTextCharFormat", "QTextCursor", ]} # QtWidgets qt_widgets = {n: _Stub for n in [ @@ -111,6 +160,14 @@ def main() -> int: "parallel_agents.desktop.widgets.artifact_viewer", "parallel_agents.desktop.services.engine", "parallel_agents.desktop.services.workers", + "parallel_agents.desktop.services.llm", + "parallel_agents.desktop.services.llm_config", + "parallel_agents.desktop.services.llm_brief", + "parallel_agents.desktop.services.llm_prfaq", + "parallel_agents.desktop.services.llm_tech_stack", + "parallel_agents.desktop.services.llm_rfc", + "parallel_agents.desktop.services.llm_roadmap", + "parallel_agents.desktop.services.llm_sprint", "parallel_agents.desktop.pages._base", "parallel_agents.desktop.pages.projects_page", "parallel_agents.desktop.pages.company_page", diff --git a/scripts/smoke_desktop_polish.py b/scripts/smoke_desktop_polish.py index 719f082..042d926 100644 --- a/scripts/smoke_desktop_polish.py +++ b/scripts/smoke_desktop_polish.py @@ -86,17 +86,98 @@ def main() -> int: assert key not in os.environ print("malformed file ignored cleanly: ok") - # --- 4. status bar LLM indicator --- + # --- 4. status bar LLM indicator (default-on policy) --- for key in KNOWN_KEYS: os.environ.pop(key, None) + os.environ.pop("ANTHROPIC_API_KEY", None) from parallel_agents.desktop.services.status import llm_indicator_text + # no key, no flags -> deterministic assert llm_indicator_text() == "LLM: deterministic" + + # explicit per-artifact flags win even without a key os.environ["PA_DESKTOP_LLM_BRIEF"] = "1" os.environ["PA_DESKTOP_LLM_RFC"] = "1" assert llm_indicator_text() == "LLM: brief, rfc" + os.environ.pop("PA_DESKTOP_LLM_BRIEF", None) + os.environ.pop("PA_DESKTOP_LLM_RFC", None) + + # a key present -> default-on for every artifact + os.environ["ANTHROPIC_API_KEY"] = "sk-test-not-real" + assert llm_indicator_text() == "LLM: all" + # global disable flag overrides the key + os.environ["PA_DESKTOP_LLM"] = "0" + assert llm_indicator_text() == "LLM: deterministic" + os.environ.pop("PA_DESKTOP_LLM", None) + os.environ.pop("ANTHROPIC_API_KEY", None) print(f"LLM indicator text: {llm_indicator_text()}") + # --- 5. history store: round-trip, de-dup, cap, malformed file --- + from parallel_agents.desktop.services.history import DEFAULT_LIMIT, HistoryStore + + with tempfile.TemporaryDirectory() as tmp: + hist_path = Path(tmp) / "history.json" + hist = HistoryStore(path=hist_path) + + assert hist.get("repo_path") == [] + assert hist.add("repo_path", " ") == [] # blank ignored + + hist.add("repo_path", "C:/projects/alpha") + hist.add("repo_path", "C:/projects/beta") + assert hist.get("repo_path") == ["C:/projects/beta", "C:/projects/alpha"] + + # re-add moves to front without duplication + hist.add("repo_path", "C:/projects/alpha") + assert hist.get("repo_path") == ["C:/projects/alpha", "C:/projects/beta"] + + # cap at DEFAULT_LIMIT entries + for i in range(DEFAULT_LIMIT + 3): + hist.add("repo_ref", f"owner/repo-{i}") + capped = hist.get("repo_ref") + assert len(capped) == DEFAULT_LIMIT + assert capped[0] == f"owner/repo-{DEFAULT_LIMIT + 2}" + + # keys are independent + assert hist.get("repo_path") == ["C:/projects/alpha", "C:/projects/beta"] + + hist.clear("repo_path") + assert hist.get("repo_path") == [] + assert len(hist.get("repo_ref")) == DEFAULT_LIMIT + + # malformed file degrades to empty, then recovers on next add + hist_path.write_text("{not json", encoding="utf-8") + assert hist.get("repo_ref") == [] + assert hist.add("repo_ref", "owner/fresh") == ["owner/fresh"] + print("history store round-trip/de-dup/cap: ok") + + # --- 6. engine roadmap_milestones helper --- + from parallel_agents.desktop.services.engine import EngineService + from parallel_agents.project_office import office_output_dir + + with tempfile.TemporaryDirectory() as proj_tmp: + engine = EngineService() + assert engine.roadmap_milestones(None) == [] + + engine.init_project(proj_tmp, name="smoke") + assert engine.roadmap_milestones("no-such-run") == [] + + run_dir = office_output_dir(Path(proj_tmp)) / "run-smoke" / "company" + run_dir.mkdir(parents=True) + roadmap = { + "name": "Smoke Roadmap", + "horizon_weeks": 6, + "outcomes": ["ship"], + "items": [ + {"id": "R1", "title": "a", "owner_role": "eng", "milestone": "M1"}, + {"id": "R2", "title": "b", "owner_role": "eng", "milestone": "M2"}, + {"id": "R3", "title": "c", "owner_role": "eng", "milestone": "M1"}, + {"id": "R4", "title": "d", "owner_role": "eng", "milestone": " "}, + ], + } + (run_dir / "roadmap.json").write_text(json.dumps(roadmap), encoding="utf-8") + assert engine.roadmap_milestones("run-smoke") == ["M1", "M2"] + print("engine roadmap_milestones ordered/unique/empty: ok") + print("\nOK: polish slice smoke tests pass.") return 0 diff --git a/src/parallel_agents/agents/planner.py b/src/parallel_agents/agents/planner.py index b396c90..2813721 100644 --- a/src/parallel_agents/agents/planner.py +++ b/src/parallel_agents/agents/planner.py @@ -38,6 +38,9 @@ - code: actual implementation or refactoring - review: code style, best practices +Assign at most ONE subtask per specialist worker. If a worker has several +concerns, merge them into a single subtask description for that worker. + Output ONLY valid JSON matching this schema: { "summary": "brief description of the task and approach", @@ -110,7 +113,7 @@ async def run_planner(task_input: TaskInput, config: PipelineConfig) -> TaskPlan _merge_token_usage(token_usage, attempt_usage) plan = _parse_plan(raw_text, task_input) - if not _is_parse_failure(plan): + if not is_parse_failure(plan): parse_retries_used = attempt break parse_retries_used = attempt + 1 @@ -122,6 +125,7 @@ async def run_planner(task_input: TaskInput, config: PipelineConfig) -> TaskPlan "total_cost_usd": total_cost, "token_usage": token_usage, "parse_retries_used": parse_retries_used, + "parse_failed": is_parse_failure(plan), }) return plan @@ -162,7 +166,10 @@ def _merge_token_usage(total: dict[str, int], usage: dict[str, int]) -> None: total[key] = total.get(key, 0) + value -def _is_parse_failure(plan: TaskPlan) -> bool: +def is_parse_failure(plan: TaskPlan) -> bool: + """True when the plan is the sentinel produced after all parse retries failed.""" + if plan.global_context.get("planner_metrics", {}).get("parse_failed"): + return True return plan.summary == "Failed to parse planner output" or plan.summary.startswith( "Planner output parse error:" ) diff --git a/src/parallel_agents/company_artifacts.py b/src/parallel_agents/company_artifacts.py index a28def8..a77d60e 100644 --- a/src/parallel_agents/company_artifacts.py +++ b/src/parallel_agents/company_artifacts.py @@ -1,7 +1,11 @@ from __future__ import annotations import hashlib +import hmac import json +import os +import re +from dataclasses import dataclass, field from datetime import datetime, timezone from pathlib import Path from typing import Any @@ -13,6 +17,35 @@ def _utc_now() -> datetime: return datetime.now(timezone.utc) +# Path segments that come from callers (including, on the gateway, attacker- +# influenced HTTP payloads) must not be able to escape the run directory. +_SAFE_SEGMENT = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*$") + +# Windows resolves these names to devices regardless of directory or extension +# (e.g. NUL.json silently discards writes, CON targets the console). Reject them +# everywhere so behaviour does not diverge between platforms. +_WIN_RESERVED = { + "CON", "PRN", "AUX", "NUL", + *(f"COM{i}" for i in range(1, 10)), + *(f"LPT{i}" for i in range(1, 10)), +} + + +def _safe_segment(value: str, kind: str) -> str: + """Validate a single path segment (run_id, artifact_name). + + Rejects empties, '.'/'..', path separators, Windows reserved device names, + and anything outside a strict allow-list so a value like '../../escape' + cannot traverse out of the run directory. + """ + cleaned = (value or "").strip() + if cleaned in ("", ".", "..") or not _SAFE_SEGMENT.match(cleaned): + raise ValueError(f"Invalid {kind}: {value!r}") + if cleaned.split(".", 1)[0].upper() in _WIN_RESERVED: + raise ValueError(f"Invalid {kind} (reserved device name): {value!r}") + return cleaned + + class CompanyArtifactIndex(BaseModel): run_id: str created_at: datetime = Field(default_factory=_utc_now) @@ -27,10 +60,8 @@ def persist_company_artifact( artifact_payload: BaseModel | dict[str, Any], ) -> Path: """Persist a company artifact under //company.""" - if not run_id.strip(): - raise ValueError("run_id cannot be empty") - if not artifact_name.strip(): - raise ValueError("artifact_name cannot be empty") + run_id = _safe_segment(run_id, "run_id") + artifact_name = _safe_segment(artifact_name, "artifact_name") base = Path(output_dir) / run_id / "company" base.mkdir(parents=True, exist_ok=True) @@ -47,6 +78,7 @@ def persist_company_artifact( def load_company_artifact_index(output_dir: str | Path, run_id: str) -> CompanyArtifactIndex | None: + run_id = _safe_segment(run_id, "run_id") path = Path(output_dir) / run_id / "company" / "index.json" if not path.exists(): return None @@ -55,6 +87,7 @@ def load_company_artifact_index(output_dir: str | Path, run_id: str) -> CompanyA def list_company_artifact_paths(output_dir: str | Path, run_id: str) -> dict[str, str]: + run_id = _safe_segment(run_id, "run_id") index = load_company_artifact_index(output_dir, run_id) if not index: return {} @@ -68,6 +101,7 @@ def load_company_artifact( artifact_name: str, ) -> dict[str, Any] | None: """Load one named artifact from a run-linked company artifact directory.""" + run_id = _safe_segment(run_id, "run_id") paths = list_company_artifact_paths(output_dir, run_id) artifact_path = paths.get(artifact_name) if not artifact_path: @@ -85,35 +119,15 @@ def append_company_artifact_event( event_payload: BaseModel | dict[str, Any], ) -> Path: """Append an immutable event record for a company artifact.""" - if not run_id.strip(): - raise ValueError("run_id cannot be empty") - if not artifact_name.strip(): - raise ValueError("artifact_name cannot be empty") + run_id = _safe_segment(run_id, "run_id") + artifact_name = _safe_segment(artifact_name, "artifact_name") base = Path(output_dir) / run_id / "company" audit_dir = base / "audit" audit_dir.mkdir(parents=True, exist_ok=True) log_path = audit_dir / f"{artifact_name}.jsonl" - payload = _to_payload_dict(event_payload) - previous_hash = _load_last_event_hash(log_path) - - body = { - "payload": payload, - "previous_hash": previous_hash, - } - body_json = json.dumps(body, sort_keys=True, separators=(",", ":"), default=str) - entry_hash = hashlib.sha256(body_json.encode("utf-8")).hexdigest() - - entry = { - "timestamp": _utc_now().isoformat(), - "hash": entry_hash, - "previous_hash": previous_hash, - "payload": payload, - } - with log_path.open("a", encoding="utf-8") as handle: - handle.write(json.dumps(entry, default=str)) - handle.write("\n") + append_hash_chained_line(log_path, _to_payload_dict(event_payload)) index = load_company_artifact_index(output_dir, run_id) or CompanyArtifactIndex(run_id=run_id) index.artifacts[f"{artifact_name}-audit-log"] = str(Path("audit") / log_path.name) @@ -128,6 +142,8 @@ def load_company_artifact_events( artifact_name: str, ) -> list[dict[str, Any]]: """Load append-only event records for a company artifact.""" + run_id = _safe_segment(run_id, "run_id") + artifact_name = _safe_segment(artifact_name, "artifact_name") log_path = Path(output_dir) / run_id / "company" / "audit" / f"{artifact_name}.jsonl" if not log_path.exists(): return [] @@ -140,22 +156,191 @@ def load_company_artifact_events( return events -def _load_last_event_hash(log_path: Path) -> str | None: +def _audit_key() -> bytes | None: + """Return the optional HMAC key for the audit chain, if configured. + + When ``PA_AUDIT_HMAC_KEY`` is set the chain digest is HMAC-SHA256 keyed with + a secret the log writer is assumed not to know (held by a separate verifier), + which makes edits/insertions forgeable only by a holder of the key. Without + it the chain is an unkeyed SHA-256 chain (integrity against accidental + corruption and naive in-place edits only). + """ + key = os.environ.get("PA_AUDIT_HMAC_KEY") + return key.encode("utf-8") if key else None + + +def _compute_entry_hash( + seq: int, timestamp: str, payload: dict[str, Any], previous_hash: str | None +) -> str: + body = { + "seq": seq, + "timestamp": timestamp, + "payload": payload, + "previous_hash": previous_hash, + } + body_json = json.dumps(body, sort_keys=True, separators=(",", ":"), default=str) + encoded = body_json.encode("utf-8") + key = _audit_key() + if key: + return hmac.new(key, encoded, hashlib.sha256).hexdigest() + return hashlib.sha256(encoded).hexdigest() + + +def append_hash_chained_line(log_path: Path, payload: dict[str, Any]) -> dict[str, Any]: + """Append one hash-chained entry to a .jsonl log. + + Shared by the per-artifact audit log and the workspace-wide governance log. + Each entry binds its sequence number, timestamp, payload, and the previous + entry's hash, so an in-place edit, insertion, or reordering breaks the chain. + + Threat model and limits (kept honest deliberately): + - Without ``PA_AUDIT_HMAC_KEY`` the digest is an unkeyed SHA-256 chain: it + detects accidental corruption and naive edits, but an attacker who can + rewrite the whole file can recompute every hash. Set the env var to a + secret held outside the writer to get HMAC-SHA256 and real + tamper-evidence against edits/insertions. + - Truncation of the most recent entries cannot be detected from the log + alone (a shorter prefix is still self-consistent). Verify regularly and, + for high-assurance use, anchor the head hash + count externally. + + Before appending, the existing chain is verified; appending onto a broken or + forged tail is refused so the append and verify paths never disagree. + """ + log_path.parent.mkdir(parents=True, exist_ok=True) + existing = verify_hash_chain(log_path) + if not existing.ok: + raise RuntimeError( + f"refusing to append to broken audit chain {log_path.name}: {existing.reason}" + ) + previous_hash, seq = _load_chain_tail(log_path) + timestamp = _utc_now().isoformat() + entry = { + "seq": seq, + "timestamp": timestamp, + "hash": _compute_entry_hash(seq, timestamp, payload, previous_hash), + "previous_hash": previous_hash, + "payload": payload, + } + with log_path.open("a", encoding="utf-8") as handle: + handle.write(json.dumps(entry, default=str)) + handle.write("\n") + return entry + + +def verify_hash_chain(log_path: Path, label: str = "") -> ChainVerification: + """Recompute a hash-chained .jsonl log and report the first break, if any. + + ``broken_index`` and ``entry_count`` both count logical entries (0-based), + skipping blank lines, so they stay consistent across every break reason. + """ if not log_path.exists(): - return None - last_hash: str | None = None + return ChainVerification(label, True, 0, reason="no-log") + + expected_prev: str | None = None + expected_seq = 0 + count = 0 for line in log_path.read_text(encoding="utf-8").splitlines(): - stripped = line.strip() - if not stripped: + if not line.strip(): continue try: - entry = json.loads(stripped) + entry = json.loads(line) except json.JSONDecodeError: + return ChainVerification(label, False, count, broken_index=count, reason="invalid-json") + if entry.get("previous_hash") != expected_prev: + return ChainVerification(label, False, count, broken_index=count, reason="broken-linkage") + if entry.get("seq") != expected_seq: + return ChainVerification(label, False, count, broken_index=count, reason="seq-gap") + recomputed = _compute_entry_hash( + expected_seq, + str(entry.get("timestamp", "")), + entry.get("payload", {}), + entry.get("previous_hash"), + ) + if recomputed != entry.get("hash"): + return ChainVerification(label, False, count, broken_index=count, reason="hash-mismatch") + expected_prev = entry.get("hash") + expected_seq += 1 + count += 1 + + return ChainVerification(label, True, count) + + +@dataclass +class ChainVerification: + """Result of recomputing a single artifact's audit chain.""" + + artifact_name: str + ok: bool + entry_count: int + broken_index: int | None = None + reason: str | None = None + + +@dataclass +class RunChainVerification: + """Aggregate verification across every audit log in a run.""" + + run_id: str + ok: bool + chains: list[ChainVerification] = field(default_factory=list) + + +def verify_company_artifact_chain( + output_dir: str | Path, + run_id: str, + artifact_name: str, +) -> ChainVerification: + """Recompute an artifact's hash chain and report the first break, if any. + + Recomputes every entry hash over (seq, timestamp, payload, previous_hash) + and checks the previous_hash linkage and sequence ordering. See + ``append_hash_chained_line`` for the exact tamper-evidence guarantees and + their limits (HMAC keying, tail-truncation). + """ + run_id = _safe_segment(run_id, "run_id") + artifact_name = _safe_segment(artifact_name, "artifact_name") + log_path = Path(output_dir) / run_id / "company" / "audit" / f"{artifact_name}.jsonl" + return verify_hash_chain(log_path, artifact_name) + + +def verify_run_audit_chains( + output_dir: str | Path, run_id: str +) -> RunChainVerification: + """Verify every audit chain under a run's company directory.""" + run_id = _safe_segment(run_id, "run_id") + audit_dir = Path(output_dir) / run_id / "company" / "audit" + chains: list[ChainVerification] = [] + if audit_dir.is_dir(): + for log_path in sorted(audit_dir.glob("*.jsonl")): + chains.append( + verify_company_artifact_chain(output_dir, run_id, log_path.stem) + ) + return RunChainVerification( + run_id=run_id, ok=all(c.ok for c in chains), chains=chains + ) + + +def _load_chain_tail(log_path: Path) -> tuple[str | None, int]: + """Return (last_hash, next_seq) for an already-verified chain. + + Callers must verify the chain first (``append_hash_chained_line`` does), so + every line parses and sequence numbers are contiguous from 0 — next_seq is + therefore the entry count. + """ + if not log_path.exists(): + return None, 0 + last_hash: str | None = None + seq = 0 + for line in log_path.read_text(encoding="utf-8").splitlines(): + stripped = line.strip() + if not stripped: continue + entry = json.loads(stripped) value = entry.get("hash") if isinstance(value, str) and value: last_hash = value - return last_hash + seq += 1 + return last_hash, seq def _to_payload_dict(value: BaseModel | dict[str, Any]) -> dict[str, Any]: diff --git a/src/parallel_agents/company_workflows.py b/src/parallel_agents/company_workflows.py index d75ce03..878c2b0 100644 --- a/src/parallel_agents/company_workflows.py +++ b/src/parallel_agents/company_workflows.py @@ -522,6 +522,22 @@ def build_issue_plan_from_roadmap( return planned +def issue_plan_items(payload: dict[str, Any]) -> list[Any]: + """Return the executable issue list from an issue-plan artifact. + + Unifies the two historical artifact shapes: the network surfaces (CLI, + gateway, MCP) store the normalized executor list under ``issue_plan`` while + the desktop stores it under ``issues``. Preferring ``issue_plan`` keeps the + richer normalized list when present and falls back to ``issues`` so a plan + created on any surface applies from any other — they now share one workspace + root, so cross-surface reads must agree on the schema. + """ + items = payload.get("issue_plan") + if not items: + items = payload.get("issues") + return list(items) if isinstance(items, list) else [] + + def build_github_workflow_templates(roadmap: RoadmapPlan | None = None) -> dict[str, Any]: labels = [ { diff --git a/src/parallel_agents/config.py b/src/parallel_agents/config.py index f7148a9..ff7cca3 100644 --- a/src/parallel_agents/config.py +++ b/src/parallel_agents/config.py @@ -1,10 +1,43 @@ from __future__ import annotations +from pathlib import Path from typing import Literal from pydantic import BaseModel, Field from pydantic_settings import BaseSettings +# Single source of truth for the workspace root. The desktop office, the CLI, +# the gateway, and the MCP server all default to this one directory so they +# operate on the same workspace instead of diverging into two trees. +DEFAULT_ARTIFACT_DIR = ".parallel-agents" +# Pre-unification CLI/gateway/MCP default. A one-time `office migrate` moves a +# legacy workspace onto the unified root; see migrate_legacy_artifact_dir. +LEGACY_ARTIFACT_DIR = ".parallel-agents-output" + + +def migrate_legacy_artifact_dir(project_root: str | Path = ".") -> dict[str, object]: + """Move a legacy ``.parallel-agents-output`` workspace onto the unified root. + + Explicit and safe by design (no implicit, intent-blind fallback that could + silently split a project across two roots): it only acts when the legacy + directory exists and the unified ``.parallel-agents`` directory does not, and + it never merges or overwrites. Returns a small report describing the outcome. + """ + root = Path(project_root) + legacy = root / LEGACY_ARTIFACT_DIR + target = root / DEFAULT_ARTIFACT_DIR + if not legacy.exists(): + return {"migrated": False, "reason": "no-legacy-dir", "legacy": str(legacy)} + if target.exists(): + return { + "migrated": False, + "reason": "target-exists", + "legacy": str(legacy), + "target": str(target), + } + legacy.rename(target) + return {"migrated": True, "legacy": str(legacy), "target": str(target)} + class WorkerConfig(BaseModel): enabled: bool = True @@ -31,7 +64,7 @@ class PipelineConfig(BaseSettings): judge_model: str = "opus" permission_mode: Literal["default", "acceptEdits", "plan", "bypassPermissions"] = "default" max_parallel_workers: int = 4 - output_dir: str = ".parallel-agents-output" + output_dir: str = DEFAULT_ARTIFACT_DIR max_retries: int = 2 retry_delay_seconds: float = 5.0 parse_retry_attempts: int = 1 diff --git a/src/parallel_agents/desktop/app.py b/src/parallel_agents/desktop/app.py index 8f1ce4a..230b4ca 100644 --- a/src/parallel_agents/desktop/app.py +++ b/src/parallel_agents/desktop/app.py @@ -1,19 +1,55 @@ from __future__ import annotations +import os import sys +import traceback -from parallel_agents.desktop._qt import QApplication +from parallel_agents.desktop._qt import QApplication, QMessageBox, QTimer from parallel_agents.desktop.main_window import MainWindow from parallel_agents.desktop.theme import apply_theme +def _install_excepthook() -> None: + """Surface unhandled exceptions in windowed builds instead of dying silently.""" + + def _hook(exc_type, exc, tb) -> None: + detail = "".join(traceback.format_exception(exc_type, exc, tb)) + sys.stderr.write(detail) + try: + QMessageBox.critical( + None, + "Unexpected error", + f"{exc_type.__name__}: {exc}\n\n{detail[-3000:]}", + ) + except Exception: # noqa: BLE001 - never raise from the hook itself + pass + + sys.excepthook = _hook + + def run(argv: list[str] | None = None) -> int: - app = QApplication(argv if argv is not None else sys.argv) + raw = list(sys.argv if argv is None else argv) + # --smoke (or PA_DESKTOP_SMOKE=1) builds the full window and exits 0 without + # entering the event loop. Used by CI to verify a packaged binary actually + # launches and constructs under an offscreen Qt platform. + smoke = "--smoke" in raw or os.environ.get("PA_DESKTOP_SMOKE") == "1" + qt_argv = [arg for arg in raw if arg != "--smoke"] + + app = QApplication(qt_argv) app.setApplicationName("Parallel Agents Office") app.setOrganizationName("Parallel Agents") apply_theme(app) + _install_excepthook() window = MainWindow() window.resize(1280, 800) + if smoke: + # Build the window, let Qt initialize, then close cleanly without the + # interactive "jobs still running" prompt (which would block offscreen). + window._headless = True + window.show() + QTimer.singleShot(0, window.close) + QTimer.singleShot(0, app.quit) + return app.exec() window.show() return app.exec() diff --git a/src/parallel_agents/desktop/main_window.py b/src/parallel_agents/desktop/main_window.py index db1f9f9..c5ec019 100644 --- a/src/parallel_agents/desktop/main_window.py +++ b/src/parallel_agents/desktop/main_window.py @@ -4,8 +4,10 @@ QHBoxLayout, QLabel, QMainWindow, + QMessageBox, QStackedWidget, QStatusBar, + QThread, QWidget, ) from parallel_agents.desktop.pages.approvals_page import ApprovalsPage @@ -29,6 +31,10 @@ def __init__(self) -> None: super().__init__() self.setWindowTitle("Parallel Agents Office") + # Headless/smoke mode: skip the interactive "jobs still running" close + # confirmation (a modal dialog has no one to dismiss it offscreen). + self._headless = False + self.engine = EngineService() # Apply persisted user-level settings on launch (project ones load on open). self.engine.settings_store().load() @@ -136,3 +142,39 @@ def _jump_to_artifact(self, run_id: str, path) -> None: focus = getattr(artifacts, "focus_artifact", None) if callable(focus): focus(run_id, path) + + # -- shutdown ------------------------------------------------------- + + def _running_jobs(self) -> list[QThread]: + jobs: list[QThread] = [] + for page in self.pages.values(): + for attr in ("_job", "_auth_job"): + job = getattr(page, attr, None) + if isinstance(job, QThread) and job.isRunning(): + jobs.append(job) + return jobs + + def closeEvent(self, event) -> None: # noqa: N802 - Qt API + jobs = self._running_jobs() + if jobs: + if not self._headless: + confirm = QMessageBox.question( + self, + "Jobs still running", + "A step is still running (possibly a GitHub write).\n" + "Closing now may leave it half-finished. Close anyway?", + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, + QMessageBox.StandardButton.No, + ) + if confirm != QMessageBox.StandardButton.Yes: + event.ignore() + return + for job in jobs: + job.requestInterruption() + for job in jobs: + # Give threads a chance to finish; force-stop as a last resort + # so Qt does not abort with "Destroyed while thread is running". + if not job.wait(5000): + job.terminate() + job.wait(2000) + event.accept() diff --git a/src/parallel_agents/desktop/pages/approvals_page.py b/src/parallel_agents/desktop/pages/approvals_page.py index 4c327e0..1b98e6e 100644 --- a/src/parallel_agents/desktop/pages/approvals_page.py +++ b/src/parallel_agents/desktop/pages/approvals_page.py @@ -22,6 +22,7 @@ ) from parallel_agents.desktop.pages._base import Page from parallel_agents.desktop.services.engine import EngineService +from parallel_agents.desktop.services.workers import AsyncJob from parallel_agents.desktop.widgets.artifact_viewer import ArtifactViewer from parallel_agents.project_office import office_dir @@ -33,6 +34,7 @@ def __init__(self, engine: EngineService) -> None: subtitle="Review pending writes before they touch GitHub or the repository.", ) self.engine = engine + self._job: AsyncJob | None = None refresh_row = QHBoxLayout() refresh_btn = QPushButton("Refresh") @@ -480,8 +482,6 @@ def _find_previous_artifact(self, current_path: Path) -> Path | None: return None def _apply_selected_plan(self) -> None: - from parallel_agents.desktop.widgets.error_dialog import show_error - entry = self._selected_entry() if entry is None: QMessageBox.information( @@ -499,6 +499,12 @@ def _apply_selected_plan(self) -> None: QMessageBox.warning(self, "Missing run", "Selected approval has no run_id.") return + if self._job is not None and self._job.isRunning(): + QMessageBox.information( + self, "Apply running", "An apply is already running. Wait for it to finish." + ) + return + mode_choice = QMessageBox.question( self, "Apply plan", @@ -511,12 +517,18 @@ def _apply_selected_plan(self) -> None: if mode_choice == QMessageBox.StandardButton.Cancel: return dry_run = mode_choice != QMessageBox.StandardButton.Yes - try: - result = self.engine.apply_issue_plan(run_id, dry_run=dry_run) - except Exception as exc: # noqa: BLE001 - show_error(self, "Apply failed", str(exc), details=repr(exc)) - return + self.apply_btn.setEnabled(False) + self.apply_btn.setText("Applying...") + self._job = AsyncJob( + lambda: self.engine.apply_issue_plan_async(run_id, dry_run=dry_run) + ) + self._job.finished_ok.connect(self._on_apply_finished) + self._job.failed.connect(self._on_apply_failed) + self._job.start() + + def _on_apply_finished(self, result) -> None: + self.apply_btn.setText("Apply Plan") mode = str(result.get("mode", "dry-run")) created = sum(1 for issue in result.get("issues", []) if issue.get("created")) planned = int(result.get("issues_planned", 0) or 0) @@ -527,6 +539,18 @@ def _apply_selected_plan(self) -> None: ) self._refresh() + def _on_apply_failed(self, error: str) -> None: + from parallel_agents.desktop.widgets.error_dialog import show_error + + self.apply_btn.setText("Apply Plan") + self.apply_btn.setEnabled(True) + show_error( + self, + "Apply failed", + "The GitHub apply could not complete. See details for the full error.", + details=error, + ) + def _read_artifact_for_compare(path: Path) -> str: text = path.read_text(encoding="utf-8", errors="replace") diff --git a/src/parallel_agents/desktop/pages/company_page.py b/src/parallel_agents/desktop/pages/company_page.py index b3875e5..6f75c09 100644 --- a/src/parallel_agents/desktop/pages/company_page.py +++ b/src/parallel_agents/desktop/pages/company_page.py @@ -1,5 +1,6 @@ from __future__ import annotations +import inspect from pathlib import Path from parallel_agents.desktop._qt import ( @@ -23,6 +24,17 @@ from parallel_agents.desktop.services.workers import AsyncJob +def _done_label(result) -> str: + """Surface provenance on the card status: model name or 'template'.""" + provenance = getattr(result, "provenance", None) or {} + if provenance.get("generator") == "llm": + model = str(provenance.get("model", "")).strip() + return f"done ({model})" if model else "done (llm)" + if provenance.get("generator") == "template": + return "done (template)" + return "done" + + def _editable_combo(items: list[str], placeholder: str) -> QComboBox: combo = QComboBox() combo.setEditable(True) @@ -86,6 +98,7 @@ def __init__(self, engine: EngineService) -> None: self.engine = engine self.history = HistoryStore() self._job: AsyncJob | None = None + self._auth_job: AsyncJob | None = None self._run_id: str | None = None # --- scrollable body since the workflow is long --- @@ -235,7 +248,7 @@ def __init__(self, engine: EngineService) -> None: inner_layout.addStretch(1) self._refresh_state() - self._update_github_auth_label() + self._refresh_github_auth_label() # -- factory -------------------------------------------------------- @@ -346,7 +359,7 @@ def _apply_plan(self) -> None: rid = self._run_id self._run_step( self.apply_card, - lambda: self.engine.apply_issue_plan(rid, dry_run=dry_run), + lambda: self.engine.apply_issue_plan_async(rid, dry_run=dry_run), after=self._on_apply_done, ) @@ -367,7 +380,7 @@ def _create_pr(self) -> None: rid = self._run_id self._run_step( self.pr_card, - lambda: self.engine.create_pull_request( + lambda: self.engine.create_pull_request_async( rid, repo_ref=repo, head=head, @@ -395,11 +408,19 @@ def _require_run(self, step_name: str) -> bool: return True def _run_step(self, card, work, after) -> None: + if self._job is not None and self._job.isRunning(): + QMessageBox.information( + self, "Step running", "Another step is still running. Wait for it to finish." + ) + return card.button.setEnabled(False) card.set_status("running...", "WorkerStatusRunning") async def _job(): - return work() + result = work() + if inspect.isawaitable(result): + result = await result + return result self._job = AsyncJob(_job) self._job.finished_ok.connect(after) @@ -408,14 +429,14 @@ async def _job(): def _on_brief_done(self, result) -> None: self._run_id = result.run_id - self.brief_card.set_status("done", "WorkerStatusDone") + self.brief_card.set_status(_done_label(result), "WorkerStatusDone") self.brief_card.button.setEnabled(True) self.run_banner.setText(f"Current run: {result.run_id}") self.artifact_created.emit(result.run_id, result.artifact_path) self._refresh_state() def _on_artifact_done(self, card, result) -> None: - card.set_status("done", "WorkerStatusDone") + card.set_status(_done_label(result), "WorkerStatusDone") card.button.setEnabled(True) self.artifact_created.emit(result.run_id, result.artifact_path) self._refresh_state() @@ -488,7 +509,9 @@ def _on_failed(self, card, error: str) -> None: ) def _check_github_auth(self) -> None: - status = self.engine.github_auth_status() + self._start_auth_check(self._on_github_auth_checked) + + def _on_github_auth_checked(self, status) -> None: self._update_github_auth_label(status) if not status.installed: QMessageBox.warning( @@ -510,9 +533,26 @@ def _check_github_auth(self) -> None: f"{status.details}\n\nRun:\n{status.login_command}", ) - def _update_github_auth_label(self, status=None) -> None: - if status is None: - status = self.engine.github_auth_status() + def _refresh_github_auth_label(self) -> None: + self._start_auth_check(self._update_github_auth_label) + + def _start_auth_check(self, on_done) -> None: + """Run `gh auth status` (a subprocess) off the UI thread.""" + if self._auth_job is not None and self._auth_job.isRunning(): + return + self.github_auth_status.setText("GitHub auth: checking...") + + async def _job(): + return self.engine.github_auth_status() + + self._auth_job = AsyncJob(_job) + self._auth_job.finished_ok.connect(on_done) + self._auth_job.failed.connect( + lambda _err: self.github_auth_status.setText("GitHub auth: unknown") + ) + self._auth_job.start() + + def _update_github_auth_label(self, status) -> None: if not status.installed: self.github_auth_status.setText("GitHub auth: gh missing") return @@ -524,7 +564,7 @@ def _update_github_auth_label(self, status=None) -> None: def _refresh_state(self) -> None: if self.engine.current_project() is None: return - self._update_github_auth_label() + self._refresh_github_auth_label() if self._run_id is None: latest = self.engine.latest_run_id() if latest is not None: @@ -595,8 +635,8 @@ def _mark(card, name): if not self.pr_head_input.text().strip(): try: - home = self.engine.workspace_home() - if home.current_branch: - self.pr_head_input.setText(home.current_branch) + suggested_branch = self.engine.suggest_pr_branch(self._run_id) + if suggested_branch: + self.pr_head_input.setText(suggested_branch) except Exception: pass diff --git a/src/parallel_agents/desktop/pages/projects_page.py b/src/parallel_agents/desktop/pages/projects_page.py index 8937a21..fafff09 100644 --- a/src/parallel_agents/desktop/pages/projects_page.py +++ b/src/parallel_agents/desktop/pages/projects_page.py @@ -55,11 +55,15 @@ def __init__(self, engine: EngineService) -> None: self.fix_setup_btn = QPushButton("Fix Setup") self.fix_setup_btn.clicked.connect(self._fix_setup) self.fix_setup_btn.setEnabled(False) + self.onboard_btn = QPushButton("Onboard") + self.onboard_btn.clicked.connect(self._onboard_project) + self.onboard_btn.setEnabled(False) actions.addWidget(open_btn) actions.addWidget(new_btn) actions.addWidget(self.doctor_btn) actions.addWidget(self.fix_setup_btn) + actions.addWidget(self.onboard_btn) actions.addStretch(1) self.body_layout.addLayout(actions) @@ -73,9 +77,27 @@ def __init__(self, engine: EngineService) -> None: self.current_meta.setStyleSheet("color: #8a90a2;") self.current_stats = QLabel("") self.current_stats.setStyleSheet("color: #8a90a2;") + self.gateway_meta = QLabel("Gateway: no project selected.") + self.gateway_meta.setStyleSheet("color: #8a90a2;") + gateway_actions = QHBoxLayout() + self.gateway_refresh_btn = QPushButton("Refresh Gateway") + self.gateway_refresh_btn.clicked.connect(self._refresh_gateway_status) + self.gateway_refresh_btn.setEnabled(False) + self.gateway_start_btn = QPushButton("Start Gateway") + self.gateway_start_btn.clicked.connect(self._start_gateway) + self.gateway_start_btn.setEnabled(False) + self.gateway_stop_btn = QPushButton("Stop Gateway") + self.gateway_stop_btn.clicked.connect(self._stop_gateway) + self.gateway_stop_btn.setEnabled(False) + gateway_actions.addWidget(self.gateway_refresh_btn) + gateway_actions.addWidget(self.gateway_start_btn) + gateway_actions.addWidget(self.gateway_stop_btn) + gateway_actions.addStretch(1) card_layout.addWidget(self.current_label) card_layout.addWidget(self.current_meta) card_layout.addWidget(self.current_stats) + card_layout.addWidget(self.gateway_meta) + card_layout.addLayout(gateway_actions) self.body_layout.addWidget(self.current_card) self.productivity_card = QFrame() @@ -300,6 +322,85 @@ def _fix_setup(self) -> None: message.exec() self._refresh(current) + def _onboard_project(self) -> None: + current = self.engine.current_project() + if current is None: + QMessageBox.information(self, "Onboard", "Open a project first.") + return + payload = self.engine.onboard_project() + next_actions = payload.get("next_actions") or [] + details = [ + f"- {item.get('label', 'Step')}: {item.get('command', '')}" + for item in next_actions + if isinstance(item, dict) + ] + message = QMessageBox(self) + message.setWindowTitle("Office Onboarding") + message.setText(f"Onboarding status: {payload.get('status', 'unknown')}") + message.setInformativeText( + "\n".join( + [ + f"Ready for local run: {payload.get('ready_for_local_run')}", + f"Ready for GitHub flow: {payload.get('ready_for_github_flow')}", + ] + ) + ) + message.setDetailedText("\n".join(details) if details else "No next actions.") + message.exec() + self._refresh(current) + + def _start_gateway(self) -> None: + current = self.engine.current_project() + if current is None: + QMessageBox.information(self, "Gateway", "Open a project first.") + return + try: + status = self.engine.start_gateway() + except Exception as exc: # noqa: BLE001 + QMessageBox.critical(self, "Gateway start failed", str(exc)) + return + self._render_gateway_status(status) + if not status.running: + QMessageBox.warning( + self, + "Gateway", + f"Gateway process started but is not healthy yet.\n\n{status.detail}", + ) + + def _stop_gateway(self) -> None: + current = self.engine.current_project() + if current is None: + QMessageBox.information(self, "Gateway", "Open a project first.") + return + status = self.engine.stop_gateway() + self._render_gateway_status(status) + if status.running and not status.owned: + QMessageBox.information( + self, + "Gateway", + "A gateway is still running, but it was not started by this desktop session.", + ) + + def _refresh_gateway_status(self) -> None: + current = self.engine.current_project() + if current is None: + self.gateway_meta.setText("Gateway: no project selected.") + self.gateway_refresh_btn.setEnabled(False) + self.gateway_start_btn.setEnabled(False) + self.gateway_stop_btn.setEnabled(False) + return + self._render_gateway_status(self.engine.gateway_status()) + + def _render_gateway_status(self, status) -> None: + state = "running" if status.running else status.source + owner = "desktop-owned" if status.owned else status.source + pid = f" pid={status.pid}" if status.pid else "" + detail = f" — {status.detail}" if status.detail else "" + self.gateway_meta.setText(f"Gateway: {state} ({owner}) {status.url}{pid}{detail}") + self.gateway_refresh_btn.setEnabled(True) + self.gateway_start_btn.setEnabled(not status.running) + self.gateway_stop_btn.setEnabled(status.owned) + def _clear_recent_projects(self) -> None: self.history.clear("project_root") self._refresh_recent_projects() @@ -318,6 +419,8 @@ def _refresh(self, info) -> None: + (f" | Branch: {home.current_branch}" if home.current_branch else "") ) self.fix_setup_btn.setEnabled(not home.diagnostics_healthy) + self.onboard_btn.setEnabled(True) + self._refresh_gateway_status() self._refresh_productivity() self.recent_list.clear() for run in self.engine.list_runs(): diff --git a/src/parallel_agents/desktop/pages/runs_page.py b/src/parallel_agents/desktop/pages/runs_page.py index 8e695db..9b1b7a0 100644 --- a/src/parallel_agents/desktop/pages/runs_page.py +++ b/src/parallel_agents/desktop/pages/runs_page.py @@ -26,6 +26,7 @@ def __init__(self, engine: EngineService) -> None: self.engine = engine self._job: AsyncJob | None = None self._active_workers: set[str] = set() + self._event_status_seen = False task_row = QHBoxLayout() task_row.addWidget(QLabel("Task:")) @@ -67,6 +68,7 @@ def _start_run(self) -> None: for role in self.worker_grid.tiles: self.worker_grid.update_worker(role, "idle", "Waiting") self._active_workers.clear() + self._event_status_seen = False self.start_btn.setEnabled(False) async def _job(): @@ -74,7 +76,15 @@ def _on_status(message: str) -> None: if self._job is not None: self._job.progress.emit({"message": message}) - return await self.engine.run_pipeline(task, on_status=_on_status) + def _on_event(event: dict) -> None: + if self._job is not None: + self._job.progress.emit({"event": event}) + + return await self.engine.run_pipeline( + task, + on_status=_on_status, + on_event=_on_event, + ) self._job = AsyncJob(_job) self._job.progress.connect(self._on_progress) @@ -83,11 +93,18 @@ def _on_status(message: str) -> None: self._job.start() def _on_progress(self, payload: dict) -> None: + event = payload.get("event") + if isinstance(event, dict): + self._event_status_seen = True + self._apply_worker_status_from_event(event) + return + message = str(payload.get("message", "")).strip() if not message: return self.activity.appendPlainText(message) - self._apply_worker_status_from_message(message) + if not self._event_status_seen: + self._apply_worker_status_from_message(message) def _on_finished(self, result) -> None: self.start_btn.setEnabled(True) @@ -113,6 +130,79 @@ def _on_failed(self, error: str) -> None: details=error, ) + def _apply_worker_status_from_event(self, event: dict) -> None: + agent = str(event.get("agent") or "").strip() + phase = str(event.get("phase") or "").strip() + status = str(event.get("status") or "").strip() + event_name = str(event.get("event") or "").strip() + + if agent == "pipeline": + if phase == "planning": + if status == "started": + self.worker_grid.update_worker("planner", "running", "Planning") + elif status == "completed": + subtask_count = event.get("subtask_count") + detail = ( + f"Plan completed ({subtask_count} subtasks)" + if subtask_count is not None + else "Plan completed" + ) + self.worker_grid.update_worker("planner", "done", detail) + return + if phase == "splitting" and status == "completed": + batch_count = event.get("batch_count") + detail = ( + f"Batches prepared ({batch_count})" + if batch_count is not None + else "Batches prepared" + ) + self.worker_grid.update_worker("splitter", "done", detail) + return + if phase == "execution": + if status == "started": + self.worker_grid.update_worker("splitter", "done", "Execution started") + self._active_workers.clear() + for role in _event_workers(event): + if role in self.worker_grid.tiles: + self.worker_grid.update_worker(role, "running", "Executing subtask") + self._active_workers.add(role) + elif status == "completed": + results = event.get("results") if isinstance(event.get("results"), dict) else {} + for role, result_status in results.items(): + mapped = _worker_grid_status(str(result_status)) + self.worker_grid.update_worker(role, mapped, f"Result: {result_status}") + self._active_workers.discard(str(role)) + return + if phase == "judging": + if status == "started": + for role in list(self._active_workers): + if role in self.worker_grid.tiles: + self.worker_grid.update_worker(role, "done", "Subtask complete") + self._active_workers.clear() + self.worker_grid.update_worker("judge", "running", "Merging results") + elif status == "completed": + self.worker_grid.update_worker("judge", "done", "Final output saved") + return + + if agent in self.worker_grid.tiles: + if event_name == "worker_started": + self.worker_grid.update_worker(agent, "running", "Executing subtask") + self._active_workers.add(agent) + return + if event_name == "worker_completed": + mapped = _worker_grid_status(status) + self.worker_grid.update_worker(agent, mapped, f"Result: {status or 'done'}") + self._active_workers.discard(agent) + return + if status: + mapped = _worker_grid_status(status) + findings = event.get("findings") + recommendations = event.get("recommendations") + detail = f"Result: {status}" + if findings is not None and recommendations is not None: + detail = f"{status}: {findings} findings, {recommendations} recommendations" + self.worker_grid.update_worker(agent, mapped, detail) + def _apply_worker_status_from_message(self, message: str) -> None: normalized = message.strip() if normalized.startswith("Planning:"): @@ -154,3 +244,19 @@ def _apply_worker_status_from_message(self, message: str) -> None: return if normalized.startswith("Done. Run ID:"): self.worker_grid.update_worker("judge", "done", "Final merge complete") + + +def _event_workers(event: dict) -> list[str]: + workers = event.get("workers") + if isinstance(workers, list): + return [str(item) for item in workers if str(item).strip()] + return [] + + +def _worker_grid_status(status: str) -> str: + normalized = status.strip().lower() + if normalized in {"error", "failed", "runtime_error", "parse_error", "worker_error"}: + return "error" + if normalized in {"started", "running"}: + return "running" + return "done" diff --git a/src/parallel_agents/desktop/pages/settings_page.py b/src/parallel_agents/desktop/pages/settings_page.py index edccf73..75f8437 100644 --- a/src/parallel_agents/desktop/pages/settings_page.py +++ b/src/parallel_agents/desktop/pages/settings_page.py @@ -3,7 +3,6 @@ import os from parallel_agents.desktop._qt import ( - QCheckBox, QComboBox, QFormLayout, QFrame, @@ -21,6 +20,21 @@ PERMISSION_MODE_CHOICES, ) +# Per-artifact LLM toggles, in workflow order, with display labels. +_LLM_ARTIFACTS: tuple[tuple[str, str], ...] = ( + ("BRIEF", "Brief"), + ("PRFAQ", "PR/FAQ"), + ("TECH_STACK", "Tech stack"), + ("RFC", "Architecture RFC"), + ("ROADMAP", "Roadmap"), + ("SPRINT", "Sprint"), +) + +# Tri-state: Default = let the policy decide (on when a key is present); +# On = force LLM; Off = force deterministic template. +_TRISTATE = ("Default", "On", "Off") +_TRISTATE_TO_VALUE = {"Default": "", "On": "1", "Off": "0"} + def _make_combo(choices: tuple[str, ...], editable: bool = True) -> QComboBox: combo = QComboBox() @@ -41,6 +55,13 @@ def _set_combo_value(combo: QComboBox, value: str) -> None: combo.setEditText(value) +def _env_to_tristate(name: str) -> str: + raw = os.environ.get(name) + if raw is None or not raw.strip(): + return "Default" + return "On" if raw.strip().lower() in {"1", "true", "yes", "on"} else "Off" + + class SettingsPage(Page): def __init__(self, engine: EngineService) -> None: super().__init__( @@ -68,7 +89,7 @@ def __init__(self, engine: EngineService) -> None: form.addRow("Planner model", self.planner_model) form.addRow("Judge model", self.judge_model) form.addRow("Permission mode", self.permission_mode) - form.addRow("LLM model (brief / PRFAQ / RFC)", self.llm_model) + form.addRow("LLM model (all generators)", self.llm_model) form.addRow("Gateway API key", self.gateway_key) layout.addLayout(form) @@ -79,19 +100,26 @@ def __init__(self, engine: EngineService) -> None: ) layout.addWidget(llm_header) - self.llm_brief = QCheckBox("Brief") - self.llm_prfaq = QCheckBox("PR/FAQ") - self.llm_rfc = QCheckBox("Architecture RFC") flag_hint = QLabel( - "Falls back to deterministic templates if disabled, the key is missing, or generation fails." + "Default = on when an ANTHROPIC_API_KEY is set, off otherwise. " + "On forces the LLM; Off forces deterministic templates. Any artifact " + "falls back to a template if the key is missing or generation fails." ) flag_hint.setStyleSheet("color: #8a90a2; font-size: 12px;") flag_hint.setWordWrap(True) - - for cb in (self.llm_brief, self.llm_prfaq, self.llm_rfc): - layout.addWidget(cb) layout.addWidget(flag_hint) + llm_form = QFormLayout() + llm_form.setVerticalSpacing(8) + self.llm_global = _make_combo(_TRISTATE, editable=False) + llm_form.addRow("All generators", self.llm_global) + self.llm_flags: dict[str, QComboBox] = {} + for key, label in _LLM_ARTIFACTS: + combo = _make_combo(_TRISTATE, editable=False) + self.llm_flags[key] = combo + llm_form.addRow(label, combo) + layout.addLayout(llm_form) + button_row = QHBoxLayout() button_row.addStretch(1) self.save_user_btn = QPushButton("Save to User") @@ -128,9 +156,9 @@ def _populate_from_env(self) -> None: ) _set_combo_value(self.llm_model, os.environ.get("PA_DESKTOP_LLM_MODEL", "")) self.gateway_key.setText(os.environ.get("PA_GATEWAY_API_KEY", "")) - self.llm_brief.setChecked(_truthy_env("PA_DESKTOP_LLM_BRIEF")) - self.llm_prfaq.setChecked(_truthy_env("PA_DESKTOP_LLM_PRFAQ")) - self.llm_rfc.setChecked(_truthy_env("PA_DESKTOP_LLM_RFC")) + _set_combo_value(self.llm_global, _env_to_tristate("PA_DESKTOP_LLM")) + for key, combo in self.llm_flags.items(): + _set_combo_value(combo, _env_to_tristate(f"PA_DESKTOP_LLM_{key}")) def _collect(self) -> dict[str, str]: values = { @@ -139,10 +167,12 @@ def _collect(self) -> dict[str, str]: "PA_PERMISSION_MODE": self.permission_mode.currentText().strip(), "PA_GATEWAY_API_KEY": self.gateway_key.text(), "PA_DESKTOP_LLM_MODEL": self.llm_model.currentText().strip(), - "PA_DESKTOP_LLM_BRIEF": "1" if self.llm_brief.isChecked() else "", - "PA_DESKTOP_LLM_PRFAQ": "1" if self.llm_prfaq.isChecked() else "", - "PA_DESKTOP_LLM_RFC": "1" if self.llm_rfc.isChecked() else "", + "PA_DESKTOP_LLM": _TRISTATE_TO_VALUE.get(self.llm_global.currentText(), ""), } + for key, combo in self.llm_flags.items(): + values[f"PA_DESKTOP_LLM_{key}"] = _TRISTATE_TO_VALUE.get( + combo.currentText(), "" + ) return {k: v for k, v in values.items() if k in KNOWN_KEYS} def _save_user(self) -> None: @@ -167,8 +197,3 @@ def _apply_env(values: dict[str, str]) -> None: os.environ[key] = value else: os.environ.pop(key, None) - - -def _truthy_env(name: str) -> bool: - value = os.environ.get(name, "").strip().lower() - return value in {"1", "true", "yes", "on"} diff --git a/src/parallel_agents/desktop/services/engine.py b/src/parallel_agents/desktop/services/engine.py index 931e2d2..6e4a9ac 100644 --- a/src/parallel_agents/desktop/services/engine.py +++ b/src/parallel_agents/desktop/services/engine.py @@ -7,7 +7,13 @@ from __future__ import annotations import json +import logging +import multiprocessing +import os import subprocess +import time +import urllib.error +import urllib.request import uuid from dataclasses import dataclass, field from datetime import datetime, timezone @@ -23,12 +29,14 @@ RoadmapPlan, TechStackDecision, build_architecture_rfc, + build_branch_name, build_issue_plan_from_roadmap, build_prfaq, render_pr_summary, build_roadmap, build_sprint_plan, create_product_brief, + issue_plan_items, recommend_tech_stack, ) from parallel_agents.eval_harness import ( @@ -41,6 +49,7 @@ compute_evaluation_score, summarize_evaluation_results, ) +from parallel_agents.onboarding import build_onboarding_report from parallel_agents.project_office import ( init_project_office, list_office_run_ids, @@ -69,6 +78,7 @@ class BriefResult: run_id: str artifact_path: Path approval_path: Path + provenance: dict[str, Any] = field(default_factory=dict) @dataclass @@ -77,6 +87,7 @@ class ArtifactResult: artifact: str artifact_path: Path approval_path: Path + provenance: dict[str, Any] = field(default_factory=dict) @dataclass @@ -107,6 +118,16 @@ class SetupFixResult: suggested_commands: list[str] = field(default_factory=list) +@dataclass +class GatewayLifecycleStatus: + url: str + running: bool + owned: bool + source: str + pid: int | None = None + detail: str = "" + + @dataclass class ProductivitySnapshot: generated_at: str | None @@ -244,6 +265,8 @@ class EngineService: def __init__(self) -> None: self._current_project: Path | None = None + self._gateway_process: Any | None = None + self._gateway_url_override: str | None = None # -- project -------------------------------------------------------- @@ -296,6 +319,118 @@ def fix_office_setup(self) -> SetupFixResult: suggested_commands=list(payload.get("suggested_commands") or []), ) + def onboard_project(self) -> dict[str, Any]: + root = self.require_project() + current = self.current_project() + return build_onboarding_report( + root, + name=current.name if current else None, + fix_setup=True, + check_github_auth=False, + ) + + def gateway_status(self) -> GatewayLifecycleStatus: + url = self._gateway_url_for_desktop() + process = self._gateway_process + owned_alive = bool(process is not None and process.is_alive()) + exited_detail = "" + if process is not None and not owned_alive: + exitcode = getattr(process, "exitcode", None) + if exitcode is not None: + exited_detail = f"Desktop-owned gateway exited with code {exitcode}." + self._gateway_process = None + process = None + + running, detail = _gateway_health(url) + if running: + process = self._gateway_process + owned_alive = bool(process is not None and process.is_alive()) + return GatewayLifecycleStatus( + url=url, + running=True, + owned=owned_alive, + source="desktop" if owned_alive else "external", + pid=int(getattr(process, "pid", 0) or 0) if owned_alive else None, + detail=detail, + ) + if owned_alive: + return GatewayLifecycleStatus( + url=url, + running=False, + owned=True, + source="starting", + pid=int(getattr(process, "pid", 0) or 0) or None, + detail=detail or "Gateway process started; waiting for health check.", + ) + return GatewayLifecycleStatus( + url=url, + running=False, + owned=False, + source="stopped", + detail=exited_detail or detail, + ) + + def start_gateway(self, *, host: str | None = None, port: int | None = None) -> GatewayLifecycleStatus: + root = self.require_project() + host = (host or os.environ.get("PA_DESKTOP_GATEWAY_HOST") or "127.0.0.1").strip() + port = int(port or os.environ.get("PA_DESKTOP_GATEWAY_PORT", "8733")) + url = _gateway_url_from_parts(host, port) + self._gateway_url_override = url + + current = self.gateway_status() + if current.running: + return current + + process = self._gateway_process + if process is None or not process.is_alive(): + process = _start_desktop_gateway_process( + output_dir=office_dir(root), + host=host, + port=port, + api_key=os.environ.get("PA_GATEWAY_API_KEY"), + jwt_secret=os.environ.get("PA_GATEWAY_JWT_SECRET"), + jwt_issuer=os.environ.get("PA_GATEWAY_JWT_ISSUER"), + jwt_audience=os.environ.get("PA_GATEWAY_JWT_AUDIENCE"), + allow_remote_write_tools=os.environ.get("PA_GATEWAY_ALLOW_REMOTE_WRITE_TOOLS"), + slack_signing_secret=os.environ.get("PA_GATEWAY_SLACK_SIGNING_SECRET"), + slack_allow_unsigned=os.environ.get("PA_GATEWAY_SLACK_ALLOW_UNSIGNED"), + ) + self._gateway_process = process + process.start() + + deadline = time.monotonic() + float(os.environ.get("PA_DESKTOP_GATEWAY_START_TIMEOUT", "8")) + while time.monotonic() < deadline: + status = self.gateway_status() + if status.running: + return status + process = self._gateway_process + if process is None or not process.is_alive(): + break + time.sleep(0.1) + return self.gateway_status() + + def stop_gateway(self) -> GatewayLifecycleStatus: + process = self._gateway_process + if process is None or not process.is_alive(): + return self.gateway_status() + + process.terminate() + process.join(timeout=4) + if process.is_alive() and hasattr(process, "kill"): + process.kill() + process.join(timeout=2) + self._gateway_process = None + return self.gateway_status() + + def _gateway_url_for_desktop(self) -> str: + return self._gateway_url_override or _desktop_gateway_url() or _gateway_url_from_parts( + os.environ.get("PA_DESKTOP_GATEWAY_HOST", "127.0.0.1"), + int(os.environ.get("PA_DESKTOP_GATEWAY_PORT", "8733")), + ) + + def _gateway_url_for_run(self) -> str | None: + return self._gateway_url_override or _desktop_gateway_url() + def _load_project_info(self, root: Path) -> ProjectInfo: payload = load_project_office(root) return ProjectInfo( @@ -313,14 +448,33 @@ async def run_pipeline( task: str, *, on_status: Callable[[str], None] | None = None, + on_event: Callable[[dict[str, Any]], None] | None = None, ) -> PipelineRunResult: root = self.require_project() + gateway_url = self._gateway_url_for_run() + if gateway_url and self._gateway_available(gateway_url): + try: + return self._run_pipeline_via_gateway( + task, + root=root, + gateway_url=gateway_url, + on_status=on_status, + on_event=on_event, + ) + except Exception as exc: # noqa: BLE001 - fallback keeps local desktop usable + if not _truthy(os.environ.get("PA_DESKTOP_GATEWAY_REQUIRED")): + if on_status: + on_status(f"Gateway unavailable ({exc}); falling back to in-process run.") + else: + raise + config = PipelineConfig(output_dir=str(office_output_dir(root))) pipeline = Pipeline(config) final_output = await pipeline.run( task, repo_path=str(root), on_status=on_status, + on_event=on_event, ) run_id = str(final_output.metadata.get("run_id", "")).strip() if not run_id: @@ -338,6 +492,12 @@ async def run_pipeline( "final-output", {"event": "created", "source": "desktop-runs", "task": task}, ) + if final_output.patch: + self._create_patch_review_approval( + run_id=run_id, + artifact_path=output_path, + summary=final_output.summary, + ) self._write_audit( run_id, { @@ -360,12 +520,140 @@ async def run_pipeline( }, ) + def _gateway_available(self, gateway_url: str) -> bool: + if _truthy(os.environ.get("PA_DESKTOP_USE_GATEWAY")): + return True + try: + _gateway_json_request(gateway_url, "GET", "/health", timeout_seconds=0.35) + return True + except Exception: # noqa: BLE001 - auto mode degrades to direct execution + return False + + def _run_pipeline_via_gateway( + self, + task: str, + *, + root: Path, + gateway_url: str, + on_status: Callable[[str], None] | None, + on_event: Callable[[dict[str, Any]], None] | None, + ) -> PipelineRunResult: + run_id = f"run-{datetime.now(timezone.utc).strftime('%Y%m%d-%H%M%S')}-{uuid.uuid4().hex[:6]}" + submitted = _gateway_json_request( + gateway_url, + "POST", + "/runs/pipeline", + { + "run_id": run_id, + "task": task, + "repo_path": str(root), + "wait": False, + "store_backend": os.environ.get("PA_STORE_BACKEND", "file"), + "permission_mode": os.environ.get("PA_PERMISSION_MODE", "default"), + }, + timeout_seconds=5.0, + ) + run_id = str(submitted.get("id") or run_id) + seen_event_ids: set[int] = set() + deadline = time.monotonic() + float(os.environ.get("PA_DESKTOP_GATEWAY_TIMEOUT", "1800")) + terminal = {"waiting_for_approval", "blocked_by_policy", "succeeded", "failed"} + run_payload = submitted + + while time.monotonic() < deadline: + events_payload = _gateway_json_request( + gateway_url, + "GET", + f"/runs/{run_id}/events", + timeout_seconds=5.0, + ) + for event in events_payload.get("events", []): + if not isinstance(event, dict): + continue + event_id = int(event.get("id") or 0) + if event_id in seen_event_ids: + continue + seen_event_ids.add(event_id) + event_name = str(event.get("event") or "") + payload = event.get("payload") or {} + if event_name == "pipeline_status" and on_status and isinstance(payload, dict): + message = str(payload.get("message") or "").strip() + if message: + on_status(message) + if event_name == "pipeline_trace" and on_event and isinstance(payload, dict): + on_event(payload) + + run_payload = _gateway_json_request( + gateway_url, + "GET", + f"/runs/{run_id}", + timeout_seconds=5.0, + ) + if str(run_payload.get("status")) in terminal: + break + time.sleep(0.25) + else: + raise TimeoutError(f"Gateway run timed out: {run_id}") + + if str(run_payload.get("status")) != "succeeded": + raise RuntimeError(run_payload.get("error_message") or f"Gateway run failed: {run_id}") + + payload = dict(run_payload.get("payload") or {}) + artifact_payload = payload.get("artifact") + if not isinstance(artifact_payload, dict): + artifact_response = _gateway_json_request( + gateway_url, + "GET", + f"/runs/{run_id}/artifacts/final-output", + timeout_seconds=5.0, + ) + artifact_payload = dict(artifact_response.get("artifact") or {}) + if not isinstance(artifact_payload, dict): + artifact_payload = {} + + output_path_text = str(payload.get("artifact_path") or "").strip() + output_path = Path(output_path_text) if output_path_text else ( + office_output_dir(root) / run_id / "company" / "final-output.json" + ) + metadata = artifact_payload.get("metadata") if isinstance(artifact_payload, dict) else {} + cost = metadata.get("cost", {}) if isinstance(metadata, dict) else {} + worker_results = artifact_payload.get("worker_results", {}) + worker_statuses = { + name: str(result.get("status", "unknown")) + for name, result in worker_results.items() + if isinstance(result, dict) + } if isinstance(worker_results, dict) else {} + + self._write_audit( + run_id, + { + "event": "run.execute", + "source": "desktop-gateway", + "task": task, + "repo": str(root), + "has_patch": bool(artifact_payload.get("patch")) if isinstance(artifact_payload, dict) else False, + }, + ) + if artifact_payload.get("patch") and output_path.exists(): + self._create_patch_review_approval( + run_id=run_id, + artifact_path=output_path, + summary=str(artifact_payload.get("summary") or payload.get("summary") or ""), + ) + return PipelineRunResult( + run_id=run_id, + summary=str(artifact_payload.get("summary") or payload.get("summary") or ""), + output_path=output_path, + total_tokens=int(cost.get("total_tokens", 0) or 0), + total_cost_usd=float(cost.get("total_cost_usd", 0.0) or 0.0), + worker_statuses=worker_statuses, + ) + # -- company workflows --------------------------------------------- def create_brief(self, idea: str, title: str | None = None) -> BriefResult: root = self.require_project() run_id = f"run-{datetime.now(timezone.utc).strftime('%Y%m%d-%H%M%S')}-{uuid.uuid4().hex[:6]}" - brief = self._build_brief(idea, title=title) + brief, provenance = self._build_brief(idea, title=title) result = self._persist_artifact_with_approval( run_id=run_id, artifact="brief", @@ -373,17 +661,19 @@ def create_brief(self, idea: str, title: str | None = None) -> BriefResult: title=brief.title, summary=brief.problem_statement, root=root, + provenance=provenance, ) return BriefResult( run_id=run_id, artifact_path=result.artifact_path, approval_path=result.approval_path, + provenance=provenance, ) def create_prfaq(self, run_id: str) -> ArtifactResult: root = self.require_project() brief = self._load_brief(run_id) - prfaq = self._build_prfaq(brief) + prfaq, provenance = self._build_prfaq(brief) return self._persist_artifact_with_approval( run_id=run_id, artifact="prfaq", @@ -391,12 +681,13 @@ def create_prfaq(self, run_id: str) -> ArtifactResult: title=prfaq.headline, summary=prfaq.press_release_summary, root=root, + provenance=provenance, ) def create_roadmap(self, run_id: str, horizon_weeks: int = 12) -> ArtifactResult: root = self.require_project() brief = self._load_brief(run_id) - roadmap = build_roadmap(brief, horizon_weeks=horizon_weeks) + roadmap, provenance = self._build_roadmap(brief, horizon_weeks) return self._persist_artifact_with_approval( run_id=run_id, artifact="roadmap", @@ -404,11 +695,13 @@ def create_roadmap(self, run_id: str, horizon_weeks: int = 12) -> ArtifactResult title=roadmap.name, summary=f"{len(roadmap.items)} items across {roadmap.horizon_weeks} weeks", root=root, + provenance=provenance, ) def create_tech_stack(self, run_id: str, repo_path: str | Path) -> ArtifactResult: root = self.require_project() - stack = recommend_tech_stack(repo_path) + brief = self._load_brief(run_id) + stack, provenance = self._build_tech_stack(brief, repo_path) return self._persist_artifact_with_approval( run_id=run_id, artifact="tech-stack", @@ -416,13 +709,14 @@ def create_tech_stack(self, run_id: str, repo_path: str | Path) -> ArtifactResul title=stack.recommended_option, summary=stack.rationale, root=root, + provenance=provenance, ) def create_rfc(self, run_id: str) -> ArtifactResult: root = self.require_project() brief = self._load_brief(run_id) stack = self._load_artifact(run_id, "tech-stack", TechStackDecision) - rfc = self._build_rfc(brief, stack) + rfc, provenance = self._build_rfc(brief, stack) return self._persist_artifact_with_approval( run_id=run_id, artifact="rfc", @@ -430,12 +724,13 @@ def create_rfc(self, run_id: str) -> ArtifactResult: title=rfc.title, summary=rfc.decision, root=root, + provenance=provenance, ) def create_sprint(self, run_id: str, milestone: str) -> ArtifactResult: root = self.require_project() roadmap = self._load_artifact(run_id, "roadmap", RoadmapPlan) - sprint = build_sprint_plan(roadmap, milestone=milestone) + sprint, provenance = self._build_sprint(roadmap, milestone) return self._persist_artifact_with_approval( run_id=run_id, artifact="sprint", @@ -443,6 +738,7 @@ def create_sprint(self, run_id: str, milestone: str) -> ArtifactResult: title=sprint.name, summary=f"{len(sprint.items)} items, milestone {sprint.milestone}", root=root, + provenance=provenance, ) # -- github plan / apply (slice 2) --------------------------------- @@ -451,10 +747,14 @@ def create_issue_plan(self, run_id: str, repo: str) -> ArtifactResult: root = self.require_project() roadmap = self._load_artifact(run_id, "roadmap", RoadmapPlan) planned = build_issue_plan_from_roadmap(roadmap) + planned_dump = [item.model_dump(mode="json") for item in planned] payload = { "repo": repo, "issue_count": len(planned), - "issues": [item.model_dump(mode="json") for item in planned], + # Emit both keys so the artifact matches the network surfaces and a + # plan made here applies cleanly from the CLI/gateway/MCP too. + "issues": planned_dump, + "issue_plan": planned_dump, "requires_approval": True, "approved": False, } @@ -468,9 +768,16 @@ def create_issue_plan(self, run_id: str, repo: str) -> ArtifactResult: ) def apply_issue_plan(self, run_id: str, *, dry_run: bool = True) -> dict[str, Any]: - """Apply an issue plan to GitHub. Live writes require `gh` to be authenticated.""" + """Sync wrapper for headless/CLI callers. UI code must use + apply_issue_plan_async via AsyncJob (this raises inside a running loop).""" import asyncio + return asyncio.run(self.apply_issue_plan_async(run_id, dry_run=dry_run)) + + async def apply_issue_plan_async( + self, run_id: str, *, dry_run: bool = True + ) -> dict[str, Any]: + """Apply an issue plan to GitHub. Live writes require `gh` to be authenticated.""" from parallel_agents.tools.github_apply import execute_company_issue_plan from parallel_agents.tools.github_tools import parse_repo_ref @@ -478,7 +785,8 @@ def apply_issue_plan(self, run_id: str, *, dry_run: bool = True) -> dict[str, An approval = self._find_approval(run_id, artifact="issue-plan") if approval is None: raise FileNotFoundError("No issue-plan approval found for this run.") - if approval["data"].get("status") != "approved": + approval_data = approval["data"] + if approval_data.get("status") != "approved": raise PermissionError( "Issue plan must be approved before apply. " "Approve it on the Approvals page first." @@ -487,7 +795,28 @@ def apply_issue_plan(self, run_id: str, *, dry_run: bool = True) -> dict[str, An plan_path = office_output_dir(root) / run_id / "company" / "issue-plan.json" if not plan_path.exists(): raise FileNotFoundError(f"Issue-plan artifact missing: {plan_path}") - plan_payload = json.loads(plan_path.read_text(encoding="utf-8")) + + # Bind the approval to the exact bytes that were approved: if the plan was + # regenerated or edited after approval, the digests differ and apply is + # refused (closes the approve-then-swap TOCTOU window). Read the bytes + # exactly once and parse that same buffer, so the verified bytes ARE the + # applied bytes (no second read that an attacker could swap in between). + approved_digest = approval_data.get("artifact_sha256") + if not approved_digest: + raise PermissionError( + "Approval has no bound artifact digest (legacy or missing-file " + "approval); re-approve the issue plan before applying." + ) + import hashlib + + raw = plan_path.read_bytes() + current_digest = hashlib.sha256(raw).hexdigest() + if current_digest != approved_digest: + raise PermissionError( + "Issue plan changed since approval; re-approve before applying." + ) + + plan_payload = json.loads(raw.decode("utf-8")) repo_ref = plan_payload.get("repo", "") parsed = parse_repo_ref(str(repo_ref)) @@ -495,15 +824,13 @@ def apply_issue_plan(self, run_id: str, *, dry_run: bool = True) -> dict[str, An raise ValueError(f"Issue-plan repo ref is invalid: {repo_ref!r}") owner, repo = parsed - issues = plan_payload.get("issues", []) - result = asyncio.run( - execute_company_issue_plan( - owner=owner, - repo=repo, - issue_plan=issues, - create_milestones=True, - dry_run=dry_run, - ) + issues = issue_plan_items(plan_payload) + result = await execute_company_issue_plan( + owner=owner, + repo=repo, + issue_plan=issues, + create_milestones=True, + dry_run=dry_run, ) result["run_id"] = run_id result["mode"] = "dry-run" if dry_run else "live" @@ -536,8 +863,31 @@ def create_pull_request( title: str | None = None, draft: bool = True, ) -> PullRequestResult: + """Sync wrapper for headless/CLI callers. UI code must use + create_pull_request_async via AsyncJob (this raises inside a running loop).""" import asyncio + return asyncio.run( + self.create_pull_request_async( + run_id, + repo_ref=repo_ref, + head=head, + base=base, + title=title, + draft=draft, + ) + ) + + async def create_pull_request_async( + self, + run_id: str, + *, + repo_ref: str, + head: str, + base: str = "main", + title: str | None = None, + draft: bool = True, + ) -> PullRequestResult: from parallel_agents.tools.github_tools import create_pr, parse_repo_ref root = self.require_project() @@ -550,6 +900,7 @@ def create_pull_request( clean_base = base.strip() or "main" if not clean_head: raise ValueError("head branch cannot be empty.") + self._assert_patch_review_approved(run_id) resolved_title = (title or "").strip() or f"Parallel Agents Office: {run_id}" summary_markdown = self._build_pr_summary(run_id) @@ -557,16 +908,14 @@ def create_pull_request( summary_path.parent.mkdir(parents=True, exist_ok=True) summary_path.write_text(summary_markdown, encoding="utf-8") - pr_url = asyncio.run( - create_pr( - owner, - repo, - resolved_title, - summary_markdown, - head=clean_head, - base=clean_base, - draft=draft, - ) + pr_url = await create_pr( + owner, + repo, + resolved_title, + summary_markdown, + head=clean_head, + base=clean_base, + draft=draft, ) if not pr_url: raise RuntimeError( @@ -620,6 +969,7 @@ def github_auth_status(self) -> GitHubAuthStatus: check=False, capture_output=True, text=True, + timeout=15, ) except FileNotFoundError: return GitHubAuthStatus( @@ -973,6 +1323,23 @@ def roadmap_milestones(self, run_id: str | None) -> list[str]: seen.append(ms) return seen + def suggest_pr_branch(self, run_id: str | None) -> str: + root = self.require_project() + current_branch = self._git_current_branch(root) + if current_branch and current_branch not in {"main", "master", "HEAD"}: + return current_branch + + title = "" + if run_id: + brief_payload = self._load_artifact_payload(run_id, "brief") or {} + title = str(brief_payload.get("title") or "").strip() + if not title: + roadmap_payload = self._load_artifact_payload(run_id, "roadmap") or {} + title = str(roadmap_payload.get("name") or "").strip() + if not title: + title = "parallel agents office update" + return build_branch_name(run_id or "run", title, prefix="pa") + def _load_brief(self, run_id: str) -> ProductBrief: return self._load_artifact(run_id, "brief", ProductBrief) @@ -1041,41 +1408,73 @@ def _find_approval(self, run_id: str, *, artifact: str) -> dict[str, Any] | None # -- LLM-backed generation (with deterministic fallback) ----------- - def _build_brief(self, idea: str, *, title: str | None) -> ProductBrief: - import os - - if not _truthy(os.environ.get("PA_DESKTOP_LLM_BRIEF")): + def _build_brief(self, idea: str, *, title: str | None): + def _fallback(): return create_product_brief(idea, title=title) - try: + + def _llm(): from parallel_agents.desktop.services.llm_brief import generate_llm_brief return generate_llm_brief(idea, title=title) - except Exception: - return create_product_brief(idea, title=title) - def _build_prfaq(self, brief: ProductBrief): - import os + return _generate_with_provenance("BRIEF", _llm, _fallback) - if not _truthy(os.environ.get("PA_DESKTOP_LLM_PRFAQ")): + def _build_prfaq(self, brief: ProductBrief): + def _fallback(): return build_prfaq(brief) - try: + + def _llm(): from parallel_agents.desktop.services.llm_prfaq import generate_llm_prfaq return generate_llm_prfaq(brief) - except Exception: - return build_prfaq(brief) - def _build_rfc(self, brief: ProductBrief, stack: TechStackDecision): - import os + return _generate_with_provenance("PRFAQ", _llm, _fallback) - if not _truthy(os.environ.get("PA_DESKTOP_LLM_RFC")): + def _build_tech_stack(self, brief: ProductBrief, repo_path: str | Path): + def _fallback(): + return recommend_tech_stack(repo_path) + + def _llm(): + from parallel_agents.desktop.services.llm_tech_stack import ( + generate_llm_tech_stack, + ) + + return generate_llm_tech_stack(brief, repo_path) + + return _generate_with_provenance("TECH_STACK", _llm, _fallback) + + def _build_rfc(self, brief: ProductBrief, stack: TechStackDecision): + def _fallback(): return build_architecture_rfc(brief, stack) - try: + + def _llm(): from parallel_agents.desktop.services.llm_rfc import generate_llm_rfc return generate_llm_rfc(brief, stack) - except Exception: - return build_architecture_rfc(brief, stack) + + return _generate_with_provenance("RFC", _llm, _fallback) + + def _build_roadmap(self, brief: ProductBrief, horizon_weeks: int): + def _fallback(): + return build_roadmap(brief, horizon_weeks=horizon_weeks) + + def _llm(): + from parallel_agents.desktop.services.llm_roadmap import generate_llm_roadmap + + return generate_llm_roadmap(brief, horizon_weeks=horizon_weeks) + + return _generate_with_provenance("ROADMAP", _llm, _fallback) + + def _build_sprint(self, roadmap: RoadmapPlan, milestone: str): + def _fallback(): + return build_sprint_plan(roadmap, milestone=milestone) + + def _llm(): + from parallel_agents.desktop.services.llm_sprint import generate_llm_sprint + + return generate_llm_sprint(roadmap, milestone=milestone) + + return _generate_with_provenance("SPRINT", _llm, _fallback) def _persist_artifact_with_approval( self, @@ -1086,15 +1485,14 @@ def _persist_artifact_with_approval( title: str, summary: str, root: Path, + provenance: dict[str, Any] | None = None, ) -> ArtifactResult: output_dir = office_output_dir(root) artifact_path = persist_company_artifact(output_dir, run_id, artifact, payload) - append_company_artifact_event( - output_dir, - run_id, - artifact, - {"event": "created", "source": "desktop", "title": title}, - ) + event: dict[str, Any] = {"event": "created", "source": "desktop", "title": title} + if provenance: + event["provenance"] = provenance + append_company_artifact_event(output_dir, run_id, artifact, event) approval_path = self._create_pending_approval( run_id=run_id, artifact=artifact, @@ -1107,6 +1505,7 @@ def _persist_artifact_with_approval( artifact=artifact, artifact_path=artifact_path, approval_path=approval_path, + provenance=provenance or {}, ) # -- runs / artifacts ---------------------------------------------- @@ -1154,6 +1553,54 @@ def reject(self, approval_path: Path, approver: str, reason: str = "") -> dict[s approval_path, decision="rejected", actor=approver, message=reason ) + def _create_patch_review_approval( + self, + *, + run_id: str, + artifact_path: Path, + summary: str, + ) -> Path | None: + root = self.require_project() + try: + artifact_relpath = str(artifact_path.relative_to(office_dir(root))) + except ValueError: + return None + return self._create_pending_approval( + run_id=run_id, + artifact="final-output", + title="Review generated patch before PR", + summary=summary or "Generated patch requires review before PR creation.", + artifact_relpath=artifact_relpath, + ) + + def _assert_patch_review_approved(self, run_id: str) -> None: + root = self.require_project() + final_output = self._load_artifact_payload(run_id, "final-output") + if not final_output or not final_output.get("patch"): + return + approval = self._find_approval(run_id, artifact="final-output") + if approval is None: + raise PermissionError( + "Generated patch must be reviewed before PR creation. " + "Open Approvals and approve the final-output artifact first." + ) + data = approval.get("data", {}) + if data.get("status") != "approved": + raise PermissionError( + "Generated patch is not approved. " + "Approve the final-output artifact before creating a PR." + ) + artifact_relpath = str(data.get("artifact_path") or "").strip() + approved_digest = str(data.get("artifact_sha256") or "").strip() + current_digest = ( + self._artifact_digest(root, artifact_relpath) if artifact_relpath else None + ) + if approved_digest and current_digest and approved_digest != current_digest: + raise PermissionError( + "Generated patch changed after approval. " + "Re-approve the final-output artifact before creating a PR." + ) + def _create_pending_approval( self, *, @@ -1168,11 +1615,33 @@ def _create_pending_approval( approvals_dir.mkdir(parents=True, exist_ok=True) approval_id = f"{run_id}-{artifact}" path = approvals_dir / f"{approval_id}.json" + + # If a decision already existed for this artifact (regeneration), record + # an explicit supersede event rather than silently voiding the decision. + if path.exists(): + try: + prior = json.loads(path.read_text(encoding="utf-8")) + except json.JSONDecodeError: + prior = {} + if prior.get("status") in {"approved", "rejected"}: + self._write_audit( + run_id, + { + "event": "approval.superseded", + "approval_id": approval_id, + "artifact": artifact, + "previous_status": prior.get("status"), + "previous_decided_by": prior.get("decided_by"), + }, + ) + + artifact_sha256 = self._artifact_digest(root, artifact_relpath) payload = { "approval_id": approval_id, "run_id": run_id, "artifact": artifact, "artifact_path": artifact_relpath, + "artifact_sha256": artifact_sha256, "title": title, "summary": summary, "status": "pending", @@ -1181,10 +1650,24 @@ def _create_pending_approval( path.write_text(json.dumps(payload, indent=2), encoding="utf-8") self._write_audit( run_id, - {"event": "approval.created", "approval_id": approval_id, "artifact": artifact}, + { + "event": "approval.created", + "approval_id": approval_id, + "artifact": artifact, + "artifact_sha256": artifact_sha256, + }, ) return path + @staticmethod + def _artifact_digest(root: Path, artifact_relpath: str) -> str | None: + import hashlib + + path = office_dir(root) / artifact_relpath + if not path.exists(): + return None + return hashlib.sha256(path.read_bytes()).hexdigest() + def _decide_approval( self, approval_path: Path, *, decision: str, actor: str, message: str ) -> dict[str, Any]: @@ -1251,9 +1734,16 @@ def list_audit_events( if not line.strip(): continue try: - payload = json.loads(line) + entry = json.loads(line) except json.JSONDecodeError: continue + # Hash-chained entries wrap the event under "payload"; flatten it so + # callers see the same top-level fields (run_id, event, ...) as before. + inner = entry.get("payload") + if isinstance(inner, dict): + payload = {"timestamp": entry.get("timestamp"), **inner} + else: + payload = entry # tolerate any legacy unchained lines if run_id and str(payload.get("run_id", "")) != run_id: continue if approval_id and str(payload.get("approval_id", "")) != approval_id: @@ -1264,13 +1754,64 @@ def list_audit_events( return events def _write_audit(self, run_id: str, event: dict[str, Any]) -> None: + """Append a governance event to the workspace-wide, hash-chained log. + + Approval/apply/PR/run events are the governance-relevant ones; chaining + them here makes the 'tamper-evident audit' claim true for them (they were + previously written as plain, unchained JSONL). + """ + from parallel_agents.company_artifacts import append_hash_chained_line + root = self.require_project() - audit_dir = office_dir(root) / "audit" - audit_dir.mkdir(parents=True, exist_ok=True) - record = {"timestamp": _iso_now(), "run_id": run_id, **event} - with (audit_dir / "events.jsonl").open("a", encoding="utf-8") as fh: - fh.write(json.dumps(record)) - fh.write("\n") + log_path = office_dir(root) / "audit" / "events.jsonl" + append_hash_chained_line(log_path, {"run_id": run_id, **event}) + + def verify_audit_chains(self, run_id: str | None = None) -> dict[str, Any]: + """Recompute the governance log and per-run artifact chains. + + Returns a structured report; ``ok`` is True only if every chain verifies. + """ + from parallel_agents.company_artifacts import ( + verify_hash_chain, + verify_run_audit_chains, + ) + + root = self.require_project() + central = verify_hash_chain( + office_dir(root) / "audit" / "events.jsonl", "governance-log" + ) + run_ids = [run_id] if run_id else [r["id"] for r in self.list_runs()] + runs: list[dict[str, Any]] = [] + all_ok = central.ok + for rid in run_ids: + rv = verify_run_audit_chains(office_output_dir(root), rid) + all_ok = all_ok and rv.ok + runs.append( + { + "run_id": rid, + "ok": rv.ok, + "chains": [ + { + "artifact": c.artifact_name, + "ok": c.ok, + "entries": c.entry_count, + "reason": c.reason, + "broken_index": c.broken_index, + } + for c in rv.chains + ], + } + ) + return { + "ok": all_ok, + "governance_log": { + "ok": central.ok, + "entries": central.entry_count, + "reason": central.reason, + "broken_index": central.broken_index, + }, + "runs": runs, + } @staticmethod def _git_current_branch(root: Path) -> str | None: @@ -1525,10 +2066,157 @@ def _iso_now() -> str: return datetime.now(timezone.utc).isoformat(timespec="seconds") +def _gateway_url_from_parts(host: str, port: int) -> str: + return f"http://{host.strip()}:{int(port)}" + + +def _desktop_gateway_url() -> str | None: + explicit = os.environ.get("PA_DESKTOP_GATEWAY_URL") + if explicit: + return explicit.rstrip("/") + mode = os.environ.get("PA_DESKTOP_USE_GATEWAY", "auto").strip().lower() + if mode in {"0", "false", "no", "off"}: + return None + return "http://127.0.0.1:8733" + + +def _gateway_health(gateway_url: str) -> tuple[bool, str]: + try: + payload = _gateway_json_request(gateway_url, "GET", "/health", timeout_seconds=0.5) + except Exception as exc: # noqa: BLE001 - status must degrade to a display string + return False, str(exc) + status = str(payload.get("status") or "").strip() + if status == "ok": + return True, "Gateway health check passed." + return False, f"Gateway health check returned status {status or 'unknown'}." + + +def _start_desktop_gateway_process( + *, + output_dir: Path, + host: str, + port: int, + api_key: str | None, + jwt_secret: str | None, + jwt_issuer: str | None, + jwt_audience: str | None, + allow_remote_write_tools: str | None, + slack_signing_secret: str | None, + slack_allow_unsigned: str | None, +): + multiprocessing.freeze_support() + context = multiprocessing.get_context("spawn") + return context.Process( + target=_run_desktop_gateway_process, + kwargs={ + "output_dir": str(output_dir), + "host": host, + "port": int(port), + "api_key": api_key, + "jwt_secret": jwt_secret, + "jwt_issuer": jwt_issuer, + "jwt_audience": jwt_audience, + "allow_remote_write_tools": allow_remote_write_tools, + "slack_signing_secret": slack_signing_secret, + "slack_allow_unsigned": slack_allow_unsigned, + }, + daemon=True, + name="parallel-agents-desktop-gateway", + ) + + +def _run_desktop_gateway_process( + *, + output_dir: str, + host: str, + port: int, + api_key: str | None, + jwt_secret: str | None, + jwt_issuer: str | None, + jwt_audience: str | None, + allow_remote_write_tools: str | None, + slack_signing_secret: str | None, + slack_allow_unsigned: str | None, +) -> None: + from parallel_agents.gateway import run_gateway_server + + run_gateway_server( + host=host, + port=port, + output_dir=output_dir, + api_key=api_key, + jwt_secret=jwt_secret, + jwt_issuer=jwt_issuer, + jwt_audience=jwt_audience, + allow_remote_write_tools=allow_remote_write_tools, + slack_signing_secret=slack_signing_secret, + slack_allow_unsigned=slack_allow_unsigned, + ) + + +def _gateway_json_request( + base_url: str, + method: str, + path: str, + payload: dict[str, Any] | None = None, + *, + timeout_seconds: float, +) -> dict[str, Any]: + url = f"{base_url.rstrip('/')}/{path.lstrip('/')}" + data = None + headers = {"Accept": "application/json"} + if payload is not None: + data = json.dumps(payload).encode("utf-8") + headers["Content-Type"] = "application/json" + api_key = os.environ.get("PA_GATEWAY_API_KEY") + if api_key: + headers["X-PA-API-Key"] = api_key + request = urllib.request.Request(url, data=data, method=method.upper(), headers=headers) + try: + with urllib.request.urlopen(request, timeout=timeout_seconds) as response: + body = response.read().decode("utf-8") + except urllib.error.HTTPError as exc: + detail = exc.read().decode("utf-8", errors="replace") + raise RuntimeError(f"Gateway HTTP {exc.code}: {detail}") from exc + if not body.strip(): + return {} + parsed = json.loads(body) + if not isinstance(parsed, dict): + raise RuntimeError("Gateway returned a non-object JSON response.") + return parsed + + def _truthy(value: str | None) -> bool: return bool(value) and value.strip().lower() in {"1", "true", "yes", "on"} +def _generate_with_provenance(artifact_key: str, llm_fn, fallback_fn): + """Run the LLM generator when enabled, else the deterministic builder. + + Returns ``(artifact, provenance)`` where provenance records whether the LLM + or the template produced it (and the model / failure reason). LLM failures + are logged and degrade to the template — never silently swallowed. + """ + from parallel_agents.desktop.services import llm_config + + if not llm_config.llm_enabled(artifact_key): + reason = "disabled" if llm_config.llm_available() else "no-api-key" + return fallback_fn(), {"generator": "template", "reason": reason} + try: + artifact, model = llm_fn() + return artifact, {"generator": "llm", "model": model} + except Exception as exc: # noqa: BLE001 - any LLM failure falls back + logging.getLogger("parallel_agents.desktop.engine").warning( + "LLM generation for %s failed (%s); using deterministic fallback.", + artifact_key, + exc, + ) + return fallback_fn(), { + "generator": "template", + "reason": f"llm-error: {type(exc).__name__}", + } + + def _metric_delta(candidate: float | None, baseline: float | None) -> float | None: if candidate is None or baseline is None: return None diff --git a/src/parallel_agents/desktop/services/llm.py b/src/parallel_agents/desktop/services/llm.py index 7d05052..e073948 100644 --- a/src/parallel_agents/desktop/services/llm.py +++ b/src/parallel_agents/desktop/services/llm.py @@ -1,48 +1,100 @@ -"""Shared LLM client used by llm_brief / llm_prfaq / llm_rfc generators. +"""Shared Anthropic client for the desktop artifact generators. -Every call is wrapped in tight error handling at the generator level so a -missing key or SDK or a malformed response falls back to the deterministic -artifact without surfacing to the UI. +Every generator forces structured output via tool-use, so the model returns a +schema-validated dict — no regex JSON extraction and no `list("string")` +character-explosion bug. Each call has a short timeout and a single retry; any +failure raises RuntimeError and the caller falls back to the deterministic +builder. """ from __future__ import annotations -import json +import logging import os -import re +logger = logging.getLogger("parallel_agents.desktop.llm") -def call_anthropic(prompt: str, *, max_tokens: int = 1500, model_env: str = "PA_DESKTOP_BRIEF_MODEL") -> str: +# Sonnet is the right tier for these small (~2KB) structured generations: +# fast and inexpensive, with strong instruction-following. Override with +# PA_DESKTOP_LLM_MODEL. +DEFAULT_MODEL = "claude-sonnet-4-6" +_TIMEOUT_SECONDS = 45.0 + + +def resolve_model() -> str: + return ( + os.environ.get("PA_DESKTOP_LLM_MODEL") + or os.environ.get("PA_DESKTOP_BRIEF_MODEL") + or DEFAULT_MODEL + ) + + +def call_anthropic_tool( + prompt: str, + *, + tool_name: str, + tool_description: str, + input_schema: dict, + max_tokens: int = 2000, +) -> tuple[dict, str]: + """Force the model to emit a structured object via tool-use. + + Returns ``(validated_input_dict, model_id)``. Raises RuntimeError on any + failure (missing key, missing SDK, network/API error, or no tool_use block). + """ api_key = os.environ.get("ANTHROPIC_API_KEY") if not api_key: raise RuntimeError("ANTHROPIC_API_KEY not set") try: from anthropic import Anthropic - except ImportError as exc: + except ImportError as exc: # pragma: no cover - depends on optional dep raise RuntimeError("anthropic SDK not installed") from exc - client = Anthropic(api_key=api_key) - model = os.environ.get(model_env) or os.environ.get( - "PA_DESKTOP_LLM_MODEL", "claude-sonnet-4-6" + model = resolve_model() + client = Anthropic(api_key=api_key).with_options( + timeout=_TIMEOUT_SECONDS, max_retries=1 ) response = client.messages.create( model=model, max_tokens=max_tokens, + tools=[ + { + "name": tool_name, + "description": tool_description, + "input_schema": input_schema, + } + ], + tool_choice={"type": "tool", "name": tool_name}, messages=[{"role": "user", "content": prompt}], ) - parts = [b.text for b in response.content if getattr(b, "type", "") == "text"] - if not parts: - raise RuntimeError("Empty response from Anthropic API") - return "".join(parts) + for block in response.content: + if getattr(block, "type", "") == "tool_use" and getattr(block, "name", "") == tool_name: + data = block.input + if isinstance(data, dict): + return data, model + raise RuntimeError("Model did not return the expected structured output") + +def str_list(value, fallback) -> list[str]: + """Coerce to list[str] WITHOUT exploding a bare string into characters. -def extract_json(text: str) -> dict: - text = text.strip() - if text.startswith("```"): - text = re.sub(r"^```[a-zA-Z]*", "", text).rstrip("`").strip() - return json.loads(text) + The old ``list(payload.get(...))`` pattern turned a stray string like + "increase adoption" into ['i','n','c',...]; this never does that. + """ + if isinstance(value, list): + cleaned = [ + str(v).strip() + for v in value + if v is not None and str(v).strip() + ] + return cleaned or list(fallback) + if isinstance(value, str) and value.strip(): + return [value.strip()] + return list(fallback) -def truthy(value: str | None) -> bool: - return bool(value) and value.strip().lower() in {"1", "true", "yes", "on"} +def text_or(value, fallback: str) -> str: + if isinstance(value, str) and value.strip(): + return value.strip() + return str(fallback) diff --git a/src/parallel_agents/desktop/services/llm_brief.py b/src/parallel_agents/desktop/services/llm_brief.py index afa0fc9..b2786c1 100644 --- a/src/parallel_agents/desktop/services/llm_brief.py +++ b/src/parallel_agents/desktop/services/llm_brief.py @@ -1,44 +1,52 @@ -"""LLM-backed ProductBrief generation. Falls back to deterministic on any error.""" +"""LLM-backed ProductBrief generation via structured tool-use output.""" from __future__ import annotations from parallel_agents.company_workflows import ProductBrief, create_product_brief -from parallel_agents.desktop.services.llm import call_anthropic, extract_json - -PROMPT_TEMPLATE = """You are the product agent in an AI software office. - -Produce a ProductBrief for the following idea. Respond with a single JSON object -matching this schema (no prose, no markdown fences): - -{{ - "title": str, - "problem_statement": str, - "target_users": [str, ...], - "goals": [str, ...], - "non_goals": [str, ...], - "success_metrics": [str, ...], - "assumptions": [str, ...], - "unknowns": [str, ...] -}} +from parallel_agents.desktop.services.llm import call_anthropic_tool, str_list, text_or + +_SCHEMA = { + "type": "object", + "properties": { + "title": {"type": "string"}, + "problem_statement": {"type": "string"}, + "target_users": {"type": "array", "items": {"type": "string"}}, + "goals": {"type": "array", "items": {"type": "string"}}, + "non_goals": {"type": "array", "items": {"type": "string"}}, + "success_metrics": {"type": "array", "items": {"type": "string"}}, + "assumptions": {"type": "array", "items": {"type": "string"}}, + "unknowns": {"type": "array", "items": {"type": "string"}}, + }, + "required": ["title", "problem_statement", "goals"], +} + +_PROMPT = """You are the product agent in an AI software office. +Produce a focused ProductBrief for this idea. Be specific to the idea — do not +return generic boilerplate. Idea: {idea} -Hint title (use if reasonable): {title_hint} +Suggested title (use if reasonable): {title_hint} """ -def generate_llm_brief(idea: str, *, title: str | None = None) -> ProductBrief: - raw = call_anthropic(PROMPT_TEMPLATE.format(idea=idea, title_hint=title or "(none)")) - payload = extract_json(raw) - base = create_product_brief(idea, title=payload.get("title") or title) - return ProductBrief( +def generate_llm_brief(idea: str, *, title: str | None = None) -> tuple[ProductBrief, str]: + payload, model = call_anthropic_tool( + _PROMPT.format(idea=idea, title_hint=title or "(none)"), + tool_name="emit_product_brief", + tool_description="Return the structured ProductBrief for the idea.", + input_schema=_SCHEMA, + ) + base = create_product_brief(idea, title=text_or(payload.get("title"), title or "")) + brief = ProductBrief( id=base.id, - title=payload.get("title") or title or base.title, + title=text_or(payload.get("title"), title or base.title), idea=idea, - problem_statement=str(payload.get("problem_statement") or base.problem_statement), - target_users=list(payload.get("target_users") or base.target_users), - goals=list(payload.get("goals") or base.goals), - non_goals=list(payload.get("non_goals") or base.non_goals), - success_metrics=list(payload.get("success_metrics") or base.success_metrics), - assumptions=list(payload.get("assumptions") or base.assumptions), - unknowns=list(payload.get("unknowns") or base.unknowns), + problem_statement=text_or(payload.get("problem_statement"), base.problem_statement), + target_users=str_list(payload.get("target_users"), base.target_users), + goals=str_list(payload.get("goals"), base.goals), + non_goals=str_list(payload.get("non_goals"), base.non_goals), + success_metrics=str_list(payload.get("success_metrics"), base.success_metrics), + assumptions=str_list(payload.get("assumptions"), base.assumptions), + unknowns=str_list(payload.get("unknowns"), base.unknowns), ) + return brief, model diff --git a/src/parallel_agents/desktop/services/llm_config.py b/src/parallel_agents/desktop/services/llm_config.py new file mode 100644 index 0000000..b14d315 --- /dev/null +++ b/src/parallel_agents/desktop/services/llm_config.py @@ -0,0 +1,55 @@ +"""Single source of truth for which desktop artifact generators use the LLM. + +Default-on policy: when an ANTHROPIC_API_KEY is present, LLM generation is the +default for every artifact. A per-artifact flag (``PA_DESKTOP_LLM_``) or a +global flag (``PA_DESKTOP_LLM``) overrides that default in either direction, so +users can force-enable without a key (it will fall back) or force-disable with +one. No Qt imports — safe for the status bar and the engine alike. +""" + +from __future__ import annotations + +import os + +# Artifact key (env-flag suffix) -> short human label for the status bar. +ARTIFACTS: dict[str, str] = { + "BRIEF": "brief", + "PRFAQ": "prfaq", + "TECH_STACK": "stack", + "RFC": "rfc", + "ROADMAP": "roadmap", + "SPRINT": "sprint", +} + + +def _truthy(value: str | None) -> bool: + return bool(value) and value.strip().lower() in {"1", "true", "yes", "on"} + + +def _explicit(name: str) -> bool | None: + raw = os.environ.get(name) + if raw is None: + return None + return _truthy(raw) + + +def llm_available() -> bool: + return bool(os.environ.get("ANTHROPIC_API_KEY")) + + +def llm_enabled(artifact_key: str) -> bool: + """Whether the LLM generator should run for one artifact. + + Precedence: per-artifact flag > global flag > (key present?). + """ + per = _explicit(f"PA_DESKTOP_LLM_{artifact_key}") + if per is not None: + return per + glob = _explicit("PA_DESKTOP_LLM") + if glob is not None: + return glob + return llm_available() + + +def active_generators() -> list[str]: + return [label for key, label in ARTIFACTS.items() if llm_enabled(key)] diff --git a/src/parallel_agents/desktop/services/llm_prfaq.py b/src/parallel_agents/desktop/services/llm_prfaq.py index f7f34e7..e49e0ac 100644 --- a/src/parallel_agents/desktop/services/llm_prfaq.py +++ b/src/parallel_agents/desktop/services/llm_prfaq.py @@ -1,4 +1,4 @@ -"""LLM-backed PR/FAQ generation. Falls back to deterministic on any error.""" +"""LLM-backed PR/FAQ generation via structured tool-use output.""" from __future__ import annotations @@ -8,49 +8,69 @@ ProductBrief, build_prfaq, ) -from parallel_agents.desktop.services.llm import call_anthropic, extract_json +from parallel_agents.desktop.services.llm import call_anthropic_tool, str_list, text_or -PROMPT_TEMPLATE = """You are the launch communications agent in an AI software office. +_FAQ_SCHEMA = { + "type": "array", + "items": { + "type": "object", + "properties": { + "question": {"type": "string"}, + "answer": {"type": "string"}, + }, + "required": ["question", "answer"], + }, +} -Produce a PR/FAQ document for the product described in the ProductBrief below. -Respond with a single JSON object matching this schema (no prose, no fences): +_SCHEMA = { + "type": "object", + "properties": { + "headline": {"type": "string"}, + "press_release_summary": {"type": "string"}, + "customer_faq": _FAQ_SCHEMA, + "internal_faq": _FAQ_SCHEMA, + "launch_criteria": {"type": "array", "items": {"type": "string"}}, + }, + "required": ["headline", "press_release_summary", "customer_faq"], +} -{{ - "headline": str, - "press_release_summary": str, - "customer_faq": [{{"question": str, "answer": str}}, ...], # 4-6 entries - "internal_faq": [{{"question": str, "answer": str}}, ...], # 3-5 entries - "launch_criteria": [str, ...] # 4-6 measurable gates -}} +_PROMPT = """You are the launch communications agent in an AI software office. +Produce a PR/FAQ for the product in this ProductBrief. 4-6 customer FAQ entries, +3-5 internal FAQ entries, 4-6 measurable launch criteria. Be specific to the brief. ProductBrief JSON: {brief_json} """ -def generate_llm_prfaq(brief: ProductBrief) -> PRFAQDocument: - raw = call_anthropic( - PROMPT_TEMPLATE.format(brief_json=brief.model_dump_json(indent=2)), - max_tokens=2000, +def generate_llm_prfaq(brief: ProductBrief) -> tuple[PRFAQDocument, str]: + payload, model = call_anthropic_tool( + _PROMPT.format(brief_json=brief.model_dump_json(indent=2)), + tool_name="emit_prfaq", + tool_description="Return the structured PR/FAQ document for the product.", + input_schema=_SCHEMA, + max_tokens=2500, ) - payload = extract_json(raw) fallback = build_prfaq(brief) - return PRFAQDocument( - headline=str(payload.get("headline") or fallback.headline), - press_release_summary=str( - payload.get("press_release_summary") or fallback.press_release_summary + doc = PRFAQDocument( + headline=text_or(payload.get("headline"), fallback.headline), + press_release_summary=text_or( + payload.get("press_release_summary"), fallback.press_release_summary ), customer_faq=_coerce_faq(payload.get("customer_faq"), fallback.customer_faq), internal_faq=_coerce_faq(payload.get("internal_faq"), fallback.internal_faq), - launch_criteria=list(payload.get("launch_criteria") or fallback.launch_criteria), + launch_criteria=str_list(payload.get("launch_criteria"), fallback.launch_criteria), ) + return doc, model -def _coerce_faq(value, fallback): +def _coerce_faq(value, fallback) -> list[FAQItem]: if not isinstance(value, list) or not value: return list(fallback) - items = [] + items: list[FAQItem] = [] for entry in value: if isinstance(entry, dict) and "question" in entry and "answer" in entry: - items.append(FAQItem(question=str(entry["question"]), answer=str(entry["answer"]))) + items.append( + FAQItem(question=str(entry["question"]), answer=str(entry["answer"])) + ) return items or list(fallback) diff --git a/src/parallel_agents/desktop/services/llm_rfc.py b/src/parallel_agents/desktop/services/llm_rfc.py index 550700d..a5c3fcd 100644 --- a/src/parallel_agents/desktop/services/llm_rfc.py +++ b/src/parallel_agents/desktop/services/llm_rfc.py @@ -1,4 +1,4 @@ -"""LLM-backed Architecture RFC generation. Falls back to deterministic on any error.""" +"""LLM-backed Architecture RFC generation via structured tool-use output.""" from __future__ import annotations @@ -8,23 +8,26 @@ TechStackDecision, build_architecture_rfc, ) -from parallel_agents.desktop.services.llm import call_anthropic, extract_json +from parallel_agents.desktop.services.llm import call_anthropic_tool, str_list, text_or -PROMPT_TEMPLATE = """You are the architecture agent in an AI software office. +_SCHEMA = { + "type": "object", + "properties": { + "title": {"type": "string"}, + "context": {"type": "string"}, + "decision": {"type": "string"}, + "alternatives": {"type": "array", "items": {"type": "string"}}, + "tradeoffs": {"type": "array", "items": {"type": "string"}}, + "security_considerations": {"type": "array", "items": {"type": "string"}}, + "rollout_plan": {"type": "array", "items": {"type": "string"}}, + }, + "required": ["title", "context", "decision"], +} -Draft an Architecture RFC informed by the ProductBrief and TechStackDecision -below. Respond with a single JSON object matching this schema (no prose, no -fences): - -{{ - "title": str, - "context": str, # restate the problem and constraints - "decision": str, # 1-3 sentences, decisive - "alternatives": [str, ...], # 2-4 alternatives considered - "tradeoffs": [str, ...], # 2-4 honest tradeoffs of the chosen decision - "security_considerations": [str, ...], - "rollout_plan": [str, ...] # 3-5 phased rollout steps -}} +_PROMPT = """You are the architecture agent in an AI software office. +Draft an Architecture RFC informed by the ProductBrief and TechStackDecision. +Be decisive in the decision field. 2-4 alternatives, 2-4 honest tradeoffs, +3-5 phased rollout steps. Be specific to these inputs. ProductBrief JSON: {brief_json} @@ -34,25 +37,30 @@ """ -def generate_llm_rfc(brief: ProductBrief, stack: TechStackDecision) -> ArchitectureRFC: - raw = call_anthropic( - PROMPT_TEMPLATE.format( +def generate_llm_rfc( + brief: ProductBrief, stack: TechStackDecision +) -> tuple[ArchitectureRFC, str]: + payload, model = call_anthropic_tool( + _PROMPT.format( brief_json=brief.model_dump_json(indent=2), stack_json=stack.model_dump_json(indent=2), ), - max_tokens=2500, + tool_name="emit_architecture_rfc", + tool_description="Return the structured Architecture RFC.", + input_schema=_SCHEMA, + max_tokens=3000, ) - payload = extract_json(raw) fallback = build_architecture_rfc(brief, stack) - return ArchitectureRFC( + rfc = ArchitectureRFC( id=fallback.id, - title=str(payload.get("title") or fallback.title), - context=str(payload.get("context") or fallback.context), - decision=str(payload.get("decision") or fallback.decision), - alternatives=list(payload.get("alternatives") or fallback.alternatives), - tradeoffs=list(payload.get("tradeoffs") or fallback.tradeoffs), - security_considerations=list( - payload.get("security_considerations") or fallback.security_considerations + title=text_or(payload.get("title"), fallback.title), + context=text_or(payload.get("context"), fallback.context), + decision=text_or(payload.get("decision"), fallback.decision), + alternatives=str_list(payload.get("alternatives"), fallback.alternatives), + tradeoffs=str_list(payload.get("tradeoffs"), fallback.tradeoffs), + security_considerations=str_list( + payload.get("security_considerations"), fallback.security_considerations ), - rollout_plan=list(payload.get("rollout_plan") or fallback.rollout_plan), + rollout_plan=str_list(payload.get("rollout_plan"), fallback.rollout_plan), ) + return rfc, model diff --git a/src/parallel_agents/desktop/services/llm_roadmap.py b/src/parallel_agents/desktop/services/llm_roadmap.py new file mode 100644 index 0000000..639c939 --- /dev/null +++ b/src/parallel_agents/desktop/services/llm_roadmap.py @@ -0,0 +1,101 @@ +"""LLM-backed RoadmapPlan generation via structured tool-use output. + +This replaces the single biggest source of "template theater": the deterministic +builder returned the same four roadmap items (about building parallel-agents +itself) for every idea. Here the items are generated from the actual brief. +""" + +from __future__ import annotations + +from parallel_agents.company_workflows import ( + ProductBrief, + RoadmapItem, + RoadmapPlan, + build_roadmap, +) +from parallel_agents.desktop.services.llm import call_anthropic_tool, str_list, text_or + +_ITEM_SCHEMA = { + "type": "object", + "properties": { + "id": {"type": "string"}, + "title": {"type": "string"}, + "owner_role": {"type": "string"}, + "milestone": {"type": "string"}, + "acceptance_criteria": {"type": "array", "items": {"type": "string"}}, + "dependencies": {"type": "array", "items": {"type": "string"}}, + }, + "required": ["title", "owner_role", "milestone"], +} + +_SCHEMA = { + "type": "object", + "properties": { + "outcomes": {"type": "array", "items": {"type": "string"}}, + "items": {"type": "array", "items": _ITEM_SCHEMA}, + }, + "required": ["items"], +} + +_PROMPT = """You are the planning agent in an AI software office. +Produce a delivery roadmap for the product in this ProductBrief over about +{weeks} weeks. Generate 4-8 concrete roadmap items SPECIFIC to this product — +not generic process steps. Group them into milestones (e.g. M1, M2, M3). Each +item needs a clear title, an owner role, a milestone, and acceptance criteria. + +ProductBrief JSON: +{brief_json} +""" + + +def generate_llm_roadmap( + brief: ProductBrief, horizon_weeks: int = 12 +) -> tuple[RoadmapPlan, str]: + weeks = max(1, horizon_weeks) + payload, model = call_anthropic_tool( + _PROMPT.format(weeks=weeks, brief_json=brief.model_dump_json(indent=2)), + tool_name="emit_roadmap", + tool_description="Return the structured delivery roadmap for the product.", + input_schema=_SCHEMA, + max_tokens=3000, + ) + items = _coerce_items(payload.get("items")) + if not items: + # Don't pass off the deterministic template as LLM output — that is the + # exact "template theater" this module exists to avoid. Raising routes + # through the engine's fallback path so provenance is recorded as + # template, not llm. + raise RuntimeError("roadmap LLM returned no usable items") + fallback = build_roadmap(brief, horizon_weeks=weeks) + return ( + RoadmapPlan( + name=f"{brief.title} Roadmap", + horizon_weeks=weeks, + outcomes=str_list(payload.get("outcomes"), fallback.outcomes), + items=items, + ), + model, + ) + + +def _coerce_items(value) -> list[RoadmapItem]: + if not isinstance(value, list): + return [] + items: list[RoadmapItem] = [] + for idx, entry in enumerate(value, start=1): + if not isinstance(entry, dict): + continue + title = text_or(entry.get("title"), "") + if not title: + continue + items.append( + RoadmapItem( + id=text_or(entry.get("id"), f"RM-{idx:02d}"), + title=title, + owner_role=text_or(entry.get("owner_role"), "Product Agent"), + milestone=text_or(entry.get("milestone"), "M1"), + acceptance_criteria=str_list(entry.get("acceptance_criteria"), []), + dependencies=str_list(entry.get("dependencies"), []), + ) + ) + return items diff --git a/src/parallel_agents/desktop/services/llm_sprint.py b/src/parallel_agents/desktop/services/llm_sprint.py new file mode 100644 index 0000000..99bea81 --- /dev/null +++ b/src/parallel_agents/desktop/services/llm_sprint.py @@ -0,0 +1,57 @@ +"""LLM-backed SprintPlan generation via structured tool-use output. + +Sprint items are derived deterministically from the roadmap's milestone (that +mapping is correct, not theater); the LLM rewrites the sprint goals and risks so +they are specific to the milestone scope rather than generic. +""" + +from __future__ import annotations + +from parallel_agents.company_workflows import ( + RoadmapPlan, + SprintPlan, + build_sprint_plan, +) +from parallel_agents.desktop.services.llm import call_anthropic_tool, str_list + +_SCHEMA = { + "type": "object", + "properties": { + "goals": {"type": "array", "items": {"type": "string"}}, + "risks": {"type": "array", "items": {"type": "string"}}, + }, + "required": ["goals"], +} + +_PROMPT = """You are the delivery agent in an AI software office. +For milestone "{milestone}" of this roadmap, write 2-4 concrete sprint goals and +2-4 realistic risks, specific to the scope below. + +Milestone: {milestone} +Roadmap JSON: +{roadmap_json} +""" + + +def generate_llm_sprint( + roadmap: RoadmapPlan, *, milestone: str, horizon_days: int = 14 +) -> tuple[SprintPlan, str]: + # Deterministic derivation of items from the roadmap milestone (raises + # ValueError if the milestone has no items — caller falls back). + base = build_sprint_plan(roadmap, milestone=milestone, horizon_days=horizon_days) + payload, model = call_anthropic_tool( + _PROMPT.format(milestone=milestone, roadmap_json=roadmap.model_dump_json(indent=2)), + tool_name="emit_sprint_plan", + tool_description="Return the structured sprint goals and risks.", + input_schema=_SCHEMA, + max_tokens=1500, + ) + plan = SprintPlan( + name=base.name, + milestone=base.milestone, + horizon_days=base.horizon_days, + goals=str_list(payload.get("goals"), base.goals), + items=base.items, + risks=str_list(payload.get("risks"), base.risks), + ) + return plan, model diff --git a/src/parallel_agents/desktop/services/llm_tech_stack.py b/src/parallel_agents/desktop/services/llm_tech_stack.py new file mode 100644 index 0000000..a645807 --- /dev/null +++ b/src/parallel_agents/desktop/services/llm_tech_stack.py @@ -0,0 +1,67 @@ +"""LLM-backed TechStackDecision generation via structured tool-use output. + +The deterministic option-scoring rubric and repo-signal detection stay (they are +real analysis); the LLM enriches the narrative — context, constraints, +recommended option, rationale, and risks — so the decision reflects the actual +product idea rather than a fixed template. +""" + +from __future__ import annotations + +from pathlib import Path + +from parallel_agents.company_workflows import ( + ProductBrief, + TechStackDecision, + recommend_tech_stack, +) +from parallel_agents.desktop.services.llm import call_anthropic_tool, str_list, text_or + +_SCHEMA = { + "type": "object", + "properties": { + "context": {"type": "string"}, + "constraints": {"type": "array", "items": {"type": "string"}}, + "recommended_option": {"type": "string"}, + "rationale": {"type": "string"}, + "risks": {"type": "array", "items": {"type": "string"}}, + }, + "required": ["recommended_option", "rationale"], +} + +_PROMPT = """You are the tech-stack agent in an AI software office. +Recommend a stack for the product in this ProductBrief, grounded in the detected +repository signals. Give a decisive recommended_option, a rationale specific to +the idea, realistic constraints, and honest risks. + +ProductBrief JSON: +{brief_json} + +Detected repository signals: {signals} +""" + + +def generate_llm_tech_stack( + brief: ProductBrief, repo_path: str | Path +) -> tuple[TechStackDecision, str]: + base = recommend_tech_stack(repo_path) + payload, model = call_anthropic_tool( + _PROMPT.format( + brief_json=brief.model_dump_json(indent=2), + signals=", ".join(base.detected_signals) or "(none detected)", + ), + tool_name="emit_tech_stack_decision", + tool_description="Return the structured tech-stack decision narrative.", + input_schema=_SCHEMA, + max_tokens=2000, + ) + decision = TechStackDecision( + context=text_or(payload.get("context"), base.context), + constraints=str_list(payload.get("constraints"), base.constraints), + options=base.options, # keep the deterministic scoring rubric + recommended_option=text_or(payload.get("recommended_option"), base.recommended_option), + rationale=text_or(payload.get("rationale"), base.rationale), + risks=str_list(payload.get("risks"), base.risks), + detected_signals=base.detected_signals, + ) + return decision, model diff --git a/src/parallel_agents/desktop/services/settings_store.py b/src/parallel_agents/desktop/services/settings_store.py index b8d9fd9..6a716eb 100644 --- a/src/parallel_agents/desktop/services/settings_store.py +++ b/src/parallel_agents/desktop/services/settings_store.py @@ -27,9 +27,23 @@ "PA_JUDGE_MODEL", "PA_PERMISSION_MODE", "PA_GATEWAY_API_KEY", + "PA_GATEWAY_SLACK_SIGNING_SECRET", + "PA_GATEWAY_SLACK_ALLOW_UNSIGNED", + "PA_DESKTOP_GATEWAY_URL", + "PA_DESKTOP_GATEWAY_HOST", + "PA_DESKTOP_GATEWAY_PORT", + "PA_DESKTOP_USE_GATEWAY", + "PA_DESKTOP_GATEWAY_REQUIRED", + "PA_DESKTOP_GATEWAY_TIMEOUT", + "PA_DESKTOP_GATEWAY_START_TIMEOUT", + # Per-artifact LLM toggles (override the default-on-when-key-present policy). + "PA_DESKTOP_LLM", "PA_DESKTOP_LLM_BRIEF", "PA_DESKTOP_LLM_PRFAQ", + "PA_DESKTOP_LLM_TECH_STACK", "PA_DESKTOP_LLM_RFC", + "PA_DESKTOP_LLM_ROADMAP", + "PA_DESKTOP_LLM_SPRINT", "PA_DESKTOP_LLM_MODEL", "PA_DESKTOP_BRIEF_MODEL", ) @@ -40,9 +54,9 @@ "opus", "sonnet", "haiku", - "claude-opus-4-7", + "claude-opus-4-8", "claude-sonnet-4-6", - "claude-haiku-4-5-20251001", + "claude-haiku-4-5", ) PERMISSION_MODE_CHOICES: tuple[str, ...] = ( diff --git a/src/parallel_agents/desktop/services/status.py b/src/parallel_agents/desktop/services/status.py index 985da03..f4fda05 100644 --- a/src/parallel_agents/desktop/services/status.py +++ b/src/parallel_agents/desktop/services/status.py @@ -2,21 +2,13 @@ from __future__ import annotations -import os +from parallel_agents.desktop.services.llm_config import ARTIFACTS, active_generators def llm_indicator_text() -> str: - flags = { - "brief": _truthy("PA_DESKTOP_LLM_BRIEF"), - "prfaq": _truthy("PA_DESKTOP_LLM_PRFAQ"), - "rfc": _truthy("PA_DESKTOP_LLM_RFC"), - } - enabled = [name for name, on in flags.items() if on] - if not enabled: + active = active_generators() + if not active: return "LLM: deterministic" - return "LLM: " + ", ".join(enabled) - - -def _truthy(name: str) -> bool: - value = os.environ.get(name, "").strip().lower() - return value in {"1", "true", "yes", "on"} + if len(active) == len(ARTIFACTS): + return "LLM: all" + return "LLM: " + ", ".join(active) diff --git a/src/parallel_agents/desktop/services/workers.py b/src/parallel_agents/desktop/services/workers.py index 521534d..1bbbee3 100644 --- a/src/parallel_agents/desktop/services/workers.py +++ b/src/parallel_agents/desktop/services/workers.py @@ -12,6 +12,7 @@ from __future__ import annotations import asyncio +import traceback from collections.abc import Awaitable, Callable from typing import Any @@ -36,4 +37,6 @@ def run(self) -> None: loop.close() self.finished_ok.emit(result) except Exception as exc: # noqa: BLE001 - surface any failure to UI - self.failed.emit(f"{type(exc).__name__}: {exc}") + self.failed.emit( + f"{type(exc).__name__}: {exc}\n\n{traceback.format_exc()}" + ) diff --git a/src/parallel_agents/evidence_store.py b/src/parallel_agents/evidence_store.py index e5e80fc..d21e6ed 100644 --- a/src/parallel_agents/evidence_store.py +++ b/src/parallel_agents/evidence_store.py @@ -102,7 +102,13 @@ def load_plan(self) -> TaskPlan | None: def save_worker_result(self, result: WorkerResult) -> None: filename = f"{result.worker_name}_result.json" - self._write_json(self.workers_path / filename, result.model_dump()) + path = self.workers_path / filename + existing = self._read_json(path) + if existing and existing.get("subtask_id") not in (None, result.subtask_id): + # Same worker ran another subtask this run; keep both results. + filename = f"{result.worker_name}_{result.subtask_id}_result.json" + path = self.workers_path / filename + self._write_json(path, result.model_dump()) def load_worker_result(self, worker_name: str) -> WorkerResult | None: filename = f"{worker_name}_result.json" @@ -111,11 +117,14 @@ def load_worker_result(self, worker_name: str) -> WorkerResult | None: def load_all_worker_results(self) -> dict[str, WorkerResult]: results: dict[str, WorkerResult] = {} - for path in self.workers_path.glob("*_result.json"): + for path in sorted(self.workers_path.glob("*_result.json")): data = self._read_json(path) if data: result = WorkerResult(**data) - results[result.worker_name] = result + key = result.worker_name + if key in results: + key = f"{result.worker_name}:{result.subtask_id}" + results[key] = result return results def save_final_output(self, output: FinalOutput) -> None: @@ -224,13 +233,27 @@ def _do(conn): def save_worker_result(self, result: WorkerResult) -> None: data = json.dumps(result.model_dump(), default=str) + + def _select(conn): + return conn.execute( + "SELECT data FROM worker_results WHERE run_id = ? AND worker_name = ?", + (self.run_id, result.worker_name)).fetchone() + + stored_name = result.worker_name + row = self._exec(_select) + if row: + existing_subtask = json.loads(row["data"]).get("subtask_id") + if existing_subtask not in (None, result.subtask_id): + # Same worker ran another subtask this run; keep both rows. + stored_name = f"{result.worker_name}:{result.subtask_id}" + def _do(conn): conn.execute( "INSERT INTO worker_results (run_id, worker_name, data, status, findings_count, recommendations_count) " "VALUES (?, ?, ?, ?, ?, ?) ON CONFLICT(run_id, worker_name) DO UPDATE SET " "data=excluded.data, status=excluded.status, findings_count=excluded.findings_count, " "recommendations_count=excluded.recommendations_count", - (self.run_id, result.worker_name, data, result.status, len(result.findings), len(result.recommendations))) + (self.run_id, stored_name, data, result.status, len(result.findings), len(result.recommendations))) self._exec(_do) def load_worker_result(self, worker_name: str) -> WorkerResult | None: diff --git a/src/parallel_agents/gateway.py b/src/parallel_agents/gateway.py index d0c9060..1a84639 100644 --- a/src/parallel_agents/gateway.py +++ b/src/parallel_agents/gateway.py @@ -9,11 +9,12 @@ import json import os import queue +import secrets import sqlite3 import threading import time import uuid -from datetime import datetime, timezone +from datetime import datetime, timedelta, timezone from pathlib import Path from typing import Any, Callable @@ -24,6 +25,7 @@ load_company_artifact_events, persist_company_artifact, ) +from parallel_agents.config import DEFAULT_ARTIFACT_DIR, PipelineConfig from parallel_agents.company_policy import ( CompanyApplyPolicy, derive_policy_from_issue_plan, @@ -34,7 +36,9 @@ build_issue_plan_from_roadmap, build_roadmap, create_product_brief, + issue_plan_items, ) +from parallel_agents.pipeline import Pipeline from parallel_agents.tools.github_tools import parse_repo_ref from parallel_agents.workspace_memory import ( add_memory_entry, @@ -60,11 +64,94 @@ "failed", } +PAIRING_CODE_TTL_SECONDS = 15 * 60 + def _utc_now() -> str: return datetime.now(timezone.utc).isoformat() +def _utc_after(seconds: int) -> str: + return (datetime.now(timezone.utc) + timedelta(seconds=seconds)).isoformat() + + +def _write_tools_enabled() -> bool: + """Whether the gateway may perform external/irreversible writes (apply). + + Off by default: the gateway is a network surface, so live GitHub writes must + be explicitly opted into via PA_ALLOW_WRITE_TOOLS. Read, plan, and approve + flows are unaffected; only the apply step that mutates a remote is gated. + """ + return os.environ.get("PA_ALLOW_WRITE_TOOLS", "").strip().lower() in { + "1", "true", "yes", "on", + } + + +def _pipeline_config_from_payload(payload: dict[str, Any], *, output_dir: Path) -> PipelineConfig: + store_backend = str(payload.get("store_backend") or payload.get("store") or "file") + if store_backend not in {"file", "sqlite"}: + raise ValueError("store_backend must be 'file' or 'sqlite'") + config = PipelineConfig(output_dir=str(output_dir), store_backend=store_backend) + + workers = _string_list(payload.get("workers")) + if workers: + enabled = set(workers) + for name in config.workers: + config.workers[name].enabled = name in enabled + + disabled_workers = _string_list(payload.get("disable_workers")) + for name in disabled_workers: + if name in config.workers: + config.workers[name].enabled = False + + model = str(payload.get("model") or "").strip() + if model: + config.planner_model = model + config.judge_model = model + for worker_config in config.workers.values(): + worker_config.model = model + + permission_mode = str(payload.get("permission_mode") or "").strip() + if permission_mode: + if permission_mode not in {"default", "acceptEdits", "plan", "bypassPermissions"}: + raise ValueError("permission_mode must be default, acceptEdits, plan, or bypassPermissions") + config.permission_mode = permission_mode # type: ignore[assignment] + + max_parallel_workers = payload.get("max_parallel_workers") + if max_parallel_workers is not None: + config.max_parallel_workers = max(1, int(max_parallel_workers)) + + parse_retry_attempts = payload.get("parse_retry_attempts") + if parse_retry_attempts is not None: + config.parse_retry_attempts = max(0, int(parse_retry_attempts)) + + return config + + +def _string_list(value: Any) -> list[str]: + if value is None: + return [] + if isinstance(value, str): + return [item.strip() for item in value.split(",") if item.strip()] + if isinstance(value, list): + return [str(item).strip() for item in value if str(item).strip()] + return [] + + +def _new_pairing_code() -> str: + return secrets.token_hex(3).upper() + + +def _first_text(payload: dict[str, Any], *keys: str) -> str: + for key in keys: + value = payload.get(key) + if value is not None: + text = str(value).strip() + if text: + return text + return "" + + def _row_to_dict(row: sqlite3.Row) -> dict[str, Any]: return {key: row[key] for key in row.keys()} @@ -136,6 +223,46 @@ def _resolve_gateway_allow_remote_write_tools(value: bool | str | None) -> bool: return _as_bool(value, default=False) +def _resolve_gateway_slack_signing_secret(value: str | None) -> str | None: + raw = value if value is not None else os.getenv("PA_GATEWAY_SLACK_SIGNING_SECRET") + if raw is None: + return None + cleaned = str(raw).strip() + return cleaned or None + + +def _resolve_gateway_slack_allow_unsigned(value: bool | str | None) -> bool: + if value is None: + return _as_bool(os.getenv("PA_GATEWAY_SLACK_ALLOW_UNSIGNED"), default=False) + return _as_bool(value, default=False) + + +def _verify_slack_signature( + *, + signing_secret: str, + timestamp: str | None, + signature: str | None, + raw_body: bytes, + now_seconds: int | None = None, +) -> bool: + if not timestamp or not signature: + return False + try: + timestamp_int = int(timestamp) + except ValueError: + return False + now = int(now_seconds if now_seconds is not None else time.time()) + if abs(now - timestamp_int) > 60 * 5: + return False + base = f"v0:{timestamp}:".encode("utf-8") + raw_body + expected = "v0=" + hmac.new( + signing_secret.encode("utf-8"), + base, + hashlib.sha256, + ).hexdigest() + return hmac.compare_digest(expected, signature) + + def _decode_b64url(segment: str) -> bytes: padded = segment + ("=" * (-len(segment) % 4)) return base64.urlsafe_b64decode(padded.encode("utf-8")) @@ -216,7 +343,7 @@ def _decode_jwt_payload(token: str) -> dict[str, Any] | None: class GatewayStore: - def __init__(self, output_dir: str | Path = ".parallel-agents-output") -> None: + def __init__(self, output_dir: str | Path = DEFAULT_ARTIFACT_DIR) -> None: self.output_dir = Path(output_dir) self.output_dir.mkdir(parents=True, exist_ok=True) self.db_path = self.output_dir / "gateway.sqlite" @@ -564,6 +691,126 @@ def list_access_audit(self, *, limit: int = 200) -> list[dict[str, Any]]: items.append(payload) return items + def is_channel_peer_allowed(self, *, channel: str, peer_id: str) -> bool: + with self._connect() as conn: + row = conn.execute( + """ + SELECT 1 FROM channel_peers + WHERE channel = ? AND peer_id = ? + LIMIT 1 + """, + (channel, peer_id), + ).fetchone() + return row is not None + + def create_pairing_code( + self, + *, + channel: str, + peer_id: str, + ttl_seconds: int = PAIRING_CODE_TTL_SECONDS, + ) -> dict[str, Any]: + now = _utc_now() + with self._connect() as conn: + row = conn.execute( + """ + SELECT * FROM pairing_codes + WHERE channel = ? AND peer_id = ? AND approved_at IS NULL AND expires_at > ? + ORDER BY created_at DESC + LIMIT 1 + """, + (channel, peer_id, now), + ).fetchone() + if row: + return _row_to_dict(row) + + code = _new_pairing_code() + expires_at = _utc_after(max(60, int(ttl_seconds))) + for _ in range(5): + try: + conn.execute( + """ + INSERT INTO pairing_codes ( + code, channel, peer_id, created_at, expires_at, approved_at, approved_by + ) + VALUES (?, ?, ?, ?, ?, ?, ?) + """, + (code, channel, peer_id, now, expires_at, None, None), + ) + break + except sqlite3.IntegrityError: + code = _new_pairing_code() + else: + raise RuntimeError("could not allocate pairing code") + + return { + "code": code, + "channel": channel, + "peer_id": peer_id, + "created_at": now, + "expires_at": expires_at, + "approved_at": None, + "approved_by": None, + } + + def approve_pairing_code(self, *, code: str, approved_by: str) -> dict[str, Any]: + now = _utc_now() + normalized_code = code.strip().upper() + with self._connect() as conn: + row = conn.execute( + "SELECT * FROM pairing_codes WHERE code = ?", + (normalized_code,), + ).fetchone() + if not row: + raise ValueError("pairing code not found") + item = _row_to_dict(row) + if item.get("approved_at"): + raise ValueError("pairing code already approved") + if str(item.get("expires_at") or "") <= now: + raise ValueError("pairing code expired") + + conn.execute( + """ + UPDATE pairing_codes + SET approved_at = ?, approved_by = ? + WHERE code = ? + """, + (now, approved_by, normalized_code), + ) + conn.execute( + """ + INSERT INTO channel_peers ( + channel, peer_id, approved_at, approved_by, label + ) + VALUES (?, ?, ?, ?, ?) + ON CONFLICT(channel, peer_id) DO UPDATE SET + approved_at = excluded.approved_at, + approved_by = excluded.approved_by, + label = excluded.label + """, + ( + item["channel"], + item["peer_id"], + now, + approved_by, + f"{item['channel']}:{item['peer_id']}", + ), + ) + item["approved_at"] = now + item["approved_by"] = approved_by + return item + + def list_channel_peers(self, *, channel: str | None = None) -> list[dict[str, Any]]: + sql = "SELECT * FROM channel_peers" + params: list[Any] = [] + if channel: + sql += " WHERE channel = ?" + params.append(channel) + sql += " ORDER BY approved_at DESC" + with self._connect() as conn: + rows = conn.execute(sql, params).fetchall() + return [_row_to_dict(row) for row in rows] + def get_metrics_summary(self) -> dict[str, Any]: with self._connect() as conn: project_count = int(conn.execute("SELECT COUNT(*) AS c FROM projects").fetchone()["c"]) @@ -683,6 +930,25 @@ def _init_db(self) -> None: allowed INTEGER NOT NULL, detail TEXT ); + + CREATE TABLE IF NOT EXISTS channel_peers ( + channel TEXT NOT NULL, + peer_id TEXT NOT NULL, + approved_at TEXT NOT NULL, + approved_by TEXT NOT NULL, + label TEXT, + PRIMARY KEY(channel, peer_id) + ); + + CREATE TABLE IF NOT EXISTS pairing_codes ( + code TEXT PRIMARY KEY, + channel TEXT NOT NULL, + peer_id TEXT NOT NULL, + created_at TEXT NOT NULL, + expires_at TEXT NOT NULL, + approved_at TEXT, + approved_by TEXT + ); """ ) self._ensure_column(conn, "runs", "attempt_count", "INTEGER NOT NULL DEFAULT 1") @@ -839,16 +1105,18 @@ def _process_one(self, run_id: str) -> None: def create_gateway_app( - output_dir: str | Path = ".parallel-agents-output", + output_dir: str | Path = DEFAULT_ARTIFACT_DIR, api_key: str | None = None, jwt_secret: str | None = None, jwt_issuer: str | None = None, jwt_audience: str | None = None, allow_remote_write_tools: bool | str | None = None, + slack_signing_secret: str | None = None, + slack_allow_unsigned: bool | str | None = None, ): try: from fastapi import FastAPI, HTTPException, Request - from fastapi.responses import JSONResponse + from fastapi.responses import JSONResponse, PlainTextResponse except ImportError as exc: # pragma: no cover - import guard raise RuntimeError( "Gateway support requires FastAPI. Install with `pip install parallel-agents[gateway]`." @@ -862,6 +1130,12 @@ def create_gateway_app( resolved_allow_remote_write_tools = _resolve_gateway_allow_remote_write_tools( allow_remote_write_tools ) + resolved_slack_signing_secret = _resolve_gateway_slack_signing_secret( + slack_signing_secret + ) + resolved_slack_allow_unsigned = _resolve_gateway_slack_allow_unsigned( + slack_allow_unsigned + ) def _run_company_idea(run: dict[str, Any]) -> tuple[str, dict[str, Any] | None, str | None]: payload = dict(run.get("payload") or {}) @@ -991,6 +1265,12 @@ def _run_company_approve(run: dict[str, Any]) -> tuple[str, dict[str, Any] | Non ) def _run_company_apply(run: dict[str, Any]) -> tuple[str, dict[str, Any] | None, str | None]: + if not _write_tools_enabled(): + return ( + "blocked_by_policy", + {"write_tools_enabled": False}, + "live writes disabled; set PA_ALLOW_WRITE_TOOLS=1 to enable apply", + ) payload = dict(run.get("payload") or {}) run_id = str(payload.get("run_id") or run["id"]) artifact_name_override = payload.get("artifact") @@ -1010,7 +1290,7 @@ def _run_company_apply(run: dict[str, Any]) -> tuple[str, dict[str, Any] | None, if not parsed: raise ValueError("artifact repo is invalid or missing") owner, repo = parsed - issue_plan = artifact.get("issue_plan") or [] + issue_plan = issue_plan_items(artifact) policy = derive_policy_from_issue_plan(f"{owner}/{repo}", issue_plan) if isinstance(artifact.get("apply_policy"), dict): @@ -1046,8 +1326,77 @@ def _run_company_apply(run: dict[str, Any]) -> tuple[str, dict[str, Any] | None, None, ) + def _run_pipeline_run(run: dict[str, Any]) -> tuple[str, dict[str, Any] | None, str | None]: + payload = dict(run.get("payload") or {}) + task = str(payload.get("task") or payload.get("input") or "").strip() + if not task: + raise ValueError("task is required") + + repo_path_value = payload.get("repo_path") + if repo_path_value is None: + repo_path_value = payload.get("repo") + repo_path = str(repo_path_value).strip() if repo_path_value is not None else None + if repo_path == "": + repo_path = None + + config = _pipeline_config_from_payload(payload, output_dir=store.output_dir) + + def _on_status(message: str) -> None: + store.add_event(run["id"], "pipeline_status", {"message": message}) + + def _on_event(event: dict[str, Any]) -> None: + store.add_event(run["id"], "pipeline_trace", event) + + final_output = asyncio.run( + Pipeline(config).run( + task, + repo_path=repo_path, + on_status=_on_status, + on_event=_on_event, + run_id=run["id"], + ) + ) + final_payload = final_output.model_dump(mode="json") + artifact_path = persist_company_artifact( + store.output_dir, + run["id"], + "final-output", + final_payload, + ) + + worker_errors = [ + name + for name, result in final_output.worker_results.items() + if result.status == "error" + ] + summary = final_output.summary.lower() + failed = ( + bool(final_output.metadata.get("error")) + or "failed to parse" in summary + or "parse error" in summary + or bool(worker_errors) + ) + result_payload = { + "artifact": final_payload, + "artifact_path": str(artifact_path), + "summary": final_output.summary, + "run_id": run["id"], + "pipeline_run_id": final_output.metadata.get("run_id", run["id"]), + "worker_errors": worker_errors, + "has_patch": final_output.patch is not None, + "patch_validation": final_output.metadata.get("patch_validation"), + "cost": final_output.metadata.get("cost"), + } + return ( + "failed" if failed else "succeeded", + result_payload, + str(final_output.metadata.get("error") or "pipeline run failed") if failed else None, + ) + def _run_handler(run: dict[str, Any]) -> tuple[str, dict[str, Any] | None, str | None]: kind = str(run.get("kind") or "") + if kind == "pipeline.run": + return _run_pipeline_run(run) if kind == "company.idea": return _run_company_idea(run) if kind == "company.roadmap": @@ -1115,7 +1464,10 @@ async def _api_key_auth_middleware(request: Request, call_next): path = request.url.path authorized, auth_mode, principal = _request_auth_context(request) auth_required = bool(resolved_api_key or resolved_jwt_secret) - bypass_auth = (not auth_required) or (path == "/health") + slack_signed_endpoint = path == "/channels/slack/events" and bool( + resolved_slack_signing_secret or resolved_slack_allow_unsigned + ) + bypass_auth = (not auth_required) or (path == "/health") or slack_signed_endpoint if bypass_auth or authorized: response = await call_next(request) @@ -1187,6 +1539,54 @@ def _submit_run( timeout_seconds = 30.0 return runner.wait_for_terminal(run["id"], timeout_seconds=timeout_seconds) or run + def _handle_channel_inbound(payload: dict[str, Any]) -> dict[str, Any]: + channel = _first_text(payload, "channel").lower() + peer_id = _first_text(payload, "peer_id", "sender", "from") + message = _first_text(payload, "message", "text", "task", "body") + if not channel: + raise HTTPException(status_code=400, detail="channel is required") + if not peer_id: + raise HTTPException(status_code=400, detail="peer_id is required") + + if not store.is_channel_peer_allowed(channel=channel, peer_id=peer_id): + pairing = store.create_pairing_code(channel=channel, peer_id=peer_id) + return { + "status": "pairing_required", + "processed": False, + "channel": channel, + "peer_id": peer_id, + "pairing_code": pairing["code"], + "expires_at": pairing["expires_at"], + "message": "Unknown sender was not processed. Approve the pairing code first.", + } + + response: dict[str, Any] = { + "status": "accepted", + "processed": False, + "channel": channel, + "peer_id": peer_id, + } + if not _as_bool(payload.get("execute"), default=False): + return response + if not message: + raise HTTPException(status_code=400, detail="message/text/task is required when execute=true") + + run_payload = { + "run_id": _first_text(payload, "run_id") or f"run-channel-{uuid.uuid4().hex[:12]}", + "task": message, + "repo_path": _first_text(payload, "repo_path", "repo") or None, + "permission_mode": _first_text(payload, "permission_mode") or "plan", + "wait": _as_bool(payload.get("wait"), default=False), + "wait_timeout_seconds": payload.get("wait_timeout_seconds"), + "source": { + "channel": channel, + "peer_id": peer_id, + }, + } + response["processed"] = True + response["run"] = _submit_run("pipeline.run", run_payload) + return response + @app.get("/health") def health() -> dict[str, Any]: auth_modes: list[str] = [] @@ -1201,6 +1601,10 @@ def health() -> dict[str, Any]: "auth_required": bool(resolved_api_key or resolved_jwt_secret), "auth_modes": auth_modes, "allow_remote_write_tools": resolved_allow_remote_write_tools, + "slack_events_enabled": bool( + resolved_slack_signing_secret or resolved_slack_allow_unsigned + ), + "slack_unsigned_allowed": bool(resolved_slack_allow_unsigned), } def _gateway_mcp_tools_catalog() -> list[dict[str, Any]]: @@ -1387,6 +1791,108 @@ def call_mcp_tool(tool_name: str, payload: dict[str, Any] | None = None) -> dict "response_is_json": bool(response_payload["response_is_json"]), } + @app.post("/runs/pipeline") + def run_pipeline(payload: dict[str, Any]) -> dict[str, Any]: + return _submit_run("pipeline.run", payload) + + async def slack_events(request): + raw_body = await request.body() + if not resolved_slack_allow_unsigned: + if not resolved_slack_signing_secret: + raise HTTPException(status_code=503, detail="Slack signing secret is not configured") + if not _verify_slack_signature( + signing_secret=resolved_slack_signing_secret, + timestamp=request.headers.get("x-slack-request-timestamp"), + signature=request.headers.get("x-slack-signature"), + raw_body=raw_body, + ): + raise HTTPException(status_code=401, detail="invalid Slack signature") + + try: + payload = json.loads(raw_body.decode("utf-8")) + except json.JSONDecodeError as exc: + raise HTTPException(status_code=400, detail="invalid Slack JSON payload") from exc + if not isinstance(payload, dict): + raise HTTPException(status_code=400, detail="Slack payload must be a JSON object") + + if payload.get("type") == "url_verification": + challenge = str(payload.get("challenge") or "") + return PlainTextResponse(challenge) + + if payload.get("type") != "event_callback": + return {"status": "ignored", "reason": "unsupported Slack payload type"} + + event = payload.get("event") + if not isinstance(event, dict): + return {"status": "ignored", "reason": "missing Slack event"} + if event.get("type") != "message": + return {"status": "ignored", "reason": "unsupported Slack event type"} + if event.get("subtype") or event.get("bot_id"): + return {"status": "ignored", "reason": "Slack bot/subtype messages are ignored"} + + user_id = _first_text(event, "user") + channel_id = _first_text(event, "channel") + text = _first_text(event, "text") + if not user_id or not channel_id: + return {"status": "ignored", "reason": "Slack event missing user/channel"} + + peer_id = ":".join( + item + for item in [ + _first_text(payload, "team_id"), + channel_id, + user_id, + ] + if item + ) + inbound_payload = { + "channel": "slack", + "peer_id": peer_id, + "message": text, + "execute": True, + "wait": False, + "permission_mode": "plan", + "source_event": { + "team_id": payload.get("team_id"), + "event_id": payload.get("event_id"), + "event_time": payload.get("event_time"), + "channel": channel_id, + "user": user_id, + }, + } + return _handle_channel_inbound(inbound_payload) + + slack_events.__annotations__["request"] = Request + app.post("/channels/slack/events")(slack_events) + + @app.post("/channels/inbound") + def channel_inbound(payload: dict[str, Any]) -> dict[str, Any]: + return _handle_channel_inbound(payload) + + @app.post("/channels/pairing/approve") + def approve_channel_pairing(payload: dict[str, Any]) -> dict[str, Any]: + code = _first_text(payload, "code", "pairing_code") + if not code: + raise HTTPException(status_code=400, detail="code is required") + approved_by = _first_text(payload, "approved_by", "approver") or "gateway-operator" + try: + pairing = store.approve_pairing_code(code=code, approved_by=approved_by) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + return { + "status": "approved", + "channel": pairing["channel"], + "peer_id": pairing["peer_id"], + "approved_at": pairing["approved_at"], + "approved_by": pairing["approved_by"], + } + + @app.get("/channels/peers") + def list_channel_peers(channel: str | None = None) -> dict[str, Any]: + normalized = channel.strip().lower() if channel else None + peers = store.list_channel_peers(channel=normalized) + return {"peers": peers, "count": len(peers)} + @app.post("/runs/company/idea") def run_company_idea(payload: dict[str, Any]) -> dict[str, Any]: return _submit_run("company.idea", payload) @@ -1572,12 +2078,14 @@ def run_gateway_server( *, host: str = "127.0.0.1", port: int = 8733, - output_dir: str | Path = ".parallel-agents-output", + output_dir: str | Path = DEFAULT_ARTIFACT_DIR, api_key: str | None = None, jwt_secret: str | None = None, jwt_issuer: str | None = None, jwt_audience: str | None = None, allow_remote_write_tools: bool | str | None = None, + slack_signing_secret: str | None = None, + slack_allow_unsigned: bool | str | None = None, ) -> None: try: import uvicorn @@ -1593,5 +2101,7 @@ def run_gateway_server( jwt_issuer=jwt_issuer, jwt_audience=jwt_audience, allow_remote_write_tools=allow_remote_write_tools, + slack_signing_secret=slack_signing_secret, + slack_allow_unsigned=slack_allow_unsigned, ) uvicorn.run(app, host=host, port=port) diff --git a/src/parallel_agents/main.py b/src/parallel_agents/main.py index 32a43ee..e85296a 100644 --- a/src/parallel_agents/main.py +++ b/src/parallel_agents/main.py @@ -8,6 +8,9 @@ import subprocess import sys import tomllib +import urllib.error +import urllib.parse +import urllib.request from datetime import datetime, timezone from pathlib import Path from typing import Any @@ -20,7 +23,11 @@ from rich.table import Table from rich.text import Text -from parallel_agents.config import PipelineConfig +from parallel_agents.config import ( + DEFAULT_ARTIFACT_DIR, + PipelineConfig, + migrate_legacy_artifact_dir, +) from parallel_agents.eval_harness import ( EvaluationAnnotationUpdate, apply_evaluation_annotations, @@ -71,6 +78,7 @@ ) from parallel_agents.evidence_store import create_evidence_store from parallel_agents.models import FinalOutput +from parallel_agents.onboarding import build_onboarding_report from parallel_agents.patch_tools import apply_unified_diff from parallel_agents.pipeline import Pipeline from parallel_agents.project_office import ( @@ -107,6 +115,19 @@ console = Console() +DEFAULT_GATEWAY_URL = "http://127.0.0.1:8733" + + +def output_dir_option(func): + """Shared --output-dir option pinned to the single unified workspace root.""" + return click.option( + "--output-dir", + default=DEFAULT_ARTIFACT_DIR, + show_default=True, + help="Artifact output directory for run-linked data.", + )(func) + + EXIT_SUCCESS = 0 EXIT_RUNTIME_FAILURE = 1 EXIT_AUTH_FAILURE = 2 @@ -1376,7 +1397,7 @@ def company_group() -> None: help="Optional output path for generated brief JSON.", ) @click.option("--run-id", default=None, help="Optional run ID for artifact linking.") -@click.option("--output-dir", default=".parallel-agents-output", show_default=True, help="Artifact output directory for run-linked data.") +@output_dir_option @click.option("--json-output/--no-json-output", default=False, help="Print artifact as JSON.") def company_idea( idea: str, @@ -1426,7 +1447,7 @@ def company_idea( help="Optional output path for generated PR/FAQ JSON.", ) @click.option("--run-id", default=None, help="Optional run ID for artifact linking.") -@click.option("--output-dir", default=".parallel-agents-output", show_default=True, help="Artifact output directory for run-linked data.") +@output_dir_option @click.option("--json-output/--no-json-output", default=False, help="Print artifact as JSON.") def company_prfaq( brief_path: Path, @@ -1480,7 +1501,7 @@ def company_prfaq( help="Optional output path for generated stack decision JSON.", ) @click.option("--run-id", default=None, help="Optional run ID for artifact linking.") -@click.option("--output-dir", default=".parallel-agents-output", show_default=True, help="Artifact output directory for run-linked data.") +@output_dir_option @click.option("--json-output/--no-json-output", default=False, help="Print artifact as JSON.") def company_stack( repo: Path, @@ -1536,7 +1557,7 @@ def company_stack( help="Optional output path for generated RFC JSON.", ) @click.option("--run-id", default=None, help="Optional run ID for artifact linking.") -@click.option("--output-dir", default=".parallel-agents-output", show_default=True, help="Artifact output directory for run-linked data.") +@output_dir_option @click.option("--json-output/--no-json-output", default=False, help="Print artifact as JSON.") def company_rfc( brief_path: Path, @@ -1594,7 +1615,7 @@ def company_rfc( help="Optional output path for generated roadmap JSON.", ) @click.option("--run-id", default=None, help="Optional run ID for artifact linking.") -@click.option("--output-dir", default=".parallel-agents-output", show_default=True, help="Artifact output directory for run-linked data.") +@output_dir_option @click.option("--json-output/--no-json-output", default=False, help="Print artifact as JSON.") def company_roadmap( brief_path: Path, @@ -1648,7 +1669,7 @@ def company_roadmap( help="Optional output path for generated release readiness JSON.", ) @click.option("--run-id", default=None, help="Optional run ID for artifact linking.") -@click.option("--output-dir", default=".parallel-agents-output", show_default=True, help="Artifact output directory for run-linked data.") +@output_dir_option @click.option("--json-output/--no-json-output", default=False, help="Print artifact as JSON.") def company_release_check( repo: Path, @@ -1698,7 +1719,7 @@ def company_release_check( help="Optional output path for generated sprint plan JSON.", ) @click.option("--run-id", default=None, help="Optional run ID for artifact linking.") -@click.option("--output-dir", default=".parallel-agents-output", show_default=True, help="Artifact output directory for run-linked data.") +@output_dir_option @click.option("--json-output/--no-json-output", default=False, help="Print artifact as JSON.") def company_sprint( roadmap_path: Path, @@ -1757,7 +1778,7 @@ def company_sprint( help="Optional output path for post-release review JSON.", ) @click.option("--run-id", default=None, help="Optional run ID for artifact linking.") -@click.option("--output-dir", default=".parallel-agents-output", show_default=True, help="Artifact output directory for run-linked data.") +@output_dir_option @click.option("--json-output/--no-json-output", default=False, help="Print artifact as JSON.") def company_post_release( release_id: str, @@ -1818,7 +1839,7 @@ def company_post_release( help="Optional output path for GitHub workflow templates JSON.", ) @click.option("--run-id", default=None, help="Optional run ID for artifact linking.") -@click.option("--output-dir", default=".parallel-agents-output", show_default=True, help="Artifact output directory for run-linked data.") +@output_dir_option @click.option("--json-output/--no-json-output", default=False, help="Print artifact as JSON.") def company_templates( roadmap_path: Path | None, @@ -1867,7 +1888,7 @@ def company_templates( ) @click.option("--dry-run/--no-dry-run", default=True, show_default=True, help="Preview operations without GitHub writes.") @click.option("--run-id", default=None, help="Optional run ID for artifact linking.") -@click.option("--output-dir", default=".parallel-agents-output", show_default=True, help="Artifact output directory for run-linked data.") +@output_dir_option @click.option("--json-output/--no-json-output", default=False, help="Print sync result as JSON.") def company_sync_labels( repo_ref: str, @@ -1951,7 +1972,7 @@ def company_sync_labels( ) @click.option("--dry-run/--no-dry-run", default=True, show_default=True, help="Preview operations without GitHub writes.") @click.option("--run-id", default=None, help="Optional run ID for artifact linking.") -@click.option("--output-dir", default=".parallel-agents-output", show_default=True, help="Artifact output directory for run-linked data.") +@output_dir_option @click.option("--json-output/--no-json-output", default=False, help="Print sync result as JSON.") def company_sync_milestones( repo_ref: str, @@ -2054,7 +2075,7 @@ def company_branch_name( type=click.Path(path_type=Path), help="Optional output path for generated PR summary Markdown.", ) -@click.option("--output-dir", default=".parallel-agents-output", show_default=True, help="Artifact output directory for run-linked data.") +@output_dir_option @click.option("--json-output/--no-json-output", default=False, help="Print payload as JSON.") def company_pr_summary( run_id: str, @@ -2100,7 +2121,7 @@ def company_pr_summary( show_default=True, help="Artifact to enrich with PR link metadata.", ) -@click.option("--output-dir", default=".parallel-agents-output", show_default=True, help="Artifact output directory for run-linked data.") +@output_dir_option @click.option("--json-output/--no-json-output", default=False, help="Print payload as JSON.") def company_pr_link( run_id: str, @@ -2163,7 +2184,7 @@ def company_pr_link( @click.option("--title", default=None, help="Optional PR title.") @click.option("--artifact", "artifact_name", default="issue-plan", show_default=True, help="Artifact to enrich with PR link metadata.") @click.option("--draft/--no-draft", default=True, show_default=True, help="Create draft PR.") -@click.option("--output-dir", default=".parallel-agents-output", show_default=True, help="Artifact output directory for run-linked data.") +@output_dir_option @click.option("--json-output/--no-json-output", default=False, help="Print payload as JSON.") def company_pr_create( run_id: str, @@ -2328,7 +2349,7 @@ def company_pr_create( show_default=True, help="Which comment payloads to post.", ) -@click.option("--output-dir", default=".parallel-agents-output", show_default=True, help="Artifact output directory for run-linked data.") +@output_dir_option @click.option("--json-output/--no-json-output", default=False, help="Print payload as JSON.") def company_pr_comment( run_id: str, @@ -2498,7 +2519,7 @@ def company_pr_comment( help="Optional policy JSON file to constrain apply-time GitHub writes.", ) @click.option("--run-id", default=None, help="Optional run ID for artifact linking.") -@click.option("--output-dir", default=".parallel-agents-output", show_default=True, help="Artifact output directory for run-linked data.") +@output_dir_option @click.option("--json-output/--no-json-output", default=False, help="Print artifact as JSON.") def company_plan( roadmap_path: Path, @@ -2601,7 +2622,7 @@ def company_plan( @click.option("--artifact", "artifact_name", default="issue-plan", show_default=True, help="Artifact name to approve.") @click.option("--approver", default=None, help="Optional approver identity.") @click.option("--approval-note", default=None, help="Optional note captured in immutable approval audit log.") -@click.option("--output-dir", default=".parallel-agents-output", show_default=True, help="Artifact output directory for run-linked data.") +@output_dir_option @click.option("--json-output/--no-json-output", default=False, help="Print artifact as JSON.") def company_approve( run_id: str, @@ -2684,7 +2705,7 @@ def company_approve( type=click.Path(exists=True, dir_okay=False, path_type=Path), help="Optional policy JSON file. Overrides policy embedded in the artifact.", ) -@click.option("--output-dir", default=".parallel-agents-output", show_default=True, help="Artifact output directory for run-linked data.") +@output_dir_option @click.option("--json-output/--no-json-output", default=False, help="Print artifact as JSON.") def company_apply( run_id: str, @@ -2784,7 +2805,7 @@ def company_apply( @company_group.command(name="artifacts") @click.option("--run-id", required=True, help="Run ID to inspect.") -@click.option("--output-dir", default=".parallel-agents-output", show_default=True, help="Artifact output directory for run-linked data.") +@output_dir_option @click.option("--json-output/--no-json-output", default=False, help="Print artifact map as JSON.") def company_artifacts(run_id: str, output_dir: str, json_output: bool) -> None: """List run-linked company artifacts.""" @@ -2845,6 +2866,133 @@ def office_init(project_path: Path, name: str | None, json_output: bool) -> None )) +@office_group.command(name="onboard") +@click.option( + "--project", + "project_path", + default=".", + type=click.Path(file_okay=False, path_type=Path), + show_default=True, + help="Project folder to prepare for first use.", +) +@click.option("--name", default=None, help="Optional project display name.") +@click.option( + "--fix-setup/--no-fix-setup", + default=True, + show_default=True, + help="Create/repair the local .parallel-agents workspace before reporting readiness.", +) +@click.option( + "--check-github-auth/--skip-github-auth-check", + default=True, + show_default=True, + help="Run `gh auth status` when GitHub CLI is available.", +) +@click.option( + "--strict/--no-strict", + default=False, + show_default=True, + help="Exit non-zero until local model-backed runs are ready.", +) +@click.option("--json-output/--no-json-output", default=False, help="Print onboarding report as JSON.") +def office_onboard( + project_path: Path, + name: str | None, + fix_setup: bool, + check_github_auth: bool, + strict: bool, + json_output: bool, +) -> None: + """Prepare a project for the local desktop/CLI/GitHub workflow.""" + payload = build_onboarding_report( + project_path, + name=name, + fix_setup=fix_setup, + check_github_auth=check_github_auth, + ) + if json_output: + click.echo(json.dumps(payload, indent=2, default=str)) + else: + status = str(payload.get("status", "unknown")) + border_style = "green" if status == "ready" else ("yellow" if status.startswith("needs_") else "red") + console.print( + Panel( + "\n".join( + [ + f"Status: {status}", + f"Project root: {payload['project_root']}", + f"Ready for local run: {payload['ready_for_local_run']}", + f"Ready for GitHub flow: {payload['ready_for_github_flow']}", + f"Actions taken: {', '.join(payload['actions_taken']) or 'none'}", + ] + ), + title="Office Onboarding", + border_style=border_style, + ) + ) + + readiness = Table(title="Readiness") + readiness.add_column("Area") + readiness.add_column("Status") + readiness.add_column("Detail") + after = payload.get("after") or {} + readiness.add_row( + "workspace", + "passed" if not payload.get("blocking_failures") else "failed", + f"{after.get('passed_checks', 0)} passed, {after.get('warning_checks', 0)} warnings, {after.get('failed_checks', 0)} failures", + ) + for area in ("llm", "github"): + item = payload.get(area) or {} + readiness.add_row(area, str(item.get("status", "unknown")), str(item.get("detail", ""))) + console.print(readiness) + + next_actions = payload.get("next_actions") or [] + if next_actions: + actions_table = Table(title="Next Actions") + actions_table.add_column("Step") + actions_table.add_column("Command") + for item in next_actions: + actions_table.add_row(str(item.get("label", "")), str(item.get("command", ""))) + console.print(actions_table) + + if strict and not bool(payload.get("ready_for_local_run")): + sys.exit(EXIT_RUNTIME_FAILURE) + + +@office_group.command(name="migrate") +@click.option( + "--project", + "project_path", + default=".", + type=click.Path(file_okay=False, path_type=Path), + show_default=True, + help="Project folder to migrate.", +) +@click.option("--json-output/--no-json-output", default=False, help="Print the result as JSON.") +def office_migrate(project_path: Path, json_output: bool) -> None: + """Move a legacy .parallel-agents-output workspace onto the unified root. + + Safe and explicit: only renames when the legacy directory exists and the + unified .parallel-agents directory does not. Never merges or overwrites. + """ + result = migrate_legacy_artifact_dir(project_path) + if json_output: + click.echo(json.dumps(result, indent=2, default=str)) + elif result.get("migrated"): + console.print( + f"Migrated workspace: {result['legacy']} -> {result['target']}", + style="green", + ) + elif result.get("reason") == "no-legacy-dir": + console.print("Nothing to migrate: no legacy .parallel-agents-output found.") + elif result.get("reason") == "target-exists": + console.print( + "Refusing to migrate: .parallel-agents already exists. Merge manually.", + style="yellow", + ) + sys.exit(EXIT_RUNTIME_FAILURE) + + @office_group.command(name="status") @click.option( "--project", @@ -3105,6 +3253,82 @@ def office_home(project_path: Path, json_output: bool) -> None: console.print(memory_table) +@office_group.command(name="audit-verify") +@click.option( + "--project", + "project_path", + default=".", + type=click.Path(file_okay=False, path_type=Path), + show_default=True, + help="Project folder to inspect.", +) +@click.option("--run-id", default=None, help="Optional run id. If omitted, verify every run.") +@click.option("--json-output/--no-json-output", default=False, help="Print the verification report as JSON.") +def office_audit_verify(project_path: Path, run_id: str | None, json_output: bool) -> None: + """Recompute the workspace audit hash-chains and report any tampering. + + Exits non-zero if any chain is broken, so CI/release checks can gate on it. + """ + from parallel_agents.company_artifacts import ( + verify_hash_chain, + verify_run_audit_chains, + ) + from parallel_agents.project_office import office_dir, office_output_dir + + _resolve_initialized_office(project_path) + central = verify_hash_chain( + office_dir(project_path) / "audit" / "events.jsonl", "governance-log" + ) + target_runs = [run_id] if run_id else list_office_run_ids(project_path) + try: + run_reports = [ + verify_run_audit_chains(office_output_dir(project_path), rid) + for rid in target_runs + ] + except ValueError as exc: + click.echo(str(exc), err=True) + sys.exit(EXIT_RUNTIME_FAILURE) + all_ok = central.ok and all(r.ok for r in run_reports) + + if json_output: + click.echo( + json.dumps( + { + "ok": all_ok, + "governance_log": {"ok": central.ok, "entries": central.entry_count, "reason": central.reason}, + "runs": [ + { + "run_id": r.run_id, + "ok": r.ok, + "chains": [ + {"artifact": c.artifact_name, "ok": c.ok, "entries": c.entry_count, "reason": c.reason} + for c in r.chains + ], + } + for r in run_reports + ], + }, + indent=2, + ) + ) + else: + table = Table(title="Audit Chain Verification") + table.add_column("Scope") + table.add_column("Status") + table.add_column("Entries", justify="right") + gov_status = "OK" if central.ok else f"BROKEN ({central.reason})" + table.add_row("governance-log", gov_status, str(central.entry_count)) + for r in run_reports: + for c in r.chains: + status = "OK" if c.ok else f"BROKEN ({c.reason})" + table.add_row(f"{r.run_id}/{c.artifact_name}", status, str(c.entry_count)) + console.print(table) + console.print(f"\nOverall: {'OK' if all_ok else 'TAMPERING DETECTED'}") + + if not all_ok: + sys.exit(EXIT_RUNTIME_FAILURE) + + @office_group.command(name="artifacts") @click.option( "--project", @@ -3444,7 +3668,7 @@ def gateway_group() -> None: @gateway_group.command(name="start") @click.option("--host", default="127.0.0.1", show_default=True, help="Host address to bind.") @click.option("--port", default=8733, type=int, show_default=True, help="Port to bind.") -@click.option("--output-dir", default=".parallel-agents-output", show_default=True, help="Gateway state and artifact directory.") +@click.option("--output-dir", default=DEFAULT_ARTIFACT_DIR, show_default=True, help="Gateway state and artifact directory.") @click.option( "--api-key", default=None, @@ -3471,6 +3695,17 @@ def gateway_group() -> None: show_default=True, help="Allow write-class MCP tools over gateway /mcp endpoints.", ) +@click.option( + "--slack-signing-secret", + default=None, + help="Optional Slack signing secret. If omitted, PA_GATEWAY_SLACK_SIGNING_SECRET from env is used.", +) +@click.option( + "--allow-unsigned-slack/--no-allow-unsigned-slack", + default=False, + show_default=True, + help="Allow unsigned Slack events for local tunnel/testing only.", +) def gateway_start( host: str, port: int, @@ -3480,6 +3715,8 @@ def gateway_start( jwt_issuer: str | None, jwt_audience: str | None, allow_remote_write_tools: bool, + slack_signing_secret: str | None, + allow_unsigned_slack: bool, ) -> None: """Start the local gateway HTTP server.""" try: @@ -3498,16 +3735,189 @@ def gateway_start( jwt_issuer=jwt_issuer, jwt_audience=jwt_audience, allow_remote_write_tools=allow_remote_write_tools, + slack_signing_secret=slack_signing_secret, + slack_allow_unsigned=allow_unsigned_slack, ) except Exception as exc: click.echo(f"Failed to start gateway: {exc}", err=True) sys.exit(EXIT_RUNTIME_FAILURE) +@gateway_group.group(name="channel") +def gateway_channel_group() -> None: + """Operate the local gateway channel pairing adapter.""" + pass + + +@gateway_channel_group.command(name="inbound") +@click.option("--gateway-url", default=DEFAULT_GATEWAY_URL, show_default=True, help="Gateway base URL.") +@click.option("--api-key", default=None, help="Gateway API key, or PA_GATEWAY_API_KEY from env.") +@click.option("--channel", required=True, help="Channel adapter name, e.g. slack, discord, webhook.") +@click.option("--peer-id", required=True, help="Sender/user/conversation identifier.") +@click.option("--message", default="", help="Inbound message text.") +@click.option("--repo-path", default=None, help="Repository path to pass to the pipeline when executing.") +@click.option( + "--permission-mode", + default="plan", + show_default=True, + help="Pipeline permission mode when executing.", +) +@click.option("--execute/--no-execute", default=False, show_default=True, help="Enqueue a run after pairing.") +@click.option("--wait/--no-wait", default=False, show_default=True, help="Wait for an executed run to finish.") +@click.option("--json-output/--no-json-output", default=False, help="Print raw JSON.") +def gateway_channel_inbound( + gateway_url: str, + api_key: str | None, + channel: str, + peer_id: str, + message: str, + repo_path: str | None, + permission_mode: str, + execute: bool, + wait: bool, + json_output: bool, +) -> None: + """Submit a local inbound channel message to the gateway adapter.""" + payload = { + "channel": channel, + "peer_id": peer_id, + "message": message, + "repo_path": repo_path, + "permission_mode": permission_mode, + "execute": execute, + "wait": wait, + } + try: + result = _gateway_http_json( + gateway_url, + "POST", + "/channels/inbound", + payload, + api_key=api_key, + ) + except Exception as exc: + click.echo(f"Gateway channel inbound failed: {exc}", err=True) + sys.exit(EXIT_RUNTIME_FAILURE) + + if json_output: + click.echo(json.dumps(result, indent=2, default=str)) + return + + status = str(result.get("status", "unknown")) + border_style = "green" if status == "accepted" else "yellow" + lines = [ + f"Status: {status}", + f"Channel: {result.get('channel', channel)}", + f"Peer: {result.get('peer_id', peer_id)}", + f"Processed: {result.get('processed', False)}", + ] + if result.get("pairing_code"): + lines.append(f"Pairing code: {result['pairing_code']}") + lines.append(f"Expires: {result.get('expires_at', '-')}") + lines.append( + "Approve: " + f"parallel-agents gateway channel approve --code {result['pairing_code']}" + ) + run = result.get("run") + if isinstance(run, dict): + lines.append(f"Run: {run.get('id', '-')}") + lines.append(f"Run status: {run.get('status', '-')}") + console.print(Panel("\n".join(lines), title="Channel Inbound", border_style=border_style)) + + +@gateway_channel_group.command(name="approve") +@click.option("--gateway-url", default=DEFAULT_GATEWAY_URL, show_default=True, help="Gateway base URL.") +@click.option("--api-key", default=None, help="Gateway API key, or PA_GATEWAY_API_KEY from env.") +@click.option("--code", required=True, help="Pairing code to approve.") +@click.option("--approved-by", default="operator", show_default=True, help="Local approver label.") +@click.option("--json-output/--no-json-output", default=False, help="Print raw JSON.") +def gateway_channel_approve( + gateway_url: str, + api_key: str | None, + code: str, + approved_by: str, + json_output: bool, +) -> None: + """Approve a pending channel pairing code.""" + try: + result = _gateway_http_json( + gateway_url, + "POST", + "/channels/pairing/approve", + {"code": code, "approved_by": approved_by}, + api_key=api_key, + ) + except Exception as exc: + click.echo(f"Gateway channel approval failed: {exc}", err=True) + sys.exit(EXIT_RUNTIME_FAILURE) + + if json_output: + click.echo(json.dumps(result, indent=2, default=str)) + return + + console.print( + Panel( + "\n".join( + [ + f"Status: {result.get('status', 'unknown')}", + f"Channel: {result.get('channel', '-')}", + f"Peer: {result.get('peer_id', '-')}", + f"Approved by: {result.get('approved_by', approved_by)}", + f"Approved at: {result.get('approved_at', '-')}", + ] + ), + title="Channel Pairing", + border_style="green", + ) + ) + + +@gateway_channel_group.command(name="peers") +@click.option("--gateway-url", default=DEFAULT_GATEWAY_URL, show_default=True, help="Gateway base URL.") +@click.option("--api-key", default=None, help="Gateway API key, or PA_GATEWAY_API_KEY from env.") +@click.option("--channel", default=None, help="Optional channel filter.") +@click.option("--json-output/--no-json-output", default=False, help="Print raw JSON.") +def gateway_channel_peers( + gateway_url: str, + api_key: str | None, + channel: str | None, + json_output: bool, +) -> None: + """List approved channel peers.""" + path = "/channels/peers" + if channel: + path += "?" + urllib.parse.urlencode({"channel": channel}) + try: + result = _gateway_http_json(gateway_url, "GET", path, api_key=api_key) + except Exception as exc: + click.echo(f"Gateway channel peers failed: {exc}", err=True) + sys.exit(EXIT_RUNTIME_FAILURE) + + if json_output: + click.echo(json.dumps(result, indent=2, default=str)) + return + + table = Table(title="Approved Channel Peers") + table.add_column("Channel") + table.add_column("Peer") + table.add_column("Approved At") + table.add_column("Approved By") + for peer in result.get("peers", []): + if not isinstance(peer, dict): + continue + table.add_row( + str(peer.get("channel", "")), + str(peer.get("peer_id", "")), + str(peer.get("approved_at", "")), + str(peer.get("approved_by", "")), + ) + console.print(table) + + @cli.command() @click.argument("run_id") @click.option("--store", "-s", type=click.Choice(["file", "sqlite"]), default="file") -@click.option("--output-dir", default=".parallel-agents-output") +@click.option("--output-dir", default=DEFAULT_ARTIFACT_DIR) def show(run_id: str, store: str, output_dir: str) -> None: """View results of a previous run.""" evidence_store = create_evidence_store(output_dir, run_id, store) @@ -3545,7 +3955,7 @@ def show(run_id: str, store: str, output_dir: str) -> None: @cli.command() @click.option("--store", "-s", type=click.Choice(["file", "sqlite"]), default="file") -@click.option("--output-dir", default=".parallel-agents-output") +@click.option("--output-dir", default=DEFAULT_ARTIFACT_DIR) def history(store: str, output_dir: str) -> None: """List previous runs.""" if store == "sqlite": @@ -3833,6 +4243,44 @@ def _attach_run_metadata( entry["artifact_name"] = artifact_name +def _gateway_http_json( + gateway_url: str, + method: str, + path: str, + payload: dict[str, Any] | None = None, + *, + api_key: str | None = None, + timeout_seconds: float = 15.0, +) -> dict[str, Any]: + url = f"{gateway_url.rstrip('/')}/{path.lstrip('/')}" + data = None + headers = {"Accept": "application/json"} + if payload is not None: + data = json.dumps(payload).encode("utf-8") + headers["Content-Type"] = "application/json" + resolved_key = api_key or os.environ.get("PA_GATEWAY_API_KEY") + if resolved_key: + headers["X-PA-API-Key"] = resolved_key + request = urllib.request.Request( + url, + data=data, + method=method.upper(), + headers=headers, + ) + try: + with urllib.request.urlopen(request, timeout=timeout_seconds) as response: + body = response.read().decode("utf-8") + except urllib.error.HTTPError as exc: + detail = exc.read().decode("utf-8", errors="replace") + raise RuntimeError(f"Gateway HTTP {exc.code}: {detail}") from exc + if not body.strip(): + return {} + parsed = json.loads(body) + if not isinstance(parsed, dict): + raise RuntimeError("Gateway returned a non-object JSON response.") + return parsed + + def _fmt_percent(value: float | None) -> str: if value is None: return "n/a" diff --git a/src/parallel_agents/mcp_server.py b/src/parallel_agents/mcp_server.py index 4cd6e0e..b06601b 100644 --- a/src/parallel_agents/mcp_server.py +++ b/src/parallel_agents/mcp_server.py @@ -45,9 +45,10 @@ build_release_readiness_report, build_roadmap, create_product_brief, + issue_plan_items, recommend_tech_stack, ) -from parallel_agents.config import PipelineConfig +from parallel_agents.config import DEFAULT_ARTIFACT_DIR, PipelineConfig from parallel_agents.eval_harness import ( compute_evaluation_score, load_evaluation_results, @@ -91,6 +92,34 @@ ] +def _write_tools_enabled() -> bool: + """Whether tools that perform external/irreversible writes are allowed. + + Off by default: the MCP server is frequently wired into autonomous agents, + so live GitHub writes must be explicitly opted into via PA_ALLOW_WRITE_TOOLS + (set to 1/true/yes/on). Read analysis and the local plan/approve flow remain + available regardless; only the apply step that mutates a remote is gated. + """ + return os.environ.get("PA_ALLOW_WRITE_TOOLS", "").strip().lower() in { + "1", "true", "yes", "on", + } + + +def _write_tools_disabled_json(tool_name: str) -> str: + return json.dumps( + { + "error": True, + "error_type": "WriteToolsDisabled", + "message": ( + f"'{tool_name}' performs external writes and is disabled by default. " + "Set PA_ALLOW_WRITE_TOOLS=1 to enable it, or run the apply step from " + "the desktop Approvals flow." + ), + }, + indent=2, + ) + + @mcp.tool() async def tool_discovery(include_write: bool = True) -> str: """List available MCP tools with access level and approval requirements.""" @@ -102,8 +131,11 @@ async def tool_discovery(include_write: bool = True) -> str: "count": len(tools), "read_only_count": sum(1 for tool in tools if tool["access"] == "read"), "write_count": sum(1 for tool in tools if tool["access"] == "write"), + "write_tools_enabled": _write_tools_enabled(), "notes": [ "Write tools remain approval-gated by default.", + "company_apply performs live GitHub writes and is disabled unless " + "PA_ALLOW_WRITE_TOOLS=1.", "Use company_plan -> company_approve -> company_apply for controlled GitHub writes.", ], } @@ -319,7 +351,7 @@ async def company_idea( idea: str, title: str = "", run_id: str = "", - output_dir: str = ".parallel-agents-output", + output_dir: str = DEFAULT_ARTIFACT_DIR, ) -> str: """Create a ProductBrief artifact from a plain-language idea.""" try: @@ -339,7 +371,7 @@ async def company_stack( repo_path: str = "", focus: str = "AI software delivery workflow", run_id: str = "", - output_dir: str = ".parallel-agents-output", + output_dir: str = DEFAULT_ARTIFACT_DIR, ) -> str: """Recommend a technology stack based on repository signals.""" try: @@ -361,7 +393,7 @@ async def company_roadmap( horizon_weeks: int = 12, title: str = "", run_id: str = "", - output_dir: str = ".parallel-agents-output", + output_dir: str = DEFAULT_ARTIFACT_DIR, ) -> str: """Create a roadmap artifact from an idea.""" try: @@ -389,7 +421,7 @@ async def company_roadmap( async def company_release_check( repo_path: str = "", run_id: str = "", - output_dir: str = ".parallel-agents-output", + output_dir: str = DEFAULT_ARTIFACT_DIR, ) -> str: """Run release-readiness checks for a repository.""" try: @@ -412,7 +444,7 @@ async def company_plan( labels: str = "planning,ai-agents", create_milestones: bool = True, run_id: str = "", - output_dir: str = ".parallel-agents-output", + output_dir: str = DEFAULT_ARTIFACT_DIR, ) -> str: """Create an approval-gated GitHub issue plan from a roadmap JSON payload.""" try: @@ -456,7 +488,7 @@ async def company_approve( approver: str = "", approval_note: str = "", artifact: str = "issue-plan", - output_dir: str = ".parallel-agents-output", + output_dir: str = DEFAULT_ARTIFACT_DIR, ) -> str: """Approve a pending company issue plan artifact.""" try: @@ -498,10 +530,12 @@ async def company_approve( async def company_apply( run_id: str, artifact: str = "issue-plan", - output_dir: str = ".parallel-agents-output", + output_dir: str = DEFAULT_ARTIFACT_DIR, ) -> str: """Apply an approved company issue plan with policy validation before GitHub writes.""" try: + if not _write_tools_enabled(): + return _write_tools_disabled_json("company_apply") payload = load_company_artifact(output_dir, run_id, artifact) if not payload: return json.dumps({ @@ -520,7 +554,7 @@ async def company_apply( "message": "Artifact repo is invalid or missing.", }) owner, repo_name = parsed - issue_plan = payload.get("issue_plan") or [] + issue_plan = issue_plan_items(payload) policy = derive_policy_from_issue_plan(f"{owner}/{repo_name}", issue_plan) if isinstance(payload.get("apply_policy"), dict): policy = CompanyApplyPolicy.model_validate(payload["apply_policy"]) @@ -557,7 +591,7 @@ async def company_apply( @mcp.tool() async def company_artifacts( run_id: str, - output_dir: str = ".parallel-agents-output", + output_dir: str = DEFAULT_ARTIFACT_DIR, ) -> str: """List run-linked company artifacts.""" try: diff --git a/src/parallel_agents/onboarding.py b/src/parallel_agents/onboarding.py new file mode 100644 index 0000000..6f73500 --- /dev/null +++ b/src/parallel_agents/onboarding.py @@ -0,0 +1,229 @@ +from __future__ import annotations + +import os +import shutil +import subprocess +from pathlib import Path +from typing import Any + +from parallel_agents.project_office import ( + init_project_office, + resolve_project_root, + run_office_diagnostics, + run_office_setup_fix, +) + + +def build_onboarding_report( + project_root: str | Path | None = None, + *, + name: str | None = None, + fix_setup: bool = True, + check_github_auth: bool = True, +) -> dict[str, Any]: + """Prepare a project for the first local office run and report readiness. + + The report is intentionally deterministic and safe: it only creates/repairs + the local `.parallel-agents` workspace when `fix_setup` is true. It never + starts an LLM run or performs remote GitHub writes. + """ + root = resolve_project_root(project_root) + before = run_office_diagnostics(root) + actions_taken: list[str] = [] + + if fix_setup: + if name: + init_project_office(root, name=name) + actions_taken.append("initialized_or_updated_office_workspace") + else: + setup_result = run_office_setup_fix(root) + actions_taken.extend(list(setup_result.get("actions_taken") or [])) + elif name and bool(before.get("office_initialized")): + init_project_office(root, name=name) + actions_taken.append("updated_project_name") + + after = run_office_diagnostics(root) + llm = _llm_readiness() + github = _github_readiness(check_auth=check_github_auth) + blocking_failures = _blocking_failures(after) + + ready_for_local_run = not blocking_failures and llm["status"] == "passed" + ready_for_github_flow = ready_for_local_run and github["status"] == "passed" + status = ( + "ready" + if ready_for_github_flow + else "needs_github_auth" + if ready_for_local_run + else "needs_model_auth" + if not blocking_failures and llm["status"] != "passed" + else "blocked" + ) + + return { + "status": status, + "project_root": str(root), + "before": before, + "after": after, + "actions_taken": actions_taken, + "blocking_failures": blocking_failures, + "llm": llm, + "github": github, + "ready_for_local_run": ready_for_local_run, + "ready_for_github_flow": ready_for_github_flow, + "next_actions": _next_actions( + root, + blocking_failures=blocking_failures, + llm=llm, + github=github, + ), + } + + +def _blocking_failures(diagnostics: dict[str, Any]) -> list[dict[str, Any]]: + blockers: list[dict[str, Any]] = [] + for check in diagnostics.get("checks", []): + if not isinstance(check, dict): + continue + if str(check.get("status")) != "failed": + continue + name = str(check.get("name") or "") + if bool(check.get("required")) or name in { + "project-root", + "office-initialized", + "workspace-directories", + }: + blockers.append(check) + return blockers + + +def _llm_readiness() -> dict[str, Any]: + env_keys = [ + key + for key in ("ANTHROPIC_API_KEY", "PA_ANTHROPIC_API_KEY") + if os.environ.get(key) + ] + claude_path = shutil.which("claude") + if env_keys or claude_path: + detail_parts = [] + if env_keys: + detail_parts.append(f"env: {', '.join(env_keys)}") + if claude_path: + detail_parts.append(f"claude: {claude_path}") + return { + "status": "passed", + "detail": "; ".join(detail_parts), + "auth_modes": ["env"] if env_keys else [], + "claude_cli": claude_path, + } + return { + "status": "warning", + "detail": "No ANTHROPIC_API_KEY/PA_ANTHROPIC_API_KEY or Claude CLI found.", + "auth_modes": [], + "claude_cli": None, + } + + +def _github_readiness(*, check_auth: bool) -> dict[str, Any]: + gh_path = shutil.which("gh") + if not gh_path: + return { + "status": "warning", + "detail": "GitHub CLI not found; GitHub issue/PR flow is unavailable.", + "gh_path": None, + "authenticated": False, + } + if not check_auth: + return { + "status": "warning", + "detail": "GitHub CLI found; auth status was not checked.", + "gh_path": gh_path, + "authenticated": False, + } + + try: + proc = subprocess.run( + [gh_path, "auth", "status"], + text=True, + capture_output=True, + timeout=8, + ) + except Exception as exc: # noqa: BLE001 - readiness must degrade cleanly + return { + "status": "warning", + "detail": f"GitHub auth status check failed: {exc}", + "gh_path": gh_path, + "authenticated": False, + } + + output = "\n".join(part for part in [proc.stdout, proc.stderr] if part).strip() + return { + "status": "passed" if proc.returncode == 0 else "warning", + "detail": _truncate(output or "gh auth status returned no output"), + "gh_path": gh_path, + "authenticated": proc.returncode == 0, + } + + +def _next_actions( + root: Path, + *, + blocking_failures: list[dict[str, Any]], + llm: dict[str, Any], + github: dict[str, Any], +) -> list[dict[str, str]]: + actions: list[dict[str, str]] = [] + if blocking_failures: + actions.append( + { + "label": "Fix local blockers", + "command": f"parallel-agents office fix-setup --project {_quote(root)} --strict", + } + ) + if llm.get("status") != "passed": + actions.append( + { + "label": "Configure model auth", + "command": "claude --version # then authenticate Claude Code, or set ANTHROPIC_API_KEY", + } + ) + if github.get("status") != "passed": + actions.append( + { + "label": "Connect GitHub", + "command": "gh auth login", + } + ) + actions.extend( + [ + { + "label": "Run first safe analysis", + "command": ( + f"parallel-agents run --repo {_quote(root)} " + '"Review this project and propose a small safe PR"' + ), + }, + { + "label": "Open desktop office", + "command": "parallel-agents-desktop", + }, + { + "label": "Start local gateway", + "command": f"parallel-agents gateway start --output-dir {_quote(root / '.parallel-agents')}", + }, + ] + ) + return actions + + +def _quote(path: Path) -> str: + text = str(path) + if any(ch.isspace() for ch in text): + return f'"{text}"' + return text + + +def _truncate(value: str, limit: int = 600) -> str: + text = value.strip() + if len(text) <= limit: + return text + return f"{text[:limit].rstrip()}..." diff --git a/src/parallel_agents/pipeline.py b/src/parallel_agents/pipeline.py index 54e57c9..e7eab1d 100644 --- a/src/parallel_agents/pipeline.py +++ b/src/parallel_agents/pipeline.py @@ -6,10 +6,10 @@ import uuid from datetime import datetime, timezone from pathlib import Path -from typing import Callable +from typing import Any, Callable from parallel_agents.agents.judge import run_judge -from parallel_agents.agents.planner import run_planner +from parallel_agents.agents.planner import is_parse_failure, run_planner from parallel_agents.agents.splitter import split_tasks from parallel_agents.config import PipelineConfig from parallel_agents.cost_tracker import PipelineCostTracker @@ -86,20 +86,54 @@ async def run( raw_input: str, repo_path: str | None = None, on_status: Callable[[str], None] | None = None, + on_event: Callable[[dict[str, Any]], None] | None = None, + run_id: str | None = None, ) -> FinalOutput: task_input = self._build_task_input(raw_input, repo_path) - run_id = uuid.uuid4().hex[:12] + run_id = run_id or uuid.uuid4().hex[:12] store = create_evidence_store( self.config.output_dir, run_id, self.config.store_backend ) manifest = RunManifest(run_id=run_id, input=task_input) store.save_manifest(manifest) + try: + return await self._run_phases( + task_input, + run_id, + store, + manifest, + on_status, + on_event, + ) + except Exception: + # Persist FAILED so a crashed run is distinguishable from one + # still in flight when reading the evidence store later. + manifest.status = TaskStatus.FAILED + try: + store.save_manifest(manifest) + except Exception: + logger.exception("Could not persist FAILED status for run %s", run_id) + raise + async def _run_phases( + self, + task_input: TaskInput, + run_id: str, + store: BaseEvidenceStore, + manifest: RunManifest, + on_status: Callable[[str], None] | None, + on_event: Callable[[dict[str, Any]], None] | None, + ) -> FinalOutput: def _update_status(msg: str) -> None: logger.info(msg) if on_status: on_status(msg) + def _trace(agent_name: str, entry: dict[str, Any]) -> None: + store.append_trace(agent_name, entry) + if on_event: + on_event({"agent": agent_name, **entry}) + if task_input.github_url: _update_status("Resolving GitHub issue context...") issue = await fetch_issue(task_input.github_url) @@ -135,14 +169,14 @@ def _update_status(msg: str) -> None: # Phase 1: Planning _update_status("Planning: analyzing repository and creating task plan...") manifest.phases["planning"] = {"status": "running", "started_at": _now_iso()} - store.append_trace("pipeline", {"phase": "planning", "status": "started", "ts": _now_iso()}) + _trace("pipeline", {"phase": "planning", "status": "started", "ts": _now_iso()}) planning_started = datetime.now(timezone.utc) plan = await run_planner(task_input, self.config) planning_completed = datetime.now(timezone.utc) store.save_plan(plan) manifest.phases["planning"]["status"] = "completed" manifest.phases["planning"]["completed_at"] = _now_iso() - store.append_trace("pipeline", { + _trace("pipeline", { "phase": "planning", "status": "completed", "ts": _now_iso(), "subtask_count": len(plan.subtasks), }) @@ -156,6 +190,18 @@ def _update_status(msg: str) -> None: ) if not plan.subtasks: + if is_parse_failure(plan): + _update_status("Planner output failed to parse after retries. Marking run failed.") + manifest.status = TaskStatus.FAILED + store.save_manifest(manifest) + return FinalOutput( + summary="Planner failed to parse output after retries", + metadata={ + "run_id": run_id, + "plan_summary": plan.summary, + "error": "planner_parse_failure", + }, + ) _update_status("Planner produced no subtasks. Returning empty result.") manifest.status = TaskStatus.COMPLETED store.save_manifest(manifest) @@ -170,7 +216,7 @@ def _update_status(msg: str) -> None: batches = split_tasks(plan, self.config) manifest.phases["splitting"]["status"] = "completed" manifest.phases["splitting"]["completed_at"] = _now_iso() - store.append_trace("pipeline", { + _trace("pipeline", { "phase": "splitting", "status": "completed", "ts": _now_iso(), "batch_count": len(batches), "batches": [[s.assigned_worker for s in b] for b in batches], @@ -187,15 +233,20 @@ def _update_status(msg: str) -> None: for batch_idx, batch in enumerate(batches): workers_in_batch = [s.assigned_worker for s in batch] _update_status(f" Batch {batch_idx + 1}/{len(batches)}: {workers_in_batch}") - store.append_trace("pipeline", { + _trace("pipeline", { "phase": "execution", "batch": batch_idx + 1, "workers": workers_in_batch, "status": "started", "ts": _now_iso(), }) - batch_results = await self._run_batch(batch, plan, semaphore, store) + batch_results = await self._run_batch(batch, plan, semaphore, store, on_event) for result in batch_results: - all_results[result.worker_name] = result + # Key by worker name for the common one-subtask-per-worker case, + # but never silently overwrite a second subtask's result. + result_key = result.worker_name + if result_key in all_results: + result_key = f"{result.worker_name}:{result.subtask_id}" + all_results[result_key] = result store.save_worker_result(result) # Track cost @@ -211,14 +262,14 @@ def _update_status(msg: str) -> None: ), ) - store.append_trace(result.worker_name, { + _trace(result.worker_name, { "status": result.status, "findings": len(result.findings), "recommendations": len(result.recommendations), "ts": _now_iso(), }) - store.append_trace("pipeline", { + _trace("pipeline", { "phase": "execution", "batch": batch_idx + 1, "status": "completed", "ts": _now_iso(), "results": {r.worker_name: r.status for r in batch_results}, @@ -231,15 +282,14 @@ def _update_status(msg: str) -> None: # Phase 4: Judging _update_status("Judge is merging results and producing final output...") manifest.phases["judging"] = {"status": "running", "started_at": _now_iso()} - store.append_trace("pipeline", {"phase": "judging", "status": "started", "ts": _now_iso()}) + _trace("pipeline", {"phase": "judging", "status": "started", "ts": _now_iso()}) judging_started = datetime.now(timezone.utc) final_output = await run_judge(plan, all_results, self.config) _validate_and_normalize_patch(final_output) judging_completed = datetime.now(timezone.utc) - store.save_final_output(final_output) manifest.phases["judging"]["status"] = "completed" manifest.phases["judging"]["completed_at"] = _now_iso() - store.append_trace("pipeline", { + _trace("pipeline", { "phase": "judging", "status": "completed", "ts": _now_iso(), "risk_items": len(final_output.risk_report), "has_patch": final_output.patch is not None, @@ -258,6 +308,7 @@ def _update_status(msg: str) -> None: manifest.total_cost_usd = cost_summary["total_cost_usd"] final_output.metadata["cost"] = cost_summary final_output.metadata["run_id"] = run_id + store.save_final_output(final_output) manifest.status = TaskStatus.COMPLETED store.save_manifest(manifest) @@ -275,7 +326,13 @@ async def _run_batch( plan: TaskPlan, semaphore: asyncio.Semaphore, store: BaseEvidenceStore, + on_event: Callable[[dict[str, Any]], None] | None, ) -> list[WorkerResult]: + def _trace(agent_name: str, entry: dict[str, Any]) -> None: + store.append_trace(agent_name, entry) + if on_event: + on_event({"agent": agent_name, **entry}) + async def _run_one(subtask: Subtask) -> WorkerResult: async with semaphore: worker_cls = WORKER_REGISTRY.get(subtask.assigned_worker) @@ -297,7 +354,7 @@ async def _run_one(subtask: Subtask) -> WorkerResult: ) worker = worker_cls(worker_config) - store.append_trace(worker.name, { + _trace(worker.name, { "event": "worker_started", "subtask_id": subtask.id, "ts": _now_iso(), @@ -311,7 +368,7 @@ async def _run_one(subtask: Subtask) -> WorkerResult: timeout=worker_config.timeout_seconds, ) - store.append_trace(worker.name, { + _trace(worker.name, { "event": "worker_completed", "subtask_id": subtask.id, "status": result.status, diff --git a/tests/test_cli.py b/tests/test_cli.py index 1e5a505..e757ee8 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1897,6 +1897,8 @@ def fake_run_gateway_server( jwt_issuer, jwt_audience, allow_remote_write_tools, + slack_signing_secret, + slack_allow_unsigned, ): captured["host"] = host captured["port"] = port @@ -1906,6 +1908,8 @@ def fake_run_gateway_server( captured["jwt_issuer"] = jwt_issuer captured["jwt_audience"] = jwt_audience captured["allow_remote_write_tools"] = allow_remote_write_tools + captured["slack_signing_secret"] = slack_signing_secret + captured["slack_allow_unsigned"] = slack_allow_unsigned import parallel_agents.gateway as gateway_module @@ -1925,6 +1929,8 @@ def fake_run_gateway_server( assert captured["jwt_issuer"] is None assert captured["jwt_audience"] is None assert captured["allow_remote_write_tools"] is False + assert captured["slack_signing_secret"] is None + assert captured["slack_allow_unsigned"] is False def test_gateway_start_passes_api_key(monkeypatch, tmp_path): @@ -1940,6 +1946,8 @@ def fake_run_gateway_server( jwt_issuer, jwt_audience, allow_remote_write_tools, + slack_signing_secret, + slack_allow_unsigned, ): captured["host"] = host captured["port"] = port @@ -1949,6 +1957,8 @@ def fake_run_gateway_server( captured["jwt_issuer"] = jwt_issuer captured["jwt_audience"] = jwt_audience captured["allow_remote_write_tools"] = allow_remote_write_tools + captured["slack_signing_secret"] = slack_signing_secret + captured["slack_allow_unsigned"] = slack_allow_unsigned import parallel_agents.gateway as gateway_module @@ -1981,6 +1991,8 @@ def fake_run_gateway_server( jwt_issuer, jwt_audience, allow_remote_write_tools, + slack_signing_secret, + slack_allow_unsigned, ): captured["host"] = host captured["port"] = port @@ -1990,6 +2002,8 @@ def fake_run_gateway_server( captured["jwt_issuer"] = jwt_issuer captured["jwt_audience"] = jwt_audience captured["allow_remote_write_tools"] = allow_remote_write_tools + captured["slack_signing_secret"] = slack_signing_secret + captured["slack_allow_unsigned"] = slack_allow_unsigned import parallel_agents.gateway as gateway_module @@ -2031,6 +2045,8 @@ def fake_run_gateway_server( jwt_issuer, jwt_audience, allow_remote_write_tools, + slack_signing_secret, + slack_allow_unsigned, ): captured["host"] = host captured["port"] = port @@ -2040,6 +2056,8 @@ def fake_run_gateway_server( captured["jwt_issuer"] = jwt_issuer captured["jwt_audience"] = jwt_audience captured["allow_remote_write_tools"] = allow_remote_write_tools + captured["slack_signing_secret"] = slack_signing_secret + captured["slack_allow_unsigned"] = slack_allow_unsigned import parallel_agents.gateway as gateway_module @@ -2060,6 +2078,181 @@ def fake_run_gateway_server( assert captured["allow_remote_write_tools"] is True +def test_gateway_start_passes_slack_options(monkeypatch, tmp_path): + captured: dict[str, object] = {} + + def fake_run_gateway_server( + *, + host, + port, + output_dir, + api_key, + jwt_secret, + jwt_issuer, + jwt_audience, + allow_remote_write_tools, + slack_signing_secret, + slack_allow_unsigned, + ): + captured["slack_signing_secret"] = slack_signing_secret + captured["slack_allow_unsigned"] = slack_allow_unsigned + + import parallel_agents.gateway as gateway_module + + monkeypatch.setattr(gateway_module, "run_gateway_server", fake_run_gateway_server) + runner = _runner() + result = runner.invoke( + main_module.cli, + [ + "gateway", + "start", + "--output-dir", + str(tmp_path), + "--slack-signing-secret", + "slack-secret", + "--allow-unsigned-slack", + ], + ) + + assert result.exit_code == 0 + assert captured["slack_signing_secret"] == "slack-secret" + assert captured["slack_allow_unsigned"] is True + + +def test_gateway_channel_inbound_json(monkeypatch): + captured: dict[str, object] = {} + + def fake_gateway_http_json(gateway_url, method, path, payload=None, *, api_key=None, timeout_seconds=15.0): + captured["gateway_url"] = gateway_url + captured["method"] = method + captured["path"] = path + captured["payload"] = payload + captured["api_key"] = api_key + return { + "status": "pairing_required", + "processed": False, + "channel": "slack", + "peer_id": "U123", + "pairing_code": "ABC123", + "expires_at": "2026-06-13T12:00:00+00:00", + } + + monkeypatch.setattr(main_module, "_gateway_http_json", fake_gateway_http_json) + runner = _runner() + + result = runner.invoke( + main_module.cli, + [ + "gateway", + "channel", + "inbound", + "--gateway-url", + "http://localhost:9999", + "--api-key", + "secret", + "--channel", + "slack", + "--peer-id", + "U123", + "--message", + "Review this repo", + "--execute", + "--json-output", + ], + ) + + assert result.exit_code == 0 + assert captured["gateway_url"] == "http://localhost:9999" + assert captured["method"] == "POST" + assert captured["path"] == "/channels/inbound" + assert captured["api_key"] == "secret" + payload = captured["payload"] + assert payload["channel"] == "slack" + assert payload["peer_id"] == "U123" + assert payload["message"] == "Review this repo" + assert payload["execute"] is True + output = json.loads(result.output) + assert output["pairing_code"] == "ABC123" + + +def test_gateway_channel_approve_json(monkeypatch): + captured: dict[str, object] = {} + + def fake_gateway_http_json(gateway_url, method, path, payload=None, *, api_key=None, timeout_seconds=15.0): + captured["method"] = method + captured["path"] = path + captured["payload"] = payload + return { + "status": "approved", + "channel": "slack", + "peer_id": "U123", + "approved_at": "2026-06-13T12:00:00+00:00", + "approved_by": "operator", + } + + monkeypatch.setattr(main_module, "_gateway_http_json", fake_gateway_http_json) + runner = _runner() + + result = runner.invoke( + main_module.cli, + [ + "gateway", + "channel", + "approve", + "--code", + "ABC123", + "--approved-by", + "operator", + "--json-output", + ], + ) + + assert result.exit_code == 0 + assert captured["method"] == "POST" + assert captured["path"] == "/channels/pairing/approve" + assert captured["payload"] == {"code": "ABC123", "approved_by": "operator"} + assert json.loads(result.output)["status"] == "approved" + + +def test_gateway_channel_peers_json(monkeypatch): + captured: dict[str, object] = {} + + def fake_gateway_http_json(gateway_url, method, path, payload=None, *, api_key=None, timeout_seconds=15.0): + captured["method"] = method + captured["path"] = path + return { + "peers": [ + { + "channel": "slack", + "peer_id": "U123", + "approved_at": "2026-06-13T12:00:00+00:00", + "approved_by": "operator", + } + ], + "count": 1, + } + + monkeypatch.setattr(main_module, "_gateway_http_json", fake_gateway_http_json) + runner = _runner() + + result = runner.invoke( + main_module.cli, + [ + "gateway", + "channel", + "peers", + "--channel", + "slack/team a", + "--json-output", + ], + ) + + assert result.exit_code == 0 + assert captured["method"] == "GET" + assert captured["path"] == "/channels/peers?channel=slack%2Fteam+a" + assert json.loads(result.output)["count"] == 1 + + def test_release_verify_json_success(monkeypatch, tmp_path): expected = { "project_root": str(tmp_path), @@ -2514,3 +2707,57 @@ def test_office_memory_requires_initialized_workspace(tmp_path): ) assert result.exit_code == main_module.EXIT_RUNTIME_FAILURE assert "Project office is not initialized" in result.output + + +def test_office_onboard_json_initializes_workspace(tmp_path, monkeypatch): + monkeypatch.setenv("ANTHROPIC_API_KEY", "test-key") + monkeypatch.setattr("parallel_agents.project_office.shutil.which", lambda name: f"/usr/bin/{name}") + monkeypatch.setattr("parallel_agents.onboarding.shutil.which", lambda name: f"/usr/bin/{name}") + runner = _runner() + + result = runner.invoke( + main_module.cli, + [ + "office", + "onboard", + "--project", + str(tmp_path), + "--name", + "Demo Office", + "--skip-github-auth-check", + "--json-output", + ], + ) + + assert result.exit_code == 0 + payload = json.loads(result.output) + assert payload["project_root"] == str(tmp_path) + assert payload["llm"]["status"] == "passed" + assert payload["ready_for_local_run"] is True + assert (tmp_path / ".parallel-agents" / "project.json").exists() + assert any(item["label"] == "Run first safe analysis" for item in payload["next_actions"]) + + +def test_office_onboard_strict_fails_without_model_auth(tmp_path, monkeypatch): + monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False) + monkeypatch.delenv("PA_ANTHROPIC_API_KEY", raising=False) + monkeypatch.setattr("parallel_agents.project_office.shutil.which", lambda name: None if name == "claude" else "tool") + monkeypatch.setattr("parallel_agents.onboarding.shutil.which", lambda name: None if name == "claude" else "tool") + runner = _runner() + + result = runner.invoke( + main_module.cli, + [ + "office", + "onboard", + "--project", + str(tmp_path), + "--skip-github-auth-check", + "--strict", + "--json-output", + ], + ) + + assert result.exit_code == main_module.EXIT_RUNTIME_FAILURE + payload = json.loads(result.output) + assert payload["status"] == "needs_model_auth" diff --git a/tests/test_company_workflows.py b/tests/test_company_workflows.py index 771c594..da71cb6 100644 --- a/tests/test_company_workflows.py +++ b/tests/test_company_workflows.py @@ -10,11 +10,26 @@ build_roadmap, build_sprint_plan, create_product_brief, + issue_plan_items, recommend_tech_stack, render_pr_summary, ) +def test_issue_plan_items_prefers_normalized_then_falls_back(): + # network artifact: normalized list under issue_plan is preferred + network = {"issue_plan": [{"title": "a"}], "issues": [{"title": "tracking-only"}]} + assert issue_plan_items(network) == [{"title": "a"}] + + # desktop artifact: only the issues key (full dump) is used as fallback + desktop = {"issues": [{"title": "b", "body": "x"}]} + assert issue_plan_items(desktop) == [{"title": "b", "body": "x"}] + + # empty / malformed shapes degrade to an empty list + assert issue_plan_items({}) == [] + assert issue_plan_items({"issue_plan": None, "issues": "nope"}) == [] + + def test_create_product_brief_normalizes_input(): brief = create_product_brief(" build a no-code release assistant ") assert brief.title == "Build A No-Code Release Assistant" diff --git a/tests/test_config.py b/tests/test_config.py index 5715249..7edcefa 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -2,7 +2,13 @@ from __future__ import annotations -from parallel_agents.config import PipelineConfig, WorkerConfig +from parallel_agents.config import ( + DEFAULT_ARTIFACT_DIR, + LEGACY_ARTIFACT_DIR, + PipelineConfig, + WorkerConfig, + migrate_legacy_artifact_dir, +) class TestWorkerConfig: @@ -56,3 +62,37 @@ def test_custom_workers(self): def test_store_backend_option(self): config = PipelineConfig(store_backend="sqlite") assert config.store_backend == "sqlite" + + def test_output_dir_defaults_to_unified_root(self): + config = PipelineConfig() + assert config.output_dir == DEFAULT_ARTIFACT_DIR == ".parallel-agents" + + +class TestMigrateLegacyArtifactDir: + def test_no_legacy_dir_is_noop(self, tmp_path): + result = migrate_legacy_artifact_dir(tmp_path) + assert result["migrated"] is False + assert result["reason"] == "no-legacy-dir" + + def test_renames_legacy_onto_unified_root(self, tmp_path): + legacy = tmp_path / LEGACY_ARTIFACT_DIR + legacy.mkdir() + (legacy / "marker.txt").write_text("keep me", encoding="utf-8") + + result = migrate_legacy_artifact_dir(tmp_path) + assert result["migrated"] is True + target = tmp_path / DEFAULT_ARTIFACT_DIR + assert target.exists() + assert (target / "marker.txt").read_text(encoding="utf-8") == "keep me" + assert not legacy.exists() + + def test_refuses_when_target_exists(self, tmp_path): + (tmp_path / LEGACY_ARTIFACT_DIR).mkdir() + (tmp_path / DEFAULT_ARTIFACT_DIR).mkdir() + + result = migrate_legacy_artifact_dir(tmp_path) + assert result["migrated"] is False + assert result["reason"] == "target-exists" + # never merges or overwrites: both dirs remain untouched + assert (tmp_path / LEGACY_ARTIFACT_DIR).exists() + assert (tmp_path / DEFAULT_ARTIFACT_DIR).exists() diff --git a/tests/test_desktop_engine_service.py b/tests/test_desktop_engine_service.py index bbc74b1..d8c9824 100644 --- a/tests/test_desktop_engine_service.py +++ b/tests/test_desktop_engine_service.py @@ -8,6 +8,7 @@ from parallel_agents.desktop.services import engine as engine_module from parallel_agents.desktop.services.engine import EngineService +from parallel_agents.company_artifacts import persist_company_artifact from parallel_agents.eval_harness import ( EvaluationAggregate, EvaluationAnnotations, @@ -22,6 +23,11 @@ from parallel_agents.project_office import office_dir +@pytest.fixture(autouse=True) +def disable_desktop_gateway_auto(monkeypatch): + monkeypatch.setenv("PA_DESKTOP_USE_GATEWAY", "0") + + def test_run_pipeline_persists_final_output_and_audit(tmp_path, monkeypatch): status_messages: list[str] = [] @@ -29,11 +35,19 @@ class FakePipeline: def __init__(self, config) -> None: self.config = config - async def run(self, task: str, repo_path: str | None = None, on_status=None): + async def run( + self, + task: str, + repo_path: str | None = None, + on_status=None, + on_event=None, + ): assert task == "Run a full review" assert repo_path == str(tmp_path.resolve()) if on_status is not None: on_status("Planning: fake planner step") + if on_event is not None: + on_event({"agent": "pipeline", "phase": "planning", "status": "started"}) return FinalOutput( summary="Synthetic final summary", worker_results={ @@ -84,17 +98,57 @@ async def run(self, task: str, repo_path: str | None = None, on_status=None): assert audit_path.exists() lines = [line for line in audit_path.read_text(encoding="utf-8").splitlines() if line] assert lines, "expected at least one audit event" - latest = json.loads(lines[-1]) + # events.jsonl is hash-chained: the event fields live under "payload". + latest = json.loads(lines[-1])["payload"] assert latest["run_id"] == "run-test-001" assert latest["event"] == "run.execute" +def test_run_pipeline_emits_structured_events(tmp_path, monkeypatch): + events: list[dict] = [] + + class FakePipeline: + def __init__(self, config) -> None: + self.config = config + + async def run( + self, + task: str, + repo_path: str | None = None, + on_status=None, + on_event=None, + ): + if on_event is not None: + on_event({"agent": "security", "event": "worker_started"}) + return FinalOutput( + summary="Synthetic final summary", + metadata={ + "run_id": "run-events-001", + "cost": {"total_tokens": 0, "total_cost_usd": 0.0}, + }, + ) + + monkeypatch.setattr(engine_module, "Pipeline", FakePipeline) + + service = EngineService() + service.open_project(tmp_path) + asyncio.run(service.run_pipeline("Run with events", on_event=events.append)) + + assert events == [{"agent": "security", "event": "worker_started"}] + + def test_run_pipeline_requires_run_id(tmp_path, monkeypatch): class FakePipeline: def __init__(self, config) -> None: self.config = config - async def run(self, task: str, repo_path: str | None = None, on_status=None): + async def run( + self, + task: str, + repo_path: str | None = None, + on_status=None, + on_event=None, + ): return FinalOutput(summary="Missing run id", metadata={}) monkeypatch.setattr(engine_module, "Pipeline", FakePipeline) @@ -106,6 +160,335 @@ async def run(self, task: str, repo_path: str | None = None, on_status=None): asyncio.run(service.run_pipeline("Run without id")) +def test_run_pipeline_uses_gateway_when_available(tmp_path, monkeypatch): + calls: list[tuple[str, str, dict | None]] = [] + status_messages: list[str] = [] + events: list[dict] = [] + + def fake_gateway_url(): + return "http://127.0.0.1:8733" + + def fake_gateway_request(base_url, method, path, payload=None, *, timeout_seconds): + calls.append((method, path, payload)) + if path == "/health": + return {"status": "ok"} + if method == "POST" and path == "/runs/pipeline": + assert payload["task"] == "Run through gateway" + assert payload["repo_path"] == str(tmp_path.resolve()) + return {"id": payload["run_id"], "status": "queued"} + if path.endswith("/events"): + return { + "events": [ + { + "id": 1, + "event": "pipeline_status", + "payload": {"message": "Planning: gateway step"}, + }, + { + "id": 2, + "event": "pipeline_trace", + "payload": { + "agent": "pipeline", + "phase": "planning", + "status": "started", + }, + } + ] + } + if path.startswith("/runs/"): + run_id = path.split("/")[2] + return { + "id": run_id, + "status": "succeeded", + "payload": { + "artifact_path": str( + office_dir(tmp_path) / run_id / "company" / "final-output.json" + ), + "artifact": { + "summary": "Gateway final summary", + "metadata": { + "run_id": run_id, + "cost": {"total_tokens": 12, "total_cost_usd": 0.25}, + }, + "worker_results": { + "security": {"status": "success"}, + "review": {"status": "success"}, + }, + "patch": None, + }, + }, + } + raise AssertionError(f"unexpected gateway call: {method} {path}") + + class ShouldNotRunPipeline: + def __init__(self, config) -> None: + raise AssertionError("direct pipeline fallback should not run") + + monkeypatch.setattr(engine_module, "_desktop_gateway_url", fake_gateway_url) + monkeypatch.setattr(engine_module, "_gateway_json_request", fake_gateway_request) + monkeypatch.setattr(engine_module, "Pipeline", ShouldNotRunPipeline) + + service = EngineService() + service.open_project(tmp_path) + result = asyncio.run( + service.run_pipeline( + "Run through gateway", + on_status=status_messages.append, + on_event=events.append, + ) + ) + + assert result.summary == "Gateway final summary" + assert result.total_tokens == 12 + assert result.total_cost_usd == 0.25 + assert result.worker_statuses == {"security": "success", "review": "success"} + assert status_messages == ["Planning: gateway step"] + assert events == [{"agent": "pipeline", "phase": "planning", "status": "started"}] + assert any(call[1] == "/runs/pipeline" for call in calls) + + +def test_start_and_stop_gateway_process(tmp_path, monkeypatch): + process_ref: dict[str, object] = {} + + class FakeProcess: + pid = 4321 + exitcode = None + + def __init__(self) -> None: + self.started = False + self.terminated = False + + def start(self) -> None: + self.started = True + + def is_alive(self) -> bool: + return self.started and not self.terminated + + def terminate(self) -> None: + self.terminated = True + self.exitcode = 0 + + def join(self, timeout=None) -> None: + return None + + def fake_start_process(**kwargs): + assert kwargs["output_dir"] == office_dir(tmp_path) + assert kwargs["host"] == "127.0.0.1" + assert kwargs["port"] == 8733 + process = FakeProcess() + process_ref["process"] = process + return process + + def fake_gateway_request(base_url, method, path, payload=None, *, timeout_seconds): + process = process_ref.get("process") + if ( + path == "/health" + and process is not None + and process.is_alive() + ): + return {"status": "ok"} + raise RuntimeError("not running") + + monkeypatch.setattr(engine_module, "_start_desktop_gateway_process", fake_start_process) + monkeypatch.setattr(engine_module, "_gateway_json_request", fake_gateway_request) + + service = EngineService() + service.open_project(tmp_path) + + started = service.start_gateway() + assert started.running is True + assert started.owned is True + assert started.pid == 4321 + + stopped = service.stop_gateway() + assert stopped.running is False + assert process_ref["process"].terminated is True + + +def test_start_gateway_reuses_external_gateway(tmp_path, monkeypatch): + def should_not_start(**kwargs): + raise AssertionError("should not start a process when gateway is already healthy") + + def fake_gateway_request(base_url, method, path, payload=None, *, timeout_seconds): + assert path == "/health" + return {"status": "ok"} + + monkeypatch.setattr(engine_module, "_start_desktop_gateway_process", should_not_start) + monkeypatch.setattr(engine_module, "_gateway_json_request", fake_gateway_request) + + service = EngineService() + service.open_project(tmp_path) + status = service.start_gateway() + + assert status.running is True + assert status.owned is False + assert status.source == "external" + + +def test_run_pipeline_with_patch_creates_final_output_approval(tmp_path, monkeypatch): + class FakePipeline: + def __init__(self, config) -> None: + self.config = config + + async def run( + self, + task: str, + repo_path: str | None = None, + on_status=None, + on_event=None, + ): + return FinalOutput( + summary="Patch needs review", + patch="--- a/file.py\n+++ b/file.py\n@@ -1 +1 @@\n-old\n+new\n", + metadata={ + "run_id": "run-patch-review", + "cost": {"total_tokens": 1, "total_cost_usd": 0.01}, + }, + ) + + monkeypatch.setattr(engine_module, "Pipeline", FakePipeline) + + service = EngineService() + service.open_project(tmp_path) + result = asyncio.run(service.run_pipeline("Generate patch")) + + assert result.run_id == "run-patch-review" + approvals = service.list_pending_approvals() + assert len(approvals) == 1 + approval = approvals[0]["data"] + assert approval["artifact"] == "final-output" + assert approval["title"] == "Review generated patch before PR" + + +def test_create_pull_request_requires_approved_generated_patch(tmp_path): + service = EngineService() + service.open_project(tmp_path) + persist_company_artifact( + office_dir(tmp_path), + "run-needs-review", + "final-output", + { + "summary": "Patch summary", + "patch": "--- a/file.py\n+++ b/file.py\n@@ -1 +1 @@\n-old\n+new\n", + "metadata": {"run_id": "run-needs-review"}, + }, + ) + + with pytest.raises(PermissionError, match="reviewed before PR creation"): + asyncio.run( + service.create_pull_request_async( + "run-needs-review", + repo_ref="owner/repo", + head="feature/review", + ) + ) + + +def test_create_pull_request_rejects_changed_patch_after_approval(tmp_path): + service = EngineService() + service.open_project(tmp_path) + artifact_path = persist_company_artifact( + office_dir(tmp_path), + "run-changed-review", + "final-output", + { + "summary": "Original patch", + "patch": "--- a/file.py\n+++ b/file.py\n@@ -1 +1 @@\n-old\n+new\n", + "metadata": {"run_id": "run-changed-review"}, + }, + ) + approval_path = service._create_patch_review_approval( + run_id="run-changed-review", + artifact_path=artifact_path, + summary="Original patch", + ) + assert approval_path is not None + service.approve(approval_path, "tester", "Reviewed original patch") + persist_company_artifact( + office_dir(tmp_path), + "run-changed-review", + "final-output", + { + "summary": "Changed patch", + "patch": "--- a/file.py\n+++ b/file.py\n@@ -1 +1 @@\n-old\n+different\n", + "metadata": {"run_id": "run-changed-review"}, + }, + ) + + with pytest.raises(PermissionError, match="changed after approval"): + asyncio.run( + service.create_pull_request_async( + "run-changed-review", + repo_ref="owner/repo", + head="feature/changed-review", + ) + ) + + +def test_create_pull_request_allows_approved_generated_patch(tmp_path, monkeypatch): + service = EngineService() + service.open_project(tmp_path) + artifact_path = persist_company_artifact( + office_dir(tmp_path), + "run-approved-review", + "final-output", + { + "summary": "Approved patch", + "patch": "--- a/file.py\n+++ b/file.py\n@@ -1 +1 @@\n-old\n+new\n", + "risk_report": [], + "metadata": {"run_id": "run-approved-review", "tests_run": []}, + }, + ) + approval_path = service._create_patch_review_approval( + run_id="run-approved-review", + artifact_path=artifact_path, + summary="Approved patch", + ) + assert approval_path is not None + service.approve(approval_path, "tester", "Reviewed generated patch") + + async def fake_create_pr(owner, repo, title, body, *, head, base="main", draft=True): + assert (owner, repo) == ("owner", "repo") + assert head == "feature/approved-review" + assert base == "main" + assert draft is True + assert "Approved patch" in body + return "https://github.com/owner/repo/pull/123" + + monkeypatch.setattr("parallel_agents.tools.github_tools.create_pr", fake_create_pr) + + result = asyncio.run( + service.create_pull_request_async( + "run-approved-review", + repo_ref="owner/repo", + head="feature/approved-review", + ) + ) + + assert result.url == "https://github.com/owner/repo/pull/123" + assert result.head == "feature/approved-review" + + +def test_suggest_pr_branch_uses_artifact_title_when_on_main(tmp_path, monkeypatch): + service = EngineService() + service.open_project(tmp_path) + persist_company_artifact( + office_dir(tmp_path), + "run-branch", + "brief", + { + "title": "Ship Safer PR Flow", + "problem_statement": "Improve PR guidance.", + }, + ) + monkeypatch.setattr(EngineService, "_git_current_branch", staticmethod(lambda root: "main")) + + branch = service.suggest_pr_branch("run-branch") + + assert branch.startswith("pa/run-branch-") + assert "ship-safer-pr-flow" in branch + + def test_workspace_home_includes_office_diagnostics(tmp_path, monkeypatch): service = EngineService() service.open_project(tmp_path) @@ -199,6 +582,25 @@ def fake_run_office_setup_fix(project_root): assert result.suggested_commands == ["npm --version", "claude --version"] +def test_onboard_project_uses_shared_onboarding_report(tmp_path, monkeypatch): + service = EngineService() + service.open_project(tmp_path) + + def fake_build_onboarding_report(project_root, *, name, fix_setup, check_github_auth): + assert project_root == tmp_path.resolve() + assert name == tmp_path.name + assert fix_setup is True + assert check_github_auth is False + return {"status": "needs_github_auth", "ready_for_local_run": True} + + monkeypatch.setattr(engine_module, "build_onboarding_report", fake_build_onboarding_report) + + result = service.onboard_project() + + assert result["status"] == "needs_github_auth" + assert result["ready_for_local_run"] is True + + def test_github_auth_status_authenticated(tmp_path, monkeypatch): service = EngineService() service.open_project(tmp_path) diff --git a/tests/test_desktop_governance.py b/tests/test_desktop_governance.py new file mode 100644 index 0000000..f2b8bfb --- /dev/null +++ b/tests/test_desktop_governance.py @@ -0,0 +1,302 @@ +"""Tests for Phase 3 governance: hash-chained audit, content-digest binding, +supersede-on-regenerate, path-traversal sanitization, and chain verification.""" + +from __future__ import annotations + +import json + +import pytest + +from parallel_agents.company_artifacts import ( + append_company_artifact_event, + append_hash_chained_line, + load_company_artifact_events, + persist_company_artifact, + verify_company_artifact_chain, + verify_hash_chain, + verify_run_audit_chains, +) +from parallel_agents.desktop.services.engine import EngineService + + +@pytest.fixture(autouse=True) +def _no_llm(monkeypatch): + monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False) + + +def _new_engine(tmp_path): + eng = EngineService() + eng.init_project(str(tmp_path), name="Gov") + return eng + + +# -- path traversal ----------------------------------------------------------- + +def test_persist_rejects_traversal_run_id(tmp_path): + with pytest.raises(ValueError): + persist_company_artifact(tmp_path, "../../escape", "brief", {"x": 1}) + + +def test_append_event_rejects_traversal_artifact_name(tmp_path): + with pytest.raises(ValueError): + append_company_artifact_event(tmp_path, "run-1", "../../evil", {"event": "x"}) + + +def test_read_side_rejects_traversal_run_id(tmp_path): + # Read paths must sanitize too — they are reachable from HTTP payloads. + with pytest.raises(ValueError): + load_company_artifact_events(tmp_path, "../../escape", "issue-plan") + + +@pytest.mark.parametrize("name", ["CON", "nul", "COM1", "LPT9", "aux.json"]) +def test_rejects_windows_reserved_device_names(tmp_path, name): + with pytest.raises(ValueError): + persist_company_artifact(tmp_path, name, "brief", {"x": 1}) + + +# -- HMAC keying + sequence binding ------------------------------------------ + +def test_hmac_keying_detects_full_rewrite(tmp_path, monkeypatch): + monkeypatch.setenv("PA_AUDIT_HMAC_KEY", "s3cret-verifier-key") + log = tmp_path / "events.jsonl" + append_hash_chained_line(log, {"event": "a"}) + append_hash_chained_line(log, {"event": "b"}) + assert verify_hash_chain(log).ok + + # An attacker without the key forges entry 0 and recomputes the *unkeyed* + # chain. With the key configured, verification still rejects it. + from parallel_agents.company_artifacts import _compute_entry_hash + + monkeypatch.delenv("PA_AUDIT_HMAC_KEY", raising=False) + forged_payload = {"event": "forged"} + forged_hash = _compute_entry_hash(0, "2099-01-01T00:00:00+00:00", forged_payload, None) + log.write_text( + json.dumps( + { + "seq": 0, + "timestamp": "2099-01-01T00:00:00+00:00", + "hash": forged_hash, + "previous_hash": None, + "payload": forged_payload, + } + ) + + "\n", + encoding="utf-8", + ) + monkeypatch.setenv("PA_AUDIT_HMAC_KEY", "s3cret-verifier-key") + assert not verify_hash_chain(log).ok + + +def test_inserted_entry_breaks_sequence(tmp_path): + log = tmp_path / "events.jsonl" + e0 = append_hash_chained_line(log, {"event": "a"}) + append_hash_chained_line(log, {"event": "b"}) + + # Re-insert entry 0 a second time: linkage looks fine but seq repeats. + lines = log.read_text(encoding="utf-8").splitlines() + lines.insert(1, json.dumps(e0)) + log.write_text("\n".join(lines) + "\n", encoding="utf-8") + + result = verify_hash_chain(log) + assert not result.ok + + +def test_append_refuses_on_broken_chain(tmp_path): + log = tmp_path / "events.jsonl" + append_hash_chained_line(log, {"event": "a"}) + # Corrupt the only entry, then try to append: must refuse, not silently chain. + entry = json.loads(log.read_text(encoding="utf-8").splitlines()[0]) + entry["payload"]["event"] = "TAMPERED" + log.write_text(json.dumps(entry) + "\n", encoding="utf-8") + + with pytest.raises(RuntimeError, match="broken audit chain"): + append_hash_chained_line(log, {"event": "b"}) + + +def test_broken_index_is_logical_with_leading_blank_line(tmp_path): + log = tmp_path / "events.jsonl" + append_hash_chained_line(log, {"event": "a"}) + append_hash_chained_line(log, {"event": "b"}) + lines = log.read_text(encoding="utf-8").splitlines() + entry = json.loads(lines[1]) + entry["payload"]["event"] = "TAMPERED" + lines[1] = json.dumps(entry) + # blank line up front must not shift the reported logical index + log.write_text("\n" + "\n".join(lines) + "\n", encoding="utf-8") + + result = verify_hash_chain(log) + assert not result.ok + assert result.broken_index == 1 + + +def test_apply_fails_closed_when_digest_missing(tmp_path): + eng = _new_engine(tmp_path) + brief = eng.create_brief("an idea", title="T") + eng.create_roadmap(brief.run_id) + eng.create_issue_plan(brief.run_id, "acme/demo") + approval = next( + e for e in eng.list_pending_approvals() if e["data"]["artifact"] == "issue-plan" + ) + # Strip the bound digest to simulate a legacy/missing-file approval. + from pathlib import Path + + apath = Path(approval["path"]) + data = json.loads(apath.read_text(encoding="utf-8")) + data["artifact_sha256"] = None + data["status"] = "approved" + apath.write_text(json.dumps(data), encoding="utf-8") + + with pytest.raises(PermissionError, match="no bound artifact digest"): + eng.apply_issue_plan(brief.run_id, dry_run=True) + + +# -- hash chain + timestamp coverage ----------------------------------------- + +def test_chain_verifies_and_detects_payload_tamper(tmp_path): + persist_company_artifact(tmp_path, "run-1", "brief", {"id": "b"}) + append_company_artifact_event(tmp_path, "run-1", "brief", {"event": "created"}) + append_company_artifact_event(tmp_path, "run-1", "brief", {"event": "approved"}) + + assert verify_company_artifact_chain(tmp_path, "run-1", "brief").ok + + log = tmp_path / "run-1" / "company" / "audit" / "brief.jsonl" + lines = log.read_text(encoding="utf-8").splitlines() + entry = json.loads(lines[1]) + entry["payload"]["event"] = "TAMPERED" # change content, keep old hash + lines[1] = json.dumps(entry) + log.write_text("\n".join(lines) + "\n", encoding="utf-8") + + result = verify_company_artifact_chain(tmp_path, "run-1", "brief") + assert not result.ok + assert result.reason == "hash-mismatch" + + +def test_chain_detects_timestamp_backdating(tmp_path): + persist_company_artifact(tmp_path, "run-2", "brief", {"id": "b"}) + append_company_artifact_event(tmp_path, "run-2", "brief", {"event": "created"}) + + log = tmp_path / "run-2" / "company" / "audit" / "brief.jsonl" + lines = log.read_text(encoding="utf-8").splitlines() + entry = json.loads(lines[0]) + entry["timestamp"] = "1999-01-01T00:00:00+00:00" # backdate without rehashing + lines[0] = json.dumps(entry) + log.write_text("\n".join(lines) + "\n", encoding="utf-8") + + # timestamp is inside the hashed body, so backdating breaks the chain + assert not verify_company_artifact_chain(tmp_path, "run-2", "brief").ok + + +# -- governance log is chained ------------------------------------------------ + +def test_governance_log_is_hash_chained(tmp_path): + from parallel_agents.project_office import office_dir + + eng = _new_engine(tmp_path) + brief = eng.create_brief("an idea", title="T") + eng.create_roadmap(brief.run_id) + + log = office_dir(tmp_path) / "audit" / "events.jsonl" + chain = verify_hash_chain(log, "governance-log") + assert chain.ok and chain.entry_count >= 1 + + # entries carry chained metadata, not a bare event dict + first = json.loads(log.read_text(encoding="utf-8").splitlines()[0]) + assert {"timestamp", "hash", "previous_hash", "payload"} <= set(first) + assert first["payload"]["event"] == "approval.created" + + +def test_verify_audit_chains_reports_ok_and_detects_break(tmp_path): + from parallel_agents.project_office import office_dir + + eng = _new_engine(tmp_path) + eng.create_brief("an idea", title="T") + assert eng.verify_audit_chains()["ok"] is True + + log = office_dir(tmp_path) / "audit" / "events.jsonl" + lines = log.read_text(encoding="utf-8").splitlines() + entry = json.loads(lines[0]) + entry["payload"]["event"] = "forged" + lines[0] = json.dumps(entry) + log.write_text("\n".join(lines) + "\n", encoding="utf-8") + + report = eng.verify_audit_chains() + assert report["ok"] is False + assert report["governance_log"]["ok"] is False + + +# -- content-digest binding (approve-then-swap TOCTOU) ----------------------- + +def test_apply_refuses_when_plan_changed_after_approval(tmp_path): + eng = _new_engine(tmp_path) + brief = eng.create_brief("an idea", title="T") + eng.create_roadmap(brief.run_id) + eng.create_issue_plan(brief.run_id, "acme/demo") + + approval = next( + e for e in eng.list_pending_approvals() if e["data"]["artifact"] == "issue-plan" + ) + assert approval["data"].get("artifact_sha256") # digest was recorded + eng.approve(approval["path"], approver="lead") + + # Tamper with the approved artifact bytes after approval. + from parallel_agents.project_office import office_output_dir + + plan_path = office_output_dir(tmp_path) / brief.run_id / "company" / "issue-plan.json" + data = json.loads(plan_path.read_text(encoding="utf-8")) + data["issues"].append({"title": "sneaky injected issue", "milestone": "M1"}) + plan_path.write_text(json.dumps(data), encoding="utf-8") + + with pytest.raises(PermissionError, match="changed since approval"): + eng.apply_issue_plan(brief.run_id, dry_run=True) + + +def test_apply_succeeds_when_unchanged(tmp_path): + eng = _new_engine(tmp_path) + brief = eng.create_brief("an idea", title="T") + eng.create_roadmap(brief.run_id) + eng.create_issue_plan(brief.run_id, "acme/demo") + approval = next( + e for e in eng.list_pending_approvals() if e["data"]["artifact"] == "issue-plan" + ) + eng.approve(approval["path"], approver="lead") + result = eng.apply_issue_plan(brief.run_id, dry_run=True) + assert result["mode"] == "dry-run" + + +# -- supersede on regenerate -------------------------------------------------- + +def test_regenerate_after_decision_records_supersede(tmp_path): + eng = _new_engine(tmp_path) + brief = eng.create_brief("an idea", title="T") + eng.create_roadmap(brief.run_id) + eng.create_issue_plan(brief.run_id, "acme/demo") + approval = next( + e for e in eng.list_pending_approvals() if e["data"]["artifact"] == "issue-plan" + ) + eng.approve(approval["path"], approver="lead") + + # Regenerate the issue plan -> the prior approved decision is superseded. + eng.create_issue_plan(brief.run_id, "acme/demo") + + events = eng.list_audit_events(run_id=brief.run_id, limit=0) + superseded = [e for e in events if e.get("event") == "approval.superseded"] + assert superseded and superseded[0]["previous_status"] == "approved" + + # and the fresh approval is pending again (decision was not silently kept) + refreshed = next( + e for e in eng.list_all_approvals() if e["data"]["artifact"] == "issue-plan" + ) + assert refreshed["data"]["status"] == "pending" + + +# -- run-level verification helper ------------------------------------------- + +def test_verify_run_audit_chains_aggregates(tmp_path): + eng = _new_engine(tmp_path) + brief = eng.create_brief("an idea", title="T") + eng.create_roadmap(brief.run_id) + from parallel_agents.project_office import office_output_dir + + report = verify_run_audit_chains(office_output_dir(tmp_path), brief.run_id) + assert report.ok + assert {c.artifact_name for c in report.chains} >= {"brief", "roadmap"} diff --git a/tests/test_desktop_llm.py b/tests/test_desktop_llm.py new file mode 100644 index 0000000..3acf050 --- /dev/null +++ b/tests/test_desktop_llm.py @@ -0,0 +1,321 @@ +"""Tests for the desktop LLM layer: enablement policy, structured-output +plumbing, the string-coercion fix, and engine provenance/fallback.""" + +from __future__ import annotations + +import json +import types + +import pytest + +from parallel_agents.desktop.services import llm_config +from parallel_agents.desktop.services.llm import call_anthropic_tool, str_list + + +# -- llm_config enablement precedence ---------------------------------------- + +def _clear_llm_env(monkeypatch): + monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False) + monkeypatch.delenv("PA_DESKTOP_LLM", raising=False) + for key in llm_config.ARTIFACTS: + monkeypatch.delenv(f"PA_DESKTOP_LLM_{key}", raising=False) + + +def test_llm_disabled_without_key(monkeypatch): + _clear_llm_env(monkeypatch) + assert llm_config.llm_enabled("BRIEF") is False + assert llm_config.active_generators() == [] + + +def test_llm_default_on_with_key(monkeypatch): + _clear_llm_env(monkeypatch) + monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-test") + assert llm_config.llm_enabled("ROADMAP") is True + assert len(llm_config.active_generators()) == len(llm_config.ARTIFACTS) + + +def test_per_artifact_flag_overrides_key(monkeypatch): + _clear_llm_env(monkeypatch) + monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-test") + monkeypatch.setenv("PA_DESKTOP_LLM_BRIEF", "0") + assert llm_config.llm_enabled("BRIEF") is False + assert llm_config.llm_enabled("RFC") is True # others still default-on + + +def test_global_flag_overrides_key_but_not_per_artifact(monkeypatch): + _clear_llm_env(monkeypatch) + monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-test") + monkeypatch.setenv("PA_DESKTOP_LLM", "0") + assert llm_config.llm_enabled("SPRINT") is False + monkeypatch.setenv("PA_DESKTOP_LLM_SPRINT", "1") + assert llm_config.llm_enabled("SPRINT") is True # per-artifact wins over global + + +def test_flag_enables_without_key(monkeypatch): + _clear_llm_env(monkeypatch) + monkeypatch.setenv("PA_DESKTOP_LLM_BRIEF", "yes") + assert llm_config.llm_enabled("BRIEF") is True + + +def test_force_off_flag_round_trips_through_settings_store(tmp_path, monkeypatch): + """A user must be able to force a generator OFF even with a key present. + Validates the '0' flag survives settings persistence and disables the LLM.""" + from parallel_agents.desktop.services.settings_store import SettingsStore + + _clear_llm_env(monkeypatch) + monkeypatch.setenv("HOME", str(tmp_path)) + monkeypatch.setenv("USERPROFILE", str(tmp_path)) + + store = SettingsStore(project_root=None) + store.save_user({"PA_DESKTOP_LLM_BRIEF": "0"}) + + # reload from disk into the environment + monkeypatch.delenv("PA_DESKTOP_LLM_BRIEF", raising=False) + store.load() + monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-test") # key present, but... + + assert llm_config.llm_enabled("BRIEF") is False # explicit force-off wins + assert llm_config.llm_enabled("RFC") is True # others still default-on + + +# -- str_list: the character-explosion fix ----------------------------------- + +def test_str_list_does_not_explode_strings(): + # The old `list("increase adoption")` bug produced ['i','n','c',...]. + assert str_list("increase adoption", ["fallback"]) == ["increase adoption"] + + +def test_str_list_passes_through_lists_and_trims(): + assert str_list(["a", " b ", "", None], ["fb"]) == ["a", "b"] + + +def test_str_list_falls_back_on_empty_or_wrong_type(): + assert str_list([], ["fb"]) == ["fb"] + assert str_list(None, ["fb"]) == ["fb"] + assert str_list({"k": "v"}, ["fb"]) == ["fb"] # dict is not a list[str] + + +# -- call_anthropic_tool plumbing (fake SDK) --------------------------------- + +class _FakeBlock: + def __init__(self, name, data): + self.type = "tool_use" + self.name = name + self.input = data + + +class _FakeResponse: + def __init__(self, name, data): + self.content = [_FakeBlock(name, data)] + + +class _FakeMessages: + def __init__(self, capture): + self._capture = capture + + def create(self, **kwargs): + self._capture.update(kwargs) + name = kwargs["tool_choice"]["name"] + # Echo a payload that matches the requested tool. + return _FakeResponse(name, {"echoed": True}) + + +class _FakeClient: + def __init__(self, capture): + self.messages = _FakeMessages(capture) + self._capture = capture + + def with_options(self, **kwargs): + self._capture["with_options"] = kwargs + return self + + +def _install_fake_anthropic(monkeypatch, capture): + fake_mod = types.ModuleType("anthropic") + fake_mod.Anthropic = lambda api_key=None: _FakeClient(capture) + monkeypatch.setitem(__import__("sys").modules, "anthropic", fake_mod) + + +def test_call_anthropic_tool_forces_tool_and_sets_timeout(monkeypatch): + monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-test") + monkeypatch.setenv("PA_DESKTOP_LLM_MODEL", "claude-sonnet-4-6") + capture: dict = {} + _install_fake_anthropic(monkeypatch, capture) + + data, model = call_anthropic_tool( + "hi", + tool_name="emit_thing", + tool_description="desc", + input_schema={"type": "object", "properties": {}}, + ) + assert data == {"echoed": True} + assert model == "claude-sonnet-4-6" + # forced tool choice + a bounded timeout/retry + assert capture["tool_choice"] == {"type": "tool", "name": "emit_thing"} + assert capture["with_options"]["max_retries"] == 1 + assert capture["with_options"]["timeout"] > 0 + + +def test_call_anthropic_tool_raises_without_key(monkeypatch): + monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False) + with pytest.raises(RuntimeError): + call_anthropic_tool( + "hi", tool_name="x", tool_description="d", input_schema={"type": "object"} + ) + + +# -- generator mapping (fake call_anthropic_tool) ---------------------------- + +def test_generate_llm_brief_maps_structured_output(monkeypatch): + from parallel_agents.desktop.services import llm_brief + + def fake_tool(prompt, **kwargs): + return ( + { + "title": "Smart Garden", + "problem_statement": "Plants die when owners travel.", + "goals": ["Automate watering", "Alert on low moisture"], + # exercise the coercion guard: a string where a list is expected + "target_users": "Apartment gardeners", + }, + "claude-sonnet-4-6", + ) + + monkeypatch.setattr(llm_brief, "call_anthropic_tool", fake_tool) + brief, model = llm_brief.generate_llm_brief("a smart garden", title="Garden") + assert model == "claude-sonnet-4-6" + assert brief.title == "Smart Garden" + assert brief.goals == ["Automate watering", "Alert on low moisture"] + # string did NOT explode into characters + assert brief.target_users == ["Apartment gardeners"] + + +def test_generate_llm_roadmap_uses_llm_items(monkeypatch): + from parallel_agents.company_workflows import create_product_brief + from parallel_agents.desktop.services import llm_roadmap + + def fake_tool(prompt, **kwargs): + return ( + { + "outcomes": ["Ship MVP"], + "items": [ + {"title": "Build watering controller", "owner_role": "Eng", "milestone": "M1"}, + {"title": "Moisture sensor integration", "owner_role": "Eng", "milestone": "M2"}, + ], + }, + "claude-sonnet-4-6", + ) + + monkeypatch.setattr(llm_roadmap, "call_anthropic_tool", fake_tool) + brief = create_product_brief("a smart garden", title="Garden") + roadmap, _ = llm_roadmap.generate_llm_roadmap(brief, horizon_weeks=8) + titles = [i.title for i in roadmap.items] + assert titles == ["Build watering controller", "Moisture sensor integration"] + # NOT the deterministic template's fixed RM-01..RM-04 about parallel-agents + assert all("parallel" not in t.lower() for t in titles) + + +def test_generate_llm_roadmap_raises_on_empty_items_not_template(monkeypatch): + """Returning no usable items must NOT pass the deterministic template off as + LLM output — it must raise so provenance is recorded as template.""" + from parallel_agents.company_workflows import create_product_brief + from parallel_agents.desktop.services import llm_roadmap + + def fake_tool(prompt, **kwargs): + return ({"outcomes": ["x"], "items": []}, "claude-sonnet-4-6") + + monkeypatch.setattr(llm_roadmap, "call_anthropic_tool", fake_tool) + brief = create_product_brief("a smart garden", title="Garden") + with pytest.raises(RuntimeError): + llm_roadmap.generate_llm_roadmap(brief, horizon_weeks=8) + + +def test_engine_roadmap_empty_llm_falls_back_with_template_provenance(tmp_path, monkeypatch): + """End-to-end: an LLM roadmap that yields no items is recorded as template, + never as LLM (the headline 'template theater' guarantee).""" + from parallel_agents.desktop.services import llm_roadmap + from parallel_agents.desktop.services.engine import EngineService + + _clear_llm_env(monkeypatch) + monkeypatch.setenv("PA_DESKTOP_LLM_ROADMAP", "1") + monkeypatch.setenv("PA_DESKTOP_LLM_BRIEF", "0") # keep brief deterministic + + def fake_tool(prompt, **kwargs): + return ({"items": []}, "claude-sonnet-4-6") + + monkeypatch.setattr(llm_roadmap, "call_anthropic_tool", fake_tool) + + eng = EngineService() + eng.init_project(str(tmp_path), name="RM") + brief = eng.create_brief("a smart garden", title="Garden") + result = eng.create_roadmap(brief.run_id, horizon_weeks=8) + assert result.provenance["generator"] == "template" + assert "llm-error" in result.provenance["reason"] + + +# -- engine provenance + fallback -------------------------------------------- + +def test_engine_records_template_provenance_without_key(tmp_path, monkeypatch): + from parallel_agents.desktop.services.engine import EngineService + + _clear_llm_env(monkeypatch) + eng = EngineService() + eng.init_project(str(tmp_path), name="Prov") + result = eng.create_brief("an idea", title="T") + assert result.provenance["generator"] == "template" + assert result.provenance["reason"] == "no-api-key" + + # provenance is recorded in the artifact audit chain + from parallel_agents.company_artifacts import load_company_artifact_events + from parallel_agents.project_office import office_output_dir + + events = load_company_artifact_events( + office_output_dir(tmp_path), result.run_id, "brief" + ) + created = [e for e in events if e["payload"].get("event") == "created"] + assert created and created[0]["payload"]["provenance"]["generator"] == "template" + + +def test_engine_uses_llm_when_enabled(tmp_path, monkeypatch): + from parallel_agents.desktop.services import llm_brief + from parallel_agents.desktop.services.engine import EngineService + from parallel_agents.company_workflows import create_product_brief + + _clear_llm_env(monkeypatch) + monkeypatch.setenv("PA_DESKTOP_LLM_BRIEF", "1") # force on, no real key needed + + def fake_generate(idea, *, title=None): + b = create_product_brief(idea, title=title) + b.title = "LLM Title" + return b, "claude-sonnet-4-6" + + monkeypatch.setattr(llm_brief, "generate_llm_brief", fake_generate) + + eng = EngineService() + eng.init_project(str(tmp_path), name="LLM") + result = eng.create_brief("an idea", title="T") + assert result.provenance == {"generator": "llm", "model": "claude-sonnet-4-6"} + brief_data = json.loads(result.artifact_path.read_text(encoding="utf-8")) + assert brief_data["title"] == "LLM Title" + + +def test_engine_falls_back_and_logs_on_llm_error(tmp_path, monkeypatch): + from parallel_agents.desktop.services import llm_brief + from parallel_agents.desktop.services.engine import EngineService + + _clear_llm_env(monkeypatch) + monkeypatch.setenv("PA_DESKTOP_LLM_BRIEF", "1") + + def boom(idea, *, title=None): + raise RuntimeError("network down") + + monkeypatch.setattr(llm_brief, "generate_llm_brief", boom) + + eng = EngineService() + eng.init_project(str(tmp_path), name="Boom") + result = eng.create_brief("an idea", title="T") + assert result.provenance["generator"] == "template" + assert "llm-error" in result.provenance["reason"] + # deterministic content was still produced + brief_data = json.loads(result.artifact_path.read_text(encoding="utf-8")) + assert brief_data["title"] == "T" diff --git a/tests/test_desktop_smoke.py b/tests/test_desktop_smoke.py new file mode 100644 index 0000000..5496aba --- /dev/null +++ b/tests/test_desktop_smoke.py @@ -0,0 +1,69 @@ +"""Bring the desktop smoke scripts into the pytest gate. + +Each script manipulates sys.modules (installs a PySide6 stub) or constructs the +full Qt window, so they are run as subprocesses in a clean interpreter to avoid +polluting the rest of the suite. This makes `pytest` alone a complete gate for +the desktop surface, instead of relying on the scripts being run by hand. +""" + +from __future__ import annotations + +import os +import subprocess +import sys +from pathlib import Path + +import pytest + +REPO_ROOT = Path(__file__).resolve().parents[1] +SMOKE_SCRIPTS = [ + "smoke_desktop_imports.py", + "smoke_desktop_construct.py", + "smoke_desktop_engine.py", + "smoke_desktop_polish.py", +] + + +def _has_pyside6() -> bool: + import importlib.util + + return importlib.util.find_spec("PySide6") is not None + + +@pytest.mark.parametrize("script", SMOKE_SCRIPTS) +def test_smoke_script_passes(script): + path = REPO_ROOT / "scripts" / script + assert path.exists(), f"missing smoke script: {path}" + proc = subprocess.run( + [sys.executable, str(path)], + cwd=REPO_ROOT, + capture_output=True, + text=True, + ) + assert proc.returncode == 0, ( + f"{script} failed (rc={proc.returncode})\n" + f"STDOUT:\n{proc.stdout}\nSTDERR:\n{proc.stderr}" + ) + + +@pytest.mark.skipif(not _has_pyside6(), reason="PySide6 not installed") +def test_desktop_launches_headless_with_real_qt(): + """The packaged entry point builds the full window under offscreen Qt. + + This is the same path CI uses to smoke-execute the built binary: it exercises + the real PySide6 stack (not the stub), catching wiring bugs the stub can hide. + """ + env = {**os.environ, "QT_QPA_PLATFORM": "offscreen", "PA_DESKTOP_LLM": "0"} + env.pop("ANTHROPIC_API_KEY", None) + proc = subprocess.run( + [sys.executable, "-m", "parallel_agents.desktop", "--smoke"], + cwd=REPO_ROOT / "src", + capture_output=True, + text=True, + env=env, + timeout=300, + ) + assert proc.returncode == 0, ( + f"headless desktop launch failed (rc={proc.returncode})\n" + f"STDOUT:\n{proc.stdout}\nSTDERR:\n{proc.stderr}" + ) diff --git a/tests/test_gateway.py b/tests/test_gateway.py index 4aad0cc..3462642 100644 --- a/tests/test_gateway.py +++ b/tests/test_gateway.py @@ -11,6 +11,7 @@ from parallel_agents.company_artifacts import load_company_artifact, persist_company_artifact from parallel_agents.company_workflows import build_roadmap, create_product_brief from parallel_agents.gateway import GatewayStore, create_gateway_app +from parallel_agents.models import FinalOutput def _jwt_hs256(secret: str, payload: dict) -> str: @@ -29,6 +30,17 @@ def _enc(value: dict) -> str: return f"{encoded_header}.{encoded_payload}.{sig}" +def _slack_headers(secret: str, body: bytes, *, timestamp: int | None = None) -> dict[str, str]: + ts = str(timestamp or int(time.time())) + base = f"v0:{ts}:".encode("utf-8") + body + signature = "v0=" + hmac.new(secret.encode("utf-8"), base, hashlib.sha256).hexdigest() + return { + "x-slack-request-timestamp": ts, + "x-slack-signature": signature, + "content-type": "application/json", + } + + def _wait_for_status( client: TestClient, run_id: str, @@ -66,6 +78,230 @@ def test_gateway_health_and_project_creation(tmp_path): assert fetched["repo_path"] == "/repo" +def test_gateway_pipeline_run_endpoint_uses_gateway_run_id(tmp_path, monkeypatch): + captured: dict = {} + + class FakePipeline: + def __init__(self, config): + captured["output_dir"] = config.output_dir + captured["store_backend"] = config.store_backend + captured["permission_mode"] = config.permission_mode + + async def run(self, task, repo_path=None, on_status=None, on_event=None, run_id=None): + captured["task"] = task + captured["repo_path"] = repo_path + captured["run_id"] = run_id + if on_status: + on_status("Planning: fake pipeline step") + if on_event: + on_event({"agent": "pipeline", "phase": "planning", "status": "started"}) + return FinalOutput( + summary="pipeline complete", + metadata={ + "run_id": run_id, + "cost": {"total_tokens": 7, "total_cost_usd": 0.01}, + }, + ) + + monkeypatch.setattr("parallel_agents.gateway.Pipeline", FakePipeline) + app = create_gateway_app(tmp_path) + client = TestClient(app) + + result = client.post( + "/runs/pipeline", + json={ + "run_id": "run-pipeline", + "task": "Review auth flow", + "repo_path": "/repo", + "permission_mode": "plan", + }, + ).json() + + assert result["status"] == "succeeded" + assert captured["run_id"] == "run-pipeline" + assert captured["task"] == "Review auth flow" + assert captured["repo_path"] == "/repo" + assert captured["output_dir"] == str(tmp_path) + assert captured["permission_mode"] == "plan" + artifact = load_company_artifact(tmp_path, "run-pipeline", "final-output") + assert artifact["summary"] == "pipeline complete" + events = client.get("/runs/run-pipeline/events").json() + assert any(event["event"] == "pipeline_status" for event in events["events"]) + assert any(event["event"] == "pipeline_trace" for event in events["events"]) + + +def test_gateway_pipeline_run_requires_task(tmp_path): + app = create_gateway_app(tmp_path) + client = TestClient(app) + + result = client.post("/runs/pipeline", json={"run_id": "run-no-task"}).json() + + assert result["status"] == "failed" + assert result["error_message"] == "task is required" + + +def test_gateway_channel_inbound_requires_pairing_before_execution(tmp_path, monkeypatch): + executed: list[str] = [] + + class FakePipeline: + def __init__(self, config): + self.config = config + + async def run(self, task, repo_path=None, on_status=None, on_event=None, run_id=None): + executed.append(task) + return FinalOutput( + summary="channel run complete", + metadata={ + "run_id": run_id, + "cost": {"total_tokens": 1, "total_cost_usd": 0.0}, + }, + ) + + monkeypatch.setattr("parallel_agents.gateway.Pipeline", FakePipeline) + app = create_gateway_app(tmp_path) + client = TestClient(app) + + blocked = client.post( + "/channels/inbound", + json={ + "channel": "Slack", + "peer_id": "U123", + "message": "Review this repo", + "execute": True, + "wait": True, + }, + ).json() + + assert blocked["status"] == "pairing_required" + assert blocked["processed"] is False + assert blocked["channel"] == "slack" + assert blocked["pairing_code"] + assert executed == [] + + approved = client.post( + "/channels/pairing/approve", + json={"code": blocked["pairing_code"], "approved_by": "operator"}, + ).json() + assert approved["status"] == "approved" + assert approved["channel"] == "slack" + assert approved["peer_id"] == "U123" + + accepted = client.post( + "/channels/inbound", + json={ + "channel": "slack", + "peer_id": "U123", + "message": "Review this repo", + "execute": True, + "wait": True, + }, + ).json() + + assert accepted["status"] == "accepted" + assert accepted["processed"] is True + assert accepted["run"]["status"] == "succeeded" + assert executed == ["Review this repo"] + + peers = client.get("/channels/peers", params={"channel": "slack"}).json() + assert peers["count"] == 1 + assert peers["peers"][0]["peer_id"] == "U123" + + +def test_gateway_slack_events_url_verification(tmp_path): + app = create_gateway_app(tmp_path, slack_signing_secret="slack-secret") + client = TestClient(app) + body = json.dumps( + {"type": "url_verification", "challenge": "challenge-token"} + ).encode("utf-8") + + response = client.post( + "/channels/slack/events", + content=body, + headers=_slack_headers("slack-secret", body), + ) + + assert response.status_code == 200 + assert response.text == "challenge-token" + + +def test_gateway_slack_events_rejects_bad_signature(tmp_path): + app = create_gateway_app(tmp_path, slack_signing_secret="slack-secret") + client = TestClient(app) + body = json.dumps({"type": "event_callback", "event": {"type": "message"}}).encode("utf-8") + + response = client.post( + "/channels/slack/events", + content=body, + headers=_slack_headers("wrong-secret", body), + ) + + assert response.status_code == 401 + + +def test_gateway_slack_events_pairing_then_execution(tmp_path, monkeypatch): + executed: list[tuple[str, str | None]] = [] + + class FakePipeline: + def __init__(self, config): + self.config = config + + async def run(self, task, repo_path=None, on_status=None, on_event=None, run_id=None): + executed.append((task, run_id)) + return FinalOutput( + summary="slack run complete", + metadata={ + "run_id": run_id, + "cost": {"total_tokens": 1, "total_cost_usd": 0.0}, + }, + ) + + monkeypatch.setattr("parallel_agents.gateway.Pipeline", FakePipeline) + app = create_gateway_app(tmp_path, slack_signing_secret="slack-secret") + client = TestClient(app) + + event_payload = { + "type": "event_callback", + "team_id": "T1", + "event_id": "Ev1", + "event": { + "type": "message", + "channel": "C1", + "user": "U1", + "text": "Review Slack request path", + }, + } + body = json.dumps(event_payload).encode("utf-8") + blocked = client.post( + "/channels/slack/events", + content=body, + headers=_slack_headers("slack-secret", body), + ).json() + + assert blocked["status"] == "pairing_required" + assert blocked["processed"] is False + assert blocked["peer_id"] == "T1:C1:U1" + assert executed == [] + + approved = client.post( + "/channels/pairing/approve", + json={"code": blocked["pairing_code"], "approved_by": "operator"}, + ).json() + assert approved["status"] == "approved" + + accepted = client.post( + "/channels/slack/events", + content=body, + headers=_slack_headers("slack-secret", body), + ).json() + + assert accepted["status"] == "accepted" + assert accepted["processed"] is True + run_id = accepted["run"]["id"] + run = _wait_for_status(client, run_id, expected_statuses={"succeeded"}) + assert run["status"] == "succeeded" + assert executed == [("Review Slack request path", run_id)] + + def test_gateway_memory_endpoints(tmp_path): app = create_gateway_app(tmp_path) client = TestClient(app) @@ -148,6 +384,7 @@ def test_gateway_company_idea_roadmap_plan_lifecycle(tmp_path): def test_gateway_approval_events_and_apply_success(tmp_path, monkeypatch): + monkeypatch.setenv("PA_ALLOW_WRITE_TOOLS", "1") app = create_gateway_app(tmp_path) client = TestClient(app) roadmap = build_roadmap(create_product_brief("Build approval flow")).model_dump(mode="json") @@ -179,7 +416,8 @@ async def fake_execute_issue_plan(**kwargs): assert events["approval_audit_events"][0]["payload"]["approval_note"] == "ok" -def test_gateway_apply_waits_for_approval(tmp_path): +def test_gateway_apply_waits_for_approval(tmp_path, monkeypatch): + monkeypatch.setenv("PA_ALLOW_WRITE_TOOLS", "1") app = create_gateway_app(tmp_path) client = TestClient(app) roadmap = build_roadmap(create_product_brief("Build approval flow")).model_dump(mode="json") @@ -190,7 +428,22 @@ def test_gateway_apply_waits_for_approval(tmp_path): assert result["error_message"] == "plan is not approved" +def test_gateway_apply_disabled_by_default(tmp_path, monkeypatch): + # Without PA_ALLOW_WRITE_TOOLS the gateway must refuse the live apply step. + monkeypatch.delenv("PA_ALLOW_WRITE_TOOLS", raising=False) + app = create_gateway_app(tmp_path) + client = TestClient(app) + roadmap = build_roadmap(create_product_brief("Build gated flow")).model_dump(mode="json") + client.post("/runs/company/plan", json={"run_id": "run-gated", "roadmap": roadmap, "repo": "owner/repo"}) + client.post("/runs/company/approve", json={"run_id": "run-gated", "approver": "lead"}) + + result = client.post("/runs/company/apply", json={"run_id": "run-gated"}).json() + assert result["status"] == "blocked_by_policy" + assert "PA_ALLOW_WRITE_TOOLS" in result["error_message"] + + def test_gateway_apply_blocks_policy_before_writes(tmp_path, monkeypatch): + monkeypatch.setenv("PA_ALLOW_WRITE_TOOLS", "1") app = create_gateway_app(tmp_path) client = TestClient(app) roadmap = build_roadmap(create_product_brief("Build policy flow")).model_dump(mode="json") @@ -244,6 +497,7 @@ def test_gateway_list_runs_and_jobs_endpoint(tmp_path): def test_gateway_cancel_and_retry_controls(tmp_path, monkeypatch): + monkeypatch.setenv("PA_ALLOW_WRITE_TOOLS", "1") app = create_gateway_app(tmp_path) client = TestClient(app) diff --git a/tests/test_mcp_server.py b/tests/test_mcp_server.py index 375a179..2ea2307 100644 --- a/tests/test_mcp_server.py +++ b/tests/test_mcp_server.py @@ -362,7 +362,8 @@ async def test_company_plan_approve_artifacts_and_templates_tools(tmp_path): @pytest.mark.asyncio -async def test_company_apply_tool_requires_approval(tmp_path): +async def test_company_apply_tool_requires_approval(tmp_path, monkeypatch): + monkeypatch.setenv("PA_ALLOW_WRITE_TOOLS", "1") from parallel_agents.company_workflows import build_roadmap, create_product_brief from parallel_agents.mcp_server import company_apply, company_plan @@ -379,6 +380,17 @@ async def test_company_apply_tool_requires_approval(tmp_path): assert "not approved" in payload["message"] +@pytest.mark.asyncio +async def test_company_apply_tool_disabled_by_default(tmp_path, monkeypatch): + # With PA_ALLOW_WRITE_TOOLS unset, apply must refuse before any write path. + monkeypatch.delenv("PA_ALLOW_WRITE_TOOLS", raising=False) + from parallel_agents.mcp_server import company_apply + + payload = json.loads(await company_apply("run-anything", output_dir=str(tmp_path))) + assert payload["error"] is True + assert payload["error_type"] == "WriteToolsDisabled" + + @pytest.mark.asyncio async def test_eval_score_tool(tmp_path): from parallel_agents.eval_harness import EvaluationResults, EvaluationRunRecord diff --git a/tests/test_pipeline.py b/tests/test_pipeline.py index 53bd266..8c1abfe 100644 --- a/tests/test_pipeline.py +++ b/tests/test_pipeline.py @@ -128,7 +128,7 @@ async def _mock_query_generator(response_text: str): @pytest.mark.asyncio -async def test_pipeline_end_to_end(): +async def test_pipeline_end_to_end(tmp_path): """Test full pipeline with mocked query() calls.""" config = PipelineConfig( workers={ @@ -143,6 +143,7 @@ async def test_pipeline_end_to_end(): "docs": WorkerConfig(enabled=False), }, store_backend="file", + output_dir=str(tmp_path), ) responses = [ @@ -166,6 +167,7 @@ def mock_query(*, prompt, options=None): mock_fn = make_mock_query() statuses: list[str] = [] + events: list[dict] = [] with patch("parallel_agents.agents.planner.query", new=mock_fn): with patch("parallel_agents.agents.base.query", new=mock_fn): @@ -175,16 +177,28 @@ def mock_query(*, prompt, options=None): "Test the security and review", repo_path="/tmp/mock-repo", on_status=statuses.append, + on_event=events.append, + run_id="run-test", ) assert isinstance(result, FinalOutput) assert result.summary != "" assert len(statuses) > 0 # Progress callbacks were called + assert any( + event.get("agent") == "pipeline" + and event.get("phase") == "planning" + and event.get("status") == "started" + for event in events + ) + assert any(event.get("agent") == "security" for event in events) # Check cost tracking was recorded assert "cost" in result.metadata + assert result.metadata["run_id"] == "run-test" cost = result.metadata["cost"] assert cost["total_tokens"] >= 0 + final_payload = json.loads((tmp_path / "run-test" / "final_output.json").read_text(encoding="utf-8")) + assert final_payload["metadata"]["run_id"] == "run-test" @pytest.mark.asyncio @@ -378,6 +392,87 @@ def mock_query(*, prompt, options=None): assert "invalid_patch" in result.metadata +@pytest.mark.asyncio +async def test_pipeline_planner_parse_failure_marks_run_failed(tmp_path): + """A planner that never parses must yield a FAILED manifest and a parse-failure + summary (so the CLI exits non-zero), not a COMPLETED empty run.""" + from parallel_agents.evidence_store import create_evidence_store + from parallel_agents.main import _classify_result_exit_code, EXIT_PARSE_FAILURE + from parallel_agents.models import TaskStatus + + config = _single_review_config() + config.output_dir = str(tmp_path) + config.parse_retry_attempts = 1 # one retry, then give up + + def mock_query(*, prompt, options=None): + return _mock_query_generator("this is never valid json") + + with patch("parallel_agents.agents.planner.query", new=mock_query): + pipeline = Pipeline(config) + result = await pipeline.run("Review task", repo_path="/tmp/mock-repo") + + assert "failed to parse" in result.summary.lower() + assert result.metadata.get("error") == "planner_parse_failure" + assert _classify_result_exit_code(result) == EXIT_PARSE_FAILURE + + run_id = result.metadata["run_id"] + store = create_evidence_store(config.output_dir, run_id, config.store_backend) + manifest = store.load_manifest() + assert manifest is not None + assert manifest.status == TaskStatus.FAILED + + +@pytest.mark.asyncio +async def test_pipeline_preserves_multiple_subtasks_per_worker(tmp_path): + """Two subtasks assigned to the same worker must both survive (no silent + overwrite by worker_name) in worker_results and in the evidence store.""" + from parallel_agents.evidence_store import create_evidence_store + + config = _single_review_config() + config.output_dir = str(tmp_path) + + planner = json.dumps({ + "summary": "Two review subtasks", + "repo_analysis": {"languages": ["python"]}, + "subtasks": [ + {"id": "s1", "description": "Review module A", "assigned_worker": "review", + "context": {}, "dependencies": [], "priority": 1}, + {"id": "s2", "description": "Review module B", "assigned_worker": "review", + "context": {}, "dependencies": [], "priority": 1}, + ], + "global_context": {"repo_path": "/tmp/mock-repo"}, + }) + responses = [ + planner, + _mock_worker_response("review"), + _mock_worker_response("review"), + _mock_judge_response(), + ] + counter = {"n": 0} + + def mock_query(*, prompt, options=None): + idx = min(counter["n"], len(responses) - 1) + counter["n"] += 1 + return _mock_query_generator(responses[idx]) + + with patch("parallel_agents.agents.planner.query", new=mock_query): + with patch("parallel_agents.agents.base.query", new=mock_query): + with patch("parallel_agents.agents.judge.query", new=mock_query): + pipeline = Pipeline(config) + result = await pipeline.run("Review task", repo_path="/tmp/mock-repo") + + # Both subtask results are preserved (keyed distinctly), not collapsed to one. + assert len(result.worker_results) == 2 + subtask_ids = {r.subtask_id for r in result.worker_results.values()} + assert subtask_ids == {"s1", "s2"} + + run_id = result.metadata["run_id"] + store = create_evidence_store(config.output_dir, run_id, config.store_backend) + stored = store.load_all_worker_results() + assert len(stored) == 2 + assert {r.subtask_id for r in stored.values()} == {"s1", "s2"} + + @pytest.mark.asyncio async def test_pipeline_github_issue_happy_path_injects_issue_context(): """Fetched GitHub issue data should be included in planner input context."""