diff --git a/.claude/commands/automate.md b/.claude/commands/automate.md index bf956b59f..17d5e9a8f 100644 --- a/.claude/commands/automate.md +++ b/.claude/commands/automate.md @@ -1,382 +1,267 @@ --- name: Automate Tests -description: Generate comprehensive test suites after feature implementation +description: Keep the test suite truthful and proportional after a feature lands — prune dead, consolidate fragments, add only what proves new contracts. --- -# Test Automation Pipeline +# /automate — Test Suite Steward -You are the Test Automation agent for the ADE (Agentic Development Environment) project. +You are the test steward for ADE. You run after a feature is implemented, before `/finalize`. -**Usage:** -- `/automate` - Auto-detect feature from branch changes -- `/automate orchestrator` - Create tests for orchestrator feature -- `/automate prs, focus on merge context` - Create tests for specific area +**Your job is NOT "add tests for the new code."** It is to leave the test suite *more truthful and smaller* whenever possible. New tests are a last step, not the goal. -**Arguments:** $ARGUMENTS +The suite has bloated for three reasons. You exist to fight all three: -## Execution Mode: Autonomous - -This command runs end-to-end without user interaction. Do NOT: -- Ask the user to confirm, choose, or approve anything. -- Pause between phases to request direction. -- Request clarification on ambiguous test scope — make the best judgment from the gap tracker and note assumptions in the final report. -- Stop on non-fatal warnings — log them and continue. +1. **Dead tests linger** after features are ripped out or refactored. +2. **One feature is fractured across many tiny test files** instead of tested as a feature. +3. **Trivial / over-mocked / always-passing tests** are added that catch nothing. -Only produce the Phase 7 summary and any fatal error messages (e.g., cannot create a meaningful test). Every decision is made by the agent based on the rules in this file. +Every run does three passes in this order: **PRUNE → CONSOLIDATE → ADD**. You may finish at any pass — adding is optional. --- -## Pipeline Overview - -``` -Phase 1: Analyze & Plan (lead) -Phase 2: Plan test work & spawn agents (lead) -Phase 3: Parallel test writing (agents) - ├── desktop-tester-1..N (desktop app tests) - └── mcp-tester (mcp server tests, if applicable) -Phase 4: Test reality check (lead, after all testers done) -Phase 5: Scoped test run (new + affected) (lead) -Phase 6: CI verification (lead) -Phase 7: Summary (lead) -``` - ---- - -## Phase 1: Analyze & Plan - -### 1.1 Analyze Branch Changes +## Execution Mode: Autonomous -```bash -git diff main --name-only -git diff main --stat -``` +Run end-to-end without user interaction. Do not ask, pause, or request clarification — make judgment calls and note assumptions in the final summary. Stop only on a fatal blocker (e.g. cannot determine the changed feature at all). -Categorize changes: -- **Desktop main-process services** (`apps/desktop/src/main/services/`) — Need unit tests -- **Desktop renderer components** (`apps/desktop/src/renderer/components/`) — Need unit tests (component logic) -- **Desktop renderer lib** (`apps/desktop/src/renderer/lib/`) — Need unit tests -- **Desktop shared modules** (`apps/desktop/src/shared/`) — Need unit tests -- **Desktop preload** (`apps/desktop/src/preload/`) — Need unit tests -- **MCP server** (`apps/mcp-server/src/`) — Need unit tests -- **Web app** (`apps/web/`) — Typically no tests (marketing site) +**Do all the work yourself in the main loop.** Do NOT spawn parallel tester sub-agents — that pattern is what produced the current bloat (more agents → more files → more tests). One agent, one judgment. -### 1.2 Study Existing Test Patterns (CRITICAL) +**Argument:** `$ARGUMENTS` — optional feature hint (e.g. `/automate prs` or `/automate orchestrator, focus on merge queue`). If empty, infer the feature from `git diff main --name-only`. -**BEFORE planning any test, find and read 1-2 existing tests in the same domain.** Use Glob to find them: +--- -- Desktop main: `apps/desktop/src/main/**/*.test.ts` -- Desktop renderer: `apps/desktop/src/renderer/**/*.test.ts` -- Desktop shared: `apps/desktop/src/shared/*.test.ts` -- MCP server: `apps/mcp-server/src/**/*.test.ts` +## Pass 1: PRUNE (always runs) -Copy their patterns exactly for: imports, setup/teardown, mocking, assertions, describe/it nesting. +Goal: delete tests that no longer earn their place. Verify before each delete. -### 1.2.5 Read the feature doc for context +### 1a. Orphaned tests — sibling source is gone -Before writing any test, skim the relevant internal feature doc so you know what behavior is load-bearing vs incidental. Docs live under `docs/features//`: +For every `*.test.ts` / `*.test.tsx` in the changed feature folder AND its parents: -| Changed source area | Feature doc | -|---|---| -| services/orchestrator/ or renderer missions/ | docs/features/missions/ | -| services/prs/ or renderer prs/ | docs/features/pull-requests/ | -| services/lanes/ or renderer lanes/ | docs/features/lanes/ | -| services/chat/ or services/ai/ or renderer chat/ | docs/features/chat/ + features/agents/ | -| services/cto/ or renderer cto/ | docs/features/cto/ + features/linear-integration/ | -| services/memory/ | docs/features/memory/ | -| services/automations/ or renderer automations/ | docs/features/automations/ | -| services/conflicts/ | docs/features/conflicts/ | -| services/computerUse/ | docs/features/computer-use/ | -| services/pty/ or sessions/ or processes/ or renderer terminals/ | docs/features/terminals-and-sessions/ | -| services/files/ or renderer files/ | docs/features/files-and-editor/ | -| services/sync/ or syncRemoteCommandService | docs/features/sync-and-multi-device/ | -| services/onboarding/ or services/config/ or renderer settings/ | docs/features/onboarding-and-settings/ | -| services/history/ | docs/features/history/ | -| services/context/ | docs/features/context-packs/ | +- If the expected sibling source file does not exist (`foo.test.ts` with no `foo.ts`), the test is orphaned. +- If imports in the test resolve to nothing (symbol no longer exported anywhere), the test is orphaned. -Each `README.md` has a "Source file map" at the top, plus "gotchas / fragile areas" prose. If the README flags something as fragile, test that invariant explicitly. +**Verify** with `ls` + `grep` for the imported symbols across `apps/`, then `git rm` the file. Do not delete on suspicion alone. -Cross-cutting: `docs/ARCHITECTURE.md` covers IPC layer, data plane, build/test/deploy — read when touching preload, shared/ipc.ts, or registerIpc. +### 1b. Skip / todo / only — committed bit-rot -### 1.3 Key Test Infrastructure +- `it.skip(...)` / `test.skip(...)` / `it.todo(...)` / `it.only(...)` left in committed code → delete the block (or remove the marker if the test is actually live and someone forgot). +- Exception: `it.skipIf(...)` is conditional on env (FTS, CRSqlite, OS) — leave it. -**Desktop app:** -- Vitest workspace config: `apps/desktop/vitest.workspace.ts` -- Test setup file: `apps/desktop/src/test/setup.ts` -- Environment: `node` for all projects -- Pool: forks (maxForks: 4) -- Timeout: 20s for tests and hooks -- File naming: colocated `*.test.ts` / `*.test.tsx` next to source files +For each `.skip` you find, check whether the underlying feature still exists. If gone, delete the block. If alive but skipped, that's a bug — either re-enable or delete with a one-line note in the summary. -**Workspace projects (3 projects):** -- `unit-main`: `src/main/**/*.test.{ts,tsx}` (~150+ files — main process services, bulk of tests) -- `unit-renderer`: `src/renderer/**/*.test.{ts,tsx}` (~85+ files — components, lib) -- `unit-shared`: `src/shared/**/*.test.{ts,tsx}` + `src/preload/**/*.test.{ts,tsx}` (~7 files) +### 1c. Anti-pattern tests — pass even when broken -**Run commands — match CI exactly:** -- Run a single file: `cd apps/desktop && npx vitest run [file]` -- Run a specific project: `cd apps/desktop && npx vitest run --project unit-main` -- Run sharded (as CI does): `cd apps/desktop && npx vitest run --shard=1/8` -- Run all desktop tests: `cd apps/desktop && npx vitest run` +Search the suite (or at minimum the changed feature folder) for: -**CI runs desktop tests sharded 8-way:** `npx vitest run --shard=${{ matrix.shard }}/8` -When running the full suite locally, shard the same way CI does to avoid timeouts. +- `expect(true).toBe(true)` and equivalents — delete the test or rewrite to assert what the comment claims. +- Test bodies with zero `expect(...)` — delete or fix. +- `if (!x) return` inside a test body → silent pass when setup fails. Replace with `expect(x, "setup precondition").toBeTruthy()`. +- A test file where `vi.mock(` count > `expect(` count — over-mocked; the test is mostly fixture. Either trim mocks or delete. +- `expect(x).toBeDefined()` / `toBeTruthy()` on a value just constructed two lines above — TS already proves this. Replace with a real behavioral assertion or delete. +- `await Promise.resolve()` immediately followed by `expect(...)` with no real async work in between — fake-async. Verify the test actually exercises the async path; if not, delete. -**MCP server:** -- Vitest config: `apps/mcp-server/vitest.config.ts` -- Environment: node -- Run command: `cd apps/mcp-server && npm test` or `npx vitest run [file]` +### 1d. Trivial-assertion files -### 1.4 Build Coverage Gap Tracker +Spot files where 20+ tests assert constants exist, enum keys are defined, or formatters return strings starting with `#`. Collapse to 1–2 parameterized cases or delete. -Determine what tests are needed for each changed file: +### 1e. Render-only React tests -| Changed File Type | Unit Test? | What to Test | -|-------------------|------------|--------------| -| Service (`services/**/*.ts`) | YES | All public functions, state transitions, error paths | -| Utility (`utils/*.ts`) | YES | Pure functions, edge cases | -| Component logic (`components/**/*.ts`) | YES | View model logic, helpers, computed values | -| Renderer lib (`renderer/lib/*.ts`) | YES | Shared logic, state management helpers | -| Shared modules (`shared/*.ts`) | YES | Cross-process shared logic | -| React components (`.tsx`) | MAYBE | Only test exported logic/helpers, NOT JSX rendering | -| MCP server tools/transport | YES | Tool handlers, transport layer, error handling | -| Config/type-only files | NO | Skip — types and config don't need tests | +`*.test.tsx` that only `render()` then `getByText` with no interaction or behavior — brittle, low signal. Delete or rewrite as a behavior test. -Build the gap tracker as a list. Each group becomes a task in Phase 2. +**At end of Pass 1:** record what was deleted (file + reason) for the summary. Run the affected workspace shard once to confirm nothing else broke (`cd apps/desktop && npx vitest run --shard=1/8`, plus the shard the deletions live in). --- -## Phase 2: Plan Test Work & Spawn Agents +## Pass 2: CONSOLIDATE -### 2a. Split into batches +Goal: reduce "many small files for one feature" into feature-level suites. -Based on the gap tracker: -- `< 5` files needing tests -> 1 desktop tester agent -- `5-15` files -> 2 desktop tester agents (split by domain) -- `16+` files -> 3 desktop tester agents (split by domain) -- MCP server changes -> 1 separate mcp tester agent +### 2a. Map the feature folder -Keep related files together (service + its utils + its types). +For the feature touched by this branch, list every `*.test.*` file in the same service folder. Count `it(` blocks per file. -### 2b. Spawn parallel agents +### 2b. Consolidation triggers -Use the **Agent** tool to spawn testers in parallel. Each agent gets: +Merge files into one feature suite when ANY of these holds: -**Desktop tester prompt template:** - -``` -You are a test writer for the ADE desktop app (Electron + TypeScript). - -Your task: Write unit tests for the following files/functions: -[LIST THE SPECIFIC TEST ITEMS FROM THE GAP TRACKER] - -RULES: -1. Read 1-2 existing tests in the same domain BEFORE writing anything. - Copy their exact patterns for imports, mocking, assertions. -2. File naming: colocated next to source — `{module}.test.ts` beside `{module}.ts` -3. Every public function gets: happy path + error cases + edge cases. -4. NEVER write silent null guards (if (!x) return). Tests must FAIL LOUDLY. -5. NEVER mock the thing you're testing. -6. Run each test file as you write it: - cd apps/desktop && npx vitest run {file} -7. Fix until passing before moving to the next file. -8. Use vi.mock() for external dependencies, not for the module under test. -9. For renderer tests, use node environment (not jsdom) unless the test genuinely needs DOM. - -When ALL tests pass, report back with a summary of files created and test counts. -``` - -**MCP tester prompt template:** - -``` -You are a test writer for the ADE MCP server. +- A folder has **>5 test files averaging <15 cases each**. +- Two test files cover the same module from different angles (e.g. `prService.test.ts` + `prService.mergeContext.test.ts`). +- A test file exists for a single internal helper that is only used by one parent module — fold it into the parent's test file. -Your task: Write unit tests for the following files/functions: -[LIST THE SPECIFIC TEST ITEMS FROM THE GAP TRACKER] +When merging: keep the assertions that test public contracts, drop ones that re-test internal helpers already covered, name the result after the feature (`prService.test.ts`, not `prService.minorThing.test.ts`). -RULES: -1. Read existing tests (apps/mcp-server/src/mcpServer.test.ts, transport.test.ts) for patterns. -2. File naming: colocated `{module}.test.ts` beside source. -3. Run each test file as you write it: - cd apps/mcp-server && npx vitest run {file} -4. Fix until passing before moving to the next file. +### 2c. Hard rule — no new sibling files -When ALL tests pass, report back with a summary. -``` +When Pass 3 wants to add tests, you MUST extend the largest existing test file in the feature folder if one covers the same module. Create a new file ONLY if no existing file covers the module. ---- +### 2d. Anti-fragmentation budget per folder -## Phase 3: Monitor Agent Progress +A feature folder gets ONE test file per major contract. Use this budget: -After spawning all agents, **wait for them to complete**. Do NOT start doing work yourself. +| Folder size (source files) | Max test files | +|---|---| +| 1–5 source files | 1 test file | +| 6–15 source files | up to 3 test files (only if contracts genuinely diverge) | +| 16+ source files | up to 1 test file per major subsystem (read the README.md "Source file map") | -**If an agent gets stuck:** -- Message them directly with guidance -- If a test failure reveals an implementation bug, coordinate the fix +**If you exceed budget**, you MUST consolidate before finishing — do not leave the folder over budget. Naming pattern: +- `{service}.test.ts` — top-level service contract +- `{service}{Subsystem}.test.ts` — only if Subsystem is a distinct contract (e.g. `prMergeQueue.test.ts`, `ctoWorkerLifecycle.test.ts`) -Wait for ALL agents to report completion before proceeding. +Forbidden naming patterns (these are fragmentation signals): +- `{service}.{minorThing}.test.ts` — folds a minor concern into its own file. Merge into `{service}.test.ts`. +- `{helper}.test.ts` for a helper used by only one parent — fold into the parent's test file. --- -## Phase 4: Test Reality Check +## Pass 3: ADD (only if needed) -For each test file created, verify: +Goal: prove the feature's **public contract**. Not its internals. -1. **Does every mocked service/function actually get called in the real code?** -2. **Are there any tests that would pass even if the feature is completely broken?** -3. **Are there any silent null guards** (`if (!x) return`) that mask setup failures? -4. **Does the test actually exercise the code path it claims to test?** -5. **Are mocks realistic?** (e.g., don't mock away the entire service when testing a utility) +### 3a. What to test -**Anti-pattern check:** +Identify the *contracts* the new feature introduces: +- New exported function → one test of its happy path, plus the realistic failure modes a caller will hit. +- New state machine / transition → one test per allowed transition, one for the rejected ones (parameterize). +- New IPC handler → request shape in, response shape out, one error path. +- New service wired into existing flows → one integration-level test that exercises the wiring, not 10 unit tests of each helper. -```typescript -// BAD - test silently passes when setup fails -it("should handle merge context", () => { - if (!testData) return // SILENT PASS — never ran! -}) +### 3b. Hard caps (override only with a one-line justification in the summary) -// GOOD - fail loudly -it("should handle merge context", () => { - expect(testData, "testData should be set by beforeAll").toBeTruthy() - const result = buildMergeContext(testData) - expect(result.conflicts).toHaveLength(0) -}) -``` +- **Max 1 new test file per feature.** Prefer extending an existing file. If extending would push that file past 300 `it(` blocks, that file itself needs consolidation review — flag it but still extend. +- **Max 15 new `it(` blocks total** for the whole feature. If you want more, you're testing internals. +- **Min 3 meaningful assertions per test** (not `toBeDefined` × 3). +- **No test of a private/internal helper** unless it has non-obvious branching that the public API can't easily reach. +- **No render-only React tests.** If the change is purely visual, do not add a test — say so in the summary. +- **Respect the per-folder file budget from Pass 2d.** Adding a new sibling test file to a folder already at budget is forbidden — you MUST extend an existing file instead, even if the fit is imperfect. -If issues are found, fix them directly. +### 3c. Patterns ---- - -## Phase 5: Scoped Test Run +Before writing anything, read 1–2 existing tests in the **same folder** to copy: imports, mocking, setup/teardown, assertion style. -Verify the tests **this command just wrote** pass. Do NOT run the full suite — that is `/finalize`'s job, and running it here doubles the wait with no new signal. +Rules: +- Colocated naming: `{module}.test.ts` next to `{module}.ts`. +- Never mock the module under test. +- Mock only at process boundaries: file system, network, child processes, Electron APIs, IPC. +- Tests must FAIL LOUDLY — assert preconditions explicitly. +- Use `node` environment unless DOM is genuinely required. -### 5a. New test files together +### 3d. Run as you write -Run every test file created in Phase 3 in a single invocation: +After each test file is created or extended: ```bash -cd apps/desktop && npx vitest run [space-separated list of all new test files] +cd apps/desktop && npx vitest run ``` -All new tests must pass. If any fail, fix in place and re-run only the failing files. +Fix until passing before moving to the next. -### 5b. Affected existing tests +--- -If the branch's source changes could break existing tests (e.g., changed a service function's signature, renamed an exported type, altered shared contracts), run those existing test files — NOT the full suite: +## Verification -```bash -cd apps/desktop && npx vitest run [affected existing test files] -``` +After all three passes: -Scope "affected" narrowly — direct importers of touched modules and their test siblings. Do not expand to "everything in the same feature folder." +1. **Run the affected shards**, not the full suite (`/finalize` runs everything): + ```bash + cd apps/desktop && npx vitest run + ``` + Plus rerun the shard(s) containing files you deleted from, in case a helper was depending on them: + ```bash + cd apps/desktop && npx vitest run --shard=/8 + ``` -**If tests fail:** -- Check if it's a flaky test (retry once) -- If a specific test fails consistently, fix it and re-run only that file -- Do NOT re-run all tests — only the failed ones +2. **CI coverage check** — vitest workspace + CI are glob-based and shard 8-way (`.github/workflows/ci.yml` runs `npx vitest run --shard=${{ matrix.shard }}/8`). Any colocated `*.test.{ts,tsx}` file inside these globs is auto-picked-up; consolidating or deleting test files NEVER requires CI/workspace edits: + - `unit-main`: `src/main/**/*.test.{ts,tsx}` + - `unit-renderer`: `src/renderer/**/*.test.{ts,tsx}` + - `unit-shared`: `src/shared/**/*.test.{ts,tsx}` and `src/preload/**/*.test.{ts,tsx}` -### 5c. Not this command's job + MCP server tests live in `apps/mcp-server/` and are picked up by its own vitest config. Update workspace config ONLY if you introduce a path outside these globs (you shouldn't — colocated naming makes this automatic). -- **Full sharded suite run:** `/finalize` runs all 8 shards (and `test-ade-cli`) the same way CI does. Skip it here. -- **Build / typecheck / lint:** also deferred to `/finalize`. +3. **Do not run** typecheck, lint, or the full sharded suite — that's `/finalize`'s job. --- -## Phase 6: CI Verification - -### 6a. Check vitest workspace config +## Reference: where tests live & how to run them -Read `apps/desktop/vitest.workspace.ts` and verify every new test file matches one of the three workspace project include patterns: -- `unit-main`: `src/main/**/*.test.{ts,tsx}` -- `unit-renderer`: `src/renderer/**/*.test.{ts,tsx}` -- `unit-shared`: `src/shared/**/*.test.{ts,tsx}` and `src/preload/**/*.test.{ts,tsx}` +**Desktop** (`apps/desktop/`) — Vitest workspace, 3 projects, `node` env, forks pool, 20s timeout: +- One file: `cd apps/desktop && npx vitest run ` +- One project: `cd apps/desktop && npx vitest run --project unit-main` +- Sharded (CI uses 8): `cd apps/desktop && npx vitest run --shard=1/8` -If a test file does NOT match, update the workspace config. +**MCP server** (`apps/mcp-server/`): +- `cd apps/mcp-server && npx vitest run ` or `npm test` -### 6b. Check ci.yml coverage +**Web** (`apps/web/`) — marketing site, no tests. -Read `.github/workflows/ci.yml`. Verify: +### Feature docs (read for context before adding tests) -1. The `test-desktop` job runs `npx vitest run --shard=${{ matrix.shard }}/8` (8 shards) — this catches all `*.test.{ts,tsx}` files across all 3 workspace projects (`unit-main`, `unit-renderer`, `unit-shared`), so new colocated tests are automatically included. -2. The `test-mcp` job runs `npm test` in `apps/mcp-server/` — this catches all tests there. -3. No new test patterns were introduced that fall outside these globs. -4. The shard count in ci.yml matches what agents use locally (currently 8). +| Changed source area | Feature doc | +|---|---| +| services/orchestrator/, renderer missions/ | docs/features/missions/ | +| services/prs/, renderer prs/ | docs/features/pull-requests/ | +| services/lanes/, renderer lanes/ | docs/features/lanes/ | +| services/chat/, services/ai/, renderer chat/ | docs/features/chat/ + docs/features/agents/ | +| services/cto/, renderer cto/ | docs/features/cto/ + docs/features/linear-integration/ | +| services/memory/ | docs/features/memory/ | +| services/automations/, renderer automations/ | docs/features/automations/ | +| services/conflicts/ | docs/features/conflicts/ | +| services/computerUse/ | docs/features/computer-use/ | +| services/pty/, sessions/, processes/, renderer terminals/ | docs/features/terminals-and-sessions/ | +| services/files/, renderer files/ | docs/features/files-and-editor/ | +| services/sync/, syncRemoteCommandService | docs/features/sync-and-multi-device/ | +| services/onboarding/, services/config/, renderer settings/ | docs/features/onboarding-and-settings/ | +| services/history/ | docs/features/history/ | +| services/context/ | docs/features/context-packs/ | -### 6c. CI Coverage Checklist +Each `README.md` has a "Source file map" and a "gotchas / fragile areas" section. If something is flagged as fragile, that invariant deserves a test. -``` -- [ ] All new desktop test files match vitest.workspace.ts include patterns -- [ ] All new MCP server test files are picked up by vitest config -- [ ] Desktop tests will be included in sharded CI run -- [ ] MCP server tests will be included in CI run -- [ ] No test file exists without CI coverage -``` +Cross-cutting: `docs/ARCHITECTURE.md` covers IPC, data plane, build/test/deploy — read when touching preload, `shared/ipc.ts`, or `registerIpc`. --- -## Phase 7: Summary +## Summary (only output to the user) + +Output exactly this — nothing else. No phase-by-phase narration. ``` -## Test Automation Summary +## /automate summary -### Feature: [Name] -### Branch Changes: X files modified +Feature: -### Tests Created: +Pruned: +- orphaned test files removed: +- .skip/.todo blocks removed: +- anti-pattern tests fixed/removed: -| App | Files | Tests | Status | -|-----|-------|-------|--------| -| Desktop | X | Y | PASS | -| MCP Server | X | Y | PASS / N/A | -| **Total** | **X** | **Y** | **All Pass** | +Consolidated: +- , or "none" -### Test Files Created: -- [List each file with test count] +Added: +- +- Or "none — feature was visual / fully covered by consolidation" -### Scoped Test Run: -- New test files: PASS (X tests across Y files) -- Affected existing tests: PASS (X tests) or N/A -- NOTE: Full sharded suite run is deferred to `/finalize`. +Verification: +- Affected files: PASS ( tests) +- Shard re-run: PASS -### CI Coverage: -- vitest.workspace.ts: All new tests matched by include patterns -- ci.yml test-desktop: Sharded run covers all new tests -- ci.yml test-mcp: Covers all MCP server tests +Notes / assumptions: +- -### Next Steps: -- Run `/finalize` to wrap up (code simplifier, docs, CI checks) +Next: /finalize ``` --- -## Critical Test Rules (Non-Negotiable) - -### Silent Null Guard Anti-Pattern -Tests must FAIL LOUDLY. Never use `if (!x) return` in a test body. - -### Anti-Mock Rules -- **CAN mock**: External services, file system, network, child processes, Electron APIs, IPC -- **MUST NOT mock**: The module/function you're testing, the core logic under test -- **Before writing any test, ask**: "Would this test pass even if the feature is completely broken?" If yes, rewrite it. - -### Mandatory Coverage -- Every public function: Happy path + error cases + edge cases -- Every state transition: Valid and invalid transitions -- Every error handler: Verify errors propagate correctly - ---- - -## Completion Rules +## Completion rules -Mark as **"failed"** if you cannot create meaningful tests. -Mark as **"partial"** if tests are created but some don't pass. -Mark as **"completed"** ONLY if ALL of the following are true: +Mark **failed** if you cannot make a meaningful judgment about what changed. +Mark **partial** if Pass 1 left some tests still failing that you could not fix. +Mark **completed** only if all of: -1. ALL tests pass -2. All applicable test types were created per gap tracker -3. Scoped test run passed (Phase 5 — new + affected only; full suite deferred to /finalize) -4. CI covers all new test files (Phase 6) -5. No tests with silent null guards -6. No tests that mock the thing being tested -7. No test file exists without CI coverage +1. Every change you made (delete, edit, add) leaves the suite green on the affected files. +2. No `.skip`/`.only`/`.todo` introduced. +3. No new test file mocks the module it tests. +4. No new test file relies on `expect(true)`-class no-ops. +5. Every new test file matches a vitest workspace glob. +6. The summary is the *only* thing you output. diff --git a/.claude/commands/finalize.md b/.claude/commands/finalize.md index 5e4ace643..c7c218ece 100644 --- a/.claude/commands/finalize.md +++ b/.claude/commands/finalize.md @@ -31,13 +31,22 @@ This command runs end-to-end without user interaction. Do NOT: - Request clarification on ambiguous simplifications — skip the risky ones and note in the final report. - Ask before reverting your own work (e.g., Phase 3i drift check reverts simplifier edits silently). -The only outputs are the Phase 4 summary and any error messages for genuinely fatal failures (typecheck/lint errors, build crashes, test failures the agent itself caused). Every decision is made by the agent based on the rules in this file. +Outputs are exactly two things: the Phase 4 summary, and fatal-error messages (typecheck, lint, build, or self-caused test failures). Every other decision is made by the agent based on the rules in this file. + +## Guardrails (read once, apply everywhere) + +- Do NOT touch the public Mintlify site: `docs.json` and any root-level `*.mdx`, plus the root-level dirs `chat/`, `tools/`, `missions/`, `changelog/`, `configuration/`, `computer-use/`, `context-packs/`, `getting-started/`, `guides/`, `automations/`, `lanes/`, `cto/`. Internal docs under `docs/` are in scope. +- Do NOT modify `docs/OPTIMIZATION_OPPORTUNITIES.md` — append-only, human-curated. +- Do NOT run `apps/mcp-server` checks; the MCP server was removed. The agent surface is `apps/ade-cli`. +- Do NOT skip the sharded test run or substitute project-subset runs for it. `/finalize` is the gate that runs the full suite. +- Do NOT use bare `pkill -f vitest` / `pkill -f node`. Always scope to `apps/desktop`, `apps/ade-cli`, or `apps/web`. +- Do NOT declare remote PR review clean from `/finalize` alone — see Phase 3j handoff. ## Pipeline Overview ``` Phase 1: Analyze code changes and batch simplification work (lead) -Phase 2: Parallel execution (simplify + docs + mobile parity)(agents) +Phase 2: Parallel execution (simplify + docs + mobile + cli) (agents) Phase 3: CI sync + local verification (lead) Phase 4: Summary (lead) ``` @@ -79,11 +88,19 @@ git diff main --stat | tail -20 git log main..HEAD --oneline ``` +### 1e. Snapshot pre-Phase-2 file list (used by 3i drift check) + +```bash +git diff main --name-only | sort > /tmp/finalize-branch-files.txt +``` + --- ## Phase 2: Parallel Execution -Spawn agents in parallel using the **Agent** tool: +**Preferred orchestration: `TeamCreate`.** Spawn the four agents below as one team so progress is tracked, inboxes catch cross-agent messages, and a single completion event surfaces the whole batch. Per the global git-worktrees policy, do **not** pass worktree isolation — all agents work in the main directory. + +Fallback: if `TeamCreate` is unavailable in the current harness (or if running outside Claude entirely), spawn them as parallel `Agent` calls in a single tool-call round and aggregate their reports manually before Phase 3. ### Simplifier agents (1-3 based on batch size) @@ -141,6 +158,9 @@ Step 2: Map changed source to internal docs | Source Directory | Doc Location | |----------------------------------------------------|----------------------------------------------------| | apps/desktop/src/main/services/orchestrator/ | docs/features/missions/ | +| apps/desktop/src/main/services/projects/ | docs/features/project-home/ | +| apps/desktop/src/main/services/proof/ | docs/features/proof.md | +| apps/desktop/src/main/services/review/ | docs/features/pull-requests/ | | apps/desktop/src/main/services/prs/ | docs/features/pull-requests/ | | apps/desktop/src/main/services/lanes/ | docs/features/lanes/ | | apps/desktop/src/main/services/memory/ | docs/features/memory/ | @@ -179,11 +199,7 @@ Step 3: Update docs in place - Do NOT add changelog sections, "Updated on X" notes, or dated markers. - Do NOT modify docs/OPTIMIZATION_OPPORTUNITIES.md via this agent — it is append-only and human-curated. -Step 4: Append-only — NEVER touch the public Mintlify site -- Do NOT modify docs.json or any *.mdx file at repo root. -- Do NOT modify ./chat/, ./tools/, ./missions/, ./changelog/, ./configuration/, ./computer-use/, ./context-packs/, ./getting-started/, ./guides/, ./automations/, ./lanes/, ./cto/ (these are Mintlify pages). - -Step 5: Run doc validation +Step 4: Run doc validation node scripts/validate-docs.mjs This validator only covers the Mintlify site. For internal docs, self-check: @@ -243,6 +259,92 @@ Report: - Validation run and any environment limitations ``` +### CLI parity agent + +The `apps/ade-cli/` package is the agent-facing surface for ADE. Every desktop +action should be reachable either through a typed subcommand (`ade lanes …`, +`ade prs …`, `ade chat …`, `ade tests …`, `ade run …`, `ade proof …`) or +through the generic `ade actions run ` registry exposed by +`adeRpcServer.ts`. When a feature branch adds, renames, or removes a desktop +feature, the CLI silently drifts unless someone updates it in the same PR. +This agent closes that gap. + +Spawn a general-purpose agent with this prompt: + +``` +You are the ADE CLI parity reviewer. + +The ADE CLI (apps/ade-cli) is the primary agent-facing interface to the ADE +desktop app. Its goal is to surface every meaningful action inside ADE +desktop — either as a typed subcommand or via the generic +`ade actions run ` registry. When desktop changes, the CLI +must change with it. Your job is to detect drift on this branch and patch +apps/ade-cli/ so the CLI stays in lockstep with desktop. + +Step 1: Get branch context + git diff main --name-only + git diff main --stat | tail -30 + git log main..HEAD --oneline + +Step 2: Identify CLI-relevant desktop changes +Treat anything under these paths as a candidate for new / changed / removed +CLI surface: +- apps/desktop/src/main/services/** (each service is a candidate action + domain — lanes, prs, chat, tests, proof, run, git, files, missions, + automations, computerUse, context, conflicts, history, memory, onboarding, + pty, sessions, processes, sync, config, cto, ai) +- apps/desktop/src/preload/** and apps/desktop/src/shared/** (IPC and + shared contracts the CLI ultimately calls through) +- New domains/actions registered with the action registry on either side + +Step 3: Map each candidate to the CLI +- Typed subcommands live in apps/ade-cli/src/cli.ts (~3300 lines), a + case-based dispatcher. Existing cases include lanes, git-status, prs-list, + chat-list, tests-runs, proof-list, actions-list, action-result, etc. + Locate the closest existing case block and either extend it or add a + sibling case alongside it. +- The RPC + actions-registry surface lives in + apps/ade-cli/src/adeRpcServer.ts (~6500 lines), with a no-desktop fallback + in apps/ade-cli/src/headlessLinearServices.ts. New service actions usually + need wiring in one or both so `ade actions run ` resolves + them whether or not the desktop socket is up. +- The user-facing inventory lives in apps/ade-cli/README.md under + "CLI surface". Keep it accurate whenever a typed command is added, + renamed, or removed. + +Step 4: Apply auto-fix edits — scoped to apps/ade-cli/ only +- New feature: add a typed subcommand if the desktop feature is a distinct + user-facing workflow (lane / PR / chat / test / run / proof / mission / + automation / etc.). If it is just a new low-level service action, ensure + it is reachable via the actions registry and skip a typed wrapper. +- Renamed or behavior-changed feature: update the existing case to match + new parameters, IPC names, or output shape. Keep flag names stable when + possible — flag any breaking renames in the report. +- Removed feature: delete the dead case and any registry wiring. Do NOT + leave a stub. Drop the corresponding README line. +- Reuse existing patterns: match surrounding cases for argv parsing, + --text / --json output mode, error formatting, and --lane / --project-root + argument handling. Do not invent new dispatch styles. + +Step 5: Validate locally before reporting + cd apps/ade-cli && npm run typecheck + cd apps/ade-cli && npm test + +If tests fail in files you did not touch, leave them — Phase 3 handles +test-suite drift. Do not rewrite unrelated tests. + +Out of scope: +- Do NOT edit anything under apps/desktop/. +- Do NOT touch docs/ — the docs agent owns that. +- Do NOT refactor unrelated CLI code. + +Report: +- apps/ade-cli/ files changed (or "no CLI changes required") +- For each branch change: desktop change → CLI change, or why not applicable +- Any breaking flag / command renames +- typecheck and test results +``` + Wait for all agents to complete. --- @@ -266,16 +368,13 @@ cd apps/ade-cli && npm install cd apps/web && npm install ``` -Do not run `apps/mcp-server` checks. The MCP server was removed; ADE's -agent-facing command surface now lives in `apps/ade-cli`. - -After install, check for uncommitted lock file changes — if any lock file is dirty, it means package.json was modified without regenerating the lock file, which will break CI's `npm ci`: +After install, check for uncommitted lock file changes — a dirty lock file means `package.json` was modified without regenerating the lock, which will break CI's `npm ci`: ```bash git diff --name-only -- '*/package-lock.json' ``` -If lock files changed, warn and include them in the commit. +This is a **hard gate**: if any lock file is dirty, stage it (`git add `) and report it in the Phase 4 summary so the user commits it before pushing. Do not proceed past 3b with dirty lock files. ### 3c. Typecheck all apps @@ -293,52 +392,35 @@ cd apps/web && npm run typecheck cd apps/desktop && npm run lint ``` -### 3e. Desktop tests — full suite, sharded 8-way, run in PARALLEL - -`/finalize` is the gate that runs the whole test suite. Run **all 8 shards concurrently** — not sequentially. Running them serially takes 8× longer and masks real CI wall-clock behavior. +### 3e. Tests — desktop sharded 8-way + ade-cli, ALL 9 commands in one parallel round -The command must be identical to `.github/workflows/ci.yml` (job `test-desktop`, matrix shard 1–8, step at line 139): - -``` -- run: cd apps/desktop && npx vitest run --shard=${{ matrix.shard }}/8 -``` - -Locally that maps to 8 parallel Bash invocations in a single tool-call round: +`/finalize` is the gate that runs the whole test suite. Issue these **9 commands as concurrent Bash tool calls in a single message**. Do not chain with `&&`/`;`, do not run them sequentially — that takes 9× longer and masks real CI wall-clock behavior. Mirrors `.github/workflows/ci.yml` jobs `test-desktop` (matrix 1–8) and `test-ade-cli`: ```bash -cd apps/desktop && npx vitest run --shard=1/8 # shard 1 of 8 -cd apps/desktop && npx vitest run --shard=2/8 # shard 2 of 8 -cd apps/desktop && npx vitest run --shard=3/8 # shard 3 of 8 -cd apps/desktop && npx vitest run --shard=4/8 # shard 4 of 8 -cd apps/desktop && npx vitest run --shard=5/8 # shard 5 of 8 -cd apps/desktop && npx vitest run --shard=6/8 # shard 6 of 8 -cd apps/desktop && npx vitest run --shard=7/8 # shard 7 of 8 -cd apps/desktop && npx vitest run --shard=8/8 # shard 8 of 8 +cd apps/desktop && npx vitest run --shard=1/8 +cd apps/desktop && npx vitest run --shard=2/8 +cd apps/desktop && npx vitest run --shard=3/8 +cd apps/desktop && npx vitest run --shard=4/8 +cd apps/desktop && npx vitest run --shard=5/8 +cd apps/desktop && npx vitest run --shard=6/8 +cd apps/desktop && npx vitest run --shard=7/8 +cd apps/desktop && npx vitest run --shard=8/8 +cd apps/ade-cli && npm test ``` -Issue these as 8 concurrent Bash tool calls in a single message (one call per shard). Do not chain them with `&&` or `;` or run them one at a time. The workspace has 3 projects (`unit-main`, `unit-renderer`, `unit-shared`) — sharding distributes across all three automatically. +The desktop workspace has 3 projects (`unit-main`, `unit-renderer`, `unit-shared`); sharding distributes across all three automatically. -If a shard fails, re-run **only that shard** (or, better, only the specific failing test file inside it). Never re-run all 8 shards to verify a one-file fix. +If a shard fails, re-run **only that shard** (or only the failing test file). Never re-run all 9 to verify a one-file fix. -Workspace-project subsets exist for debugging only; they are NOT a substitute for the sharded run in `/finalize`: +Workspace-project subsets exist for debugging only; they are NOT a substitute for the sharded run: ```bash cd apps/desktop && npx vitest run --project unit-main # ~150+ main-process tests -cd apps/desktop && npx vitest run --project unit-renderer # ~85+ renderer tests -cd apps/desktop && npx vitest run --project unit-shared # ~7 shared/preload tests +cd apps/desktop && npx vitest run --project unit-renderer # ~85+ renderer tests +cd apps/desktop && npx vitest run --project unit-shared # ~7 shared/preload tests ``` -### 3f. ADE CLI tests — separate CI job, run alongside the 8 shards - -CI runs `test-ade-cli` as its own parallel job (`.github/workflows/ci.yml:156`). Locally, include it in the same parallel tool-call round as the 8 desktop shards — it's effectively a 9th concurrent invocation, not something to run after: - -```bash -cd apps/ade-cli && npm test -``` - -Do NOT run apps/mcp-server tests — the MCP server was removed; the agent-facing surface lives in `apps/ade-cli`. - -### 3g. Build all apps +### 3f. Build all apps ```bash cd apps/desktop && npm run build @@ -346,7 +428,7 @@ cd apps/ade-cli && npm run build cd apps/web && npm run build ``` -### 3h. Validate docs +### 3g. Validate docs ```bash node scripts/validate-docs.mjs @@ -372,44 +454,35 @@ Both commands should produce empty output. Any `MISSING map:` or `BROKEN LINK:` All checks must pass. If any fail, fix and re-run only the failed step. -### 3i. Test-simplifier drift check (catch Phase 2 over-reach) +### 3h. Test-simplifier drift check (catch Phase 2 over-reach) -When a simplifier agent removed "unused" code, the colocated test may still reference it — the test will only light up in Phase 3e. If a test failure appears **only in a file the simplifier touched (or its test sibling)**, treat it as suspect: +If Phase 3e fails only in files the simplifier touched (or their `*.test.ts(x)` siblings), treat it as drift, not a real failure. The pre-Phase-2 snapshot lives at `/tmp/finalize-branch-files.txt` (written in 1e); compare against current diff to see what the simplifier added on top: ```bash -# Files the simplifier touched this run: -# (Run once before Phase 2; diff after to see what changed.) -git diff main --name-only | sort > /tmp/finalize-branch-files.txt - -# After Phase 2, list what changed in this session on top of the prior branch state: git diff --name-only | sort > /tmp/finalize-session-files.txt +comm -13 /tmp/finalize-branch-files.txt /tmp/finalize-session-files.txt ``` -If Phase 3e fails only inside files the simplifier touched, revert the simplifier's edits to those files and re-run. Do NOT rewrite the test suite in Phase 3 — tests that drift because the feature branch refactored UI are a separate follow-up. +Revert the simplifier's edits to the offending files and re-run only the failed shard. Do NOT rewrite the test suite in Phase 3 — tests that drift because the feature branch refactored UI are a separate follow-up. -### 3j. Cleanup lingering processes +### 3i. Cleanup lingering processes The parallel shards, typecheck, lint, and build commands in Phase 3 sometimes leave worker processes hanging after the phase exits — most commonly vitest worker pools from the 8-shard run, and tsup/esbuild workers from `npm run build`. They don't fail the CI check, but they sit in memory, can hold file locks, and pile up across repeated `/finalize` runs. -After the rest of Phase 3 passes, kill orphaned workers. Match on the project path so you only catch processes from **this** finalize run — don't nuke vitest instances the user may have running in another terminal or editor: +After Phase 3 passes, kill orphaned workers. Always scope to ADE app paths (see Guardrails): ```bash -# List what's lingering (agent: read this before killing anything) -pgrep -fa "vitest|tsup|tsc --noEmit|eslint" | grep -E "apps/(desktop|ade-cli|web)" || echo " (no orphans)" - -# Kill vitest workers scoped to this project -pgrep -f "vitest.*apps/(desktop|ade-cli)" | xargs -r kill 2>/dev/null +PATTERN='(vitest|tsup|tsc --noEmit|eslint).*apps/(desktop|ade-cli|web)' -# Kill hung build / typecheck processes scoped to this project -pgrep -f "tsup.*apps/(desktop|ade-cli|web)|tsc --noEmit.*apps/(desktop|ade-cli|web)" | xargs -r kill 2>/dev/null +# 1. List what's lingering before killing anything +pgrep -fa "$PATTERN" || echo " (no orphans)" -# Give them 2s to exit cleanly, then SIGKILL anything stubborn in the project path +# 2. SIGTERM, wait 2s, then SIGKILL stragglers +pgrep -f "$PATTERN" | xargs -r kill 2>/dev/null sleep 2 -pgrep -f "vitest.*apps/(desktop|ade-cli)|tsup.*apps/(desktop|ade-cli|web)" | xargs -r kill -9 2>/dev/null || true +pgrep -f "$PATTERN" | xargs -r kill -9 2>/dev/null || true ``` -Never use a bare `pkill -f vitest` or `pkill -f node` — that would kill processes outside this finalize run. Always scope the pattern to `apps/desktop`, `apps/ade-cli`, or `apps/web` so only ADE-spawned workers are targeted. - Also watch for orphaned node-pty or Electron helper processes if the tests spawned subprocesses (rare, but happens): ```bash @@ -420,7 +493,7 @@ Kill selectively only if the parent is clearly gone (PPID == 1 on macOS/Linux). Report killed PIDs in the Phase 4 summary under "Cleanup" so the user can see what happened. -### 3k. Remote PR poll handoff +### 3j. Remote PR poll handoff If this finalize run is followed by a push or PR update, do not treat the first `gh pr checks` result as authoritative proof that remote review is done. Some @@ -469,6 +542,12 @@ Do not report "PR clean" from `/finalize` alone. - Applicability notes: [brief list] - Validation: PASS / blocked with reason +### CLI Parity: +- apps/ade-cli files changed: [list or "none required"] +- Desktop change → CLI change mapping: [brief list] +- Breaking flag/command renames: [list or "none"] +- Validation (typecheck + tests): PASS / blocked with reason + ### CI Verification: - Lock files in sync: PASS - Typecheck (desktop): PASS @@ -495,16 +574,4 @@ Do not report "PR clean" from `/finalize` alone. ## Completion Checklist -Before marking complete: -- [ ] Code simplification completed on all batches -- [ ] Documentation updated for all affected areas -- [ ] Mobile parity reviewed; applicable iOS updates made and validated -- [ ] CI workflow sync verified (no orphaned test files) -- [ ] Lock files in sync (no dirty lock files after install) -- [ ] Typecheck passed (desktop + ade-cli + web) -- [ ] Lint passed (desktop) -- [ ] All tests passed (desktop sharded 8-way + ade-cli) -- [ ] All apps build successfully -- [ ] Doc validation passed -- [ ] Orphan worker processes cleaned up (vitest/tsup/tsc) — scoped to apps/ paths only -- [ ] Remote PR review is not declared clean by finalize alone; after push, `/shipLane` or an equivalent poll loop must use the branch-specific cadence and re-check comments/reviews +Before marking complete: every Phase 3 step (3a–3j) must report PASS in the Phase 4 summary, and all four Phase 2 agents (simplify, docs, mobile, cli) must have reported back. Remote PR review is **not** declared clean by `/finalize` — handoff to `/shipLane` (Phase 3j) is mandatory after push. diff --git a/apps/ade-cli/README.md b/apps/ade-cli/README.md index e6f73a22e..6714b3d20 100644 --- a/apps/ade-cli/README.md +++ b/apps/ade-cli/README.md @@ -113,3 +113,28 @@ ade actions list --text ``` Then prefer typed commands such as `ade lanes list --text`, `ade files read --text`, `ade prs checks --text`, or `ade tests runs --json`. Use `ade actions run ...` as the broad escape hatch for internal ADE actions that do not yet have a typed command. + +## Automations + +Automation rules are managed with `ade automations `. Run `ade help automations` for the full flag reference. The lane-mode flags layer on top of `--from-file` / `--stdin` / `--text` for `create` and `update`: + +```bash +# Open a fresh lane for every new GitHub issue, naming it from the issue number + title. +ade automations example > rule.json +ade automations create --from-file rule.json \ + --lane-mode create --lane-name-preset issue-num-title + +# Reuse an existing lane instead. +ade automations create --from-file rule.json --lane-mode reuse --lane lane-42 + +# Custom template (only valid with --lane-name-preset custom). +ade automations create --from-file rule.json \ + --lane-mode create --lane-name-preset custom \ + --lane-name-template "{{trigger.issue.author}}/{{trigger.issue.title}}" + +# Filter run history by status. +ade automations runs --rule rule-1 --status failed +ade automations run-show --text +``` + +The standalone `create-lane` action is deprecated. By default the CLI auto-migrates a rule whose first action is `create-lane` into `execution.laneMode: "create"` and carries the template forward. Pass `--allow-legacy` on `create` / `update` to opt out of the migration. diff --git a/apps/ade-cli/src/cli.test.ts b/apps/ade-cli/src/cli.test.ts index dc71f469b..97ca66fd8 100644 --- a/apps/ade-cli/src/cli.test.ts +++ b/apps/ade-cli/src/cli.test.ts @@ -702,6 +702,228 @@ describe("ADE CLI", () => { }); }); + it("automations create merges --lane-mode and preset flags into draft.execution", () => { + const plan = buildCliPlan([ + "automations", + "create", + "--text", + "id: r1\nname: R\n", + "--lane-mode", + "create", + "--lane-name-preset", + "issue-title", + ]); + expect(plan.kind).toBe("execute"); + if (plan.kind !== "execute") return; + expect(plan.steps[0]?.params).toMatchObject({ + arguments: { + args: { + draft: { + id: "r1", + execution: { laneMode: "create", laneNamePreset: "issue-title" }, + }, + }, + }, + }); + }); + + it("automations create with --lane-mode reuse and --lane sets targetLaneId", () => { + const plan = buildCliPlan([ + "automations", + "create", + "--text", + "id: r1\n", + "--lane-mode", + "reuse", + "--lane", + "lane-99", + ]); + expect(plan.kind).toBe("execute"); + if (plan.kind !== "execute") return; + expect(plan.steps[0]?.params).toMatchObject({ + arguments: { + args: { + draft: { + execution: { laneMode: "reuse", targetLaneId: "lane-99" }, + }, + }, + }, + }); + }); + + it("automations create with --lane-name-preset custom accepts --lane-name-template", () => { + const plan = buildCliPlan([ + "automations", + "create", + "--text", + "id: r1\n", + "--lane-mode", + "create", + "--lane-name-preset", + "custom", + "--lane-name-template", + "{{trigger.issue.author}}/{{trigger.issue.title}}", + ]); + expect(plan.kind).toBe("execute"); + if (plan.kind !== "execute") return; + expect(plan.steps[0]?.params).toMatchObject({ + arguments: { + args: { + draft: { + execution: { + laneMode: "create", + laneNamePreset: "custom", + laneNameTemplate: "{{trigger.issue.author}}/{{trigger.issue.title}}", + }, + }, + }, + }, + }); + }); + + it("automations create rejects --lane with --lane-mode create", () => { + expect(() => + buildCliPlan([ + "automations", + "create", + "--text", + "id: r1\n", + "--lane-mode", + "create", + "--lane", + "lane-1", + ]), + ).toThrow(/--lane is only valid with --lane-mode reuse/); + }); + + it("automations create rejects --lane-name-preset with --lane-mode reuse", () => { + expect(() => + buildCliPlan([ + "automations", + "create", + "--text", + "id: r1\n", + "--lane-mode", + "reuse", + "--lane-name-preset", + "issue-title", + ]), + ).toThrow(/--lane-name-preset is only valid with --lane-mode create/); + }); + + it("automations create rejects --lane-name-template with non-custom preset", () => { + expect(() => + buildCliPlan([ + "automations", + "create", + "--text", + "id: r1\n", + "--lane-mode", + "create", + "--lane-name-preset", + "issue-title", + "--lane-name-template", + "{{trigger.issue.title}}", + ]), + ).toThrow(/--lane-name-template is only valid with --lane-name-preset custom/); + }); + + it("automations create rejects unknown --lane-mode value", () => { + expect(() => + buildCliPlan([ + "automations", + "create", + "--text", + "id: r1\n", + "--lane-mode", + "bogus", + ]), + ).toThrow(/--lane-mode must be one of create, reuse/); + }); + + it("automations runs accepts a --status filter", () => { + const plan = buildCliPlan(["automations", "runs", "--rule", "r1", "--status", "failed"]); + expect(plan.kind).toBe("execute"); + if (plan.kind !== "execute") return; + expect(plan.steps[0]?.params).toEqual({ + name: "run_ade_action", + arguments: { + domain: "automations", + action: "listRuns", + args: { automationId: "r1", status: "failed" }, + }, + }); + }); + + it("automations runs rejects an unknown --status value", () => { + expect(() => + buildCliPlan(["automations", "runs", "--status", "wat"]), + ).toThrow(/--status must be one of/); + }); + + it("automations example prints a parseable example rule via help kind", () => { + const plan = buildCliPlan(["automations", "example"]); + expect(plan.kind).toBe("help"); + if (plan.kind !== "help") return; + const parsed = JSON.parse(plan.text); + expect(parsed).toMatchObject({ + execution: { laneMode: "create", laneNamePreset: "issue-num-title" }, + }); + }); + + it("automations create auto-migrates a legacy create-lane first action into laneMode", () => { + const draft = JSON.stringify({ + id: "legacy-rule", + actions: [ + { type: "create-lane", laneNameTemplate: "{{trigger.issue.title}}" }, + { type: "agent-session", modelId: "claude-opus-4-7" }, + ], + }); + const plan = buildCliPlan(["automations", "create", "--text", draft]); + expect(plan.kind).toBe("execute"); + if (plan.kind !== "execute") return; + expect(plan.steps[0]?.params).toMatchObject({ + arguments: { + args: { + draft: { + execution: { + laneMode: "create", + laneNamePreset: "custom", + laneNameTemplate: "{{trigger.issue.title}}", + }, + actions: [{ type: "agent-session" }], + }, + }, + }, + }); + }); + + it("automations create --allow-legacy preserves the legacy create-lane action", () => { + const draft = JSON.stringify({ + id: "legacy-rule", + actions: [{ type: "create-lane", laneNameTemplate: "x" }], + }); + const plan = buildCliPlan(["automations", "create", "--text", draft, "--allow-legacy"]); + expect(plan.kind).toBe("execute"); + if (plan.kind !== "execute") return; + expect(plan.steps[0]?.params).toMatchObject({ + arguments: { + args: { + draft: { + actions: [{ type: "create-lane", laneNameTemplate: "x" }], + }, + }, + }, + }); + }); + + it("automations run-show wires the automation-run-detail formatter", () => { + const plan = buildCliPlan(["automations", "run-show", "run-1"]); + expect(plan.kind).toBe("execute"); + if (plan.kind !== "execute") return; + expect(plan.formatter).toBe("automation-run-detail"); + }); + it("automations toggle errors when --enabled is omitted", () => { expect(() => buildCliPlan(["automations", "toggle", "rule-42"])).toThrow( /--enabled /, diff --git a/apps/ade-cli/src/cli.ts b/apps/ade-cli/src/cli.ts index ded15c76c..bd2104e28 100644 --- a/apps/ade-cli/src/cli.ts +++ b/apps/ade-cli/src/cli.ts @@ -55,7 +55,8 @@ type FormatterId = | "tests-runs" | "proof-list" | "actions-list" - | "action-result"; + | "action-result" + | "automation-run-detail"; type CliPlan = | { kind: "help"; text: string } @@ -533,8 +534,20 @@ const HELP_BY_COMMAND: Record = { $ ade automations delete Remove a local rule $ ade automations toggle --enabled true|false $ ade automations run [--dry-run] Trigger a rule manually - $ ade automations runs [--rule ] [--limit 50] [--json] + $ ade automations runs [--rule ] [--status ] [--limit 50] $ ade automations run-show [--json] Inspect a run + $ ade automations example Print an example rule (stdout) + + Lane mode flags (apply to create/update on top of --from-file/--stdin/--text): + --lane-mode Spawn a new lane per run, or reuse one + --lane Target lane (only with --lane-mode reuse) + --lane-name-preset + --lane-name-template Template (only with preset custom) + --allow-legacy Pass legacy create-lane action through + unchanged (default: auto-migrate to laneMode) + + Run filter: + --status `, }; @@ -1729,6 +1742,106 @@ function parseDraftInput(args: string[]): JsonObject { return parsed; } +const AUTOMATION_LANE_MODES = ["create", "reuse"] as const; +const AUTOMATION_LANE_NAME_PRESETS = ["issue-title", "issue-num-title", "pr-title-author", "custom"] as const; +const AUTOMATION_RUN_STATUSES = ["queued", "running", "succeeded", "failed", "cancelled", "paused", "all"] as const; + +type AutomationLaneModeFlag = (typeof AUTOMATION_LANE_MODES)[number]; +type AutomationLaneNamePresetFlag = (typeof AUTOMATION_LANE_NAME_PRESETS)[number]; + +function readEnumOption( + args: string[], + names: string[], + allowed: readonly T[], + label: string, +): T | null { + const raw = readValue(args, names); + if (raw == null) return null; + if (!(allowed as readonly string[]).includes(raw)) { + throw new CliUsageError(`${label} must be one of ${allowed.join(", ")}.`); + } + return raw as T; +} + +function applyLaneFlagsToDraft(draft: JsonObject, args: string[]): JsonObject { + const laneMode = readEnumOption(args, ["--lane-mode"], AUTOMATION_LANE_MODES, "--lane-mode"); + const laneId = readLaneId(args); + const preset = readEnumOption(args, ["--lane-name-preset"], AUTOMATION_LANE_NAME_PRESETS, "--lane-name-preset"); + const template = readValue(args, ["--lane-name-template"]); + + if (laneMode == null && laneId == null && preset == null && template == null) { + return draft; + } + + if (laneId != null && laneMode === "create") { + throw new CliUsageError("--lane is only valid with --lane-mode reuse."); + } + if (preset != null && laneMode === "reuse") { + throw new CliUsageError("--lane-name-preset is only valid with --lane-mode create."); + } + if (template != null && preset != null && preset !== "custom") { + throw new CliUsageError("--lane-name-template is only valid with --lane-name-preset custom."); + } + if (template != null && preset == null && laneMode !== "create") { + throw new CliUsageError("--lane-name-template requires --lane-mode create (with --lane-name-preset custom)."); + } + + const existingExecution = isRecord(draft.execution) ? draft.execution : {}; + const execution: JsonObject = { ...existingExecution }; + if (laneMode != null) execution.laneMode = laneMode; + if (laneId != null) execution.targetLaneId = laneId; + if (preset != null) execution.laneNamePreset = preset; + if (template != null) execution.laneNameTemplate = template; + + return { ...draft, execution }; +} + +function migrateLegacyCreateLane(draft: JsonObject, opts: { allowLegacy: boolean }): JsonObject { + const actions = Array.isArray(draft.actions) ? draft.actions : null; + if (!actions || actions.length === 0) return draft; + const first = actions[0]; + if (!isRecord(first) || first.type !== "create-lane") return draft; + if (opts.allowLegacy) return draft; + const execution = isRecord(draft.execution) ? draft.execution : {}; + const template = typeof first.laneNameTemplate === "string" ? first.laneNameTemplate : undefined; + const migratedExecution: JsonObject = { + ...execution, + laneMode: "create", + ...(template ? { laneNamePreset: "custom", laneNameTemplate: template } : {}), + }; + return { ...draft, execution: migratedExecution, actions: actions.slice(1) }; +} + +function automationsExampleText(): string { + return JSON.stringify( + { + id: "example-rule", + name: "Open lane per GitHub issue", + enabled: true, + trigger: { + kind: "github.issue", + event: "opened", + }, + execution: { + kind: "agent-session", + laneMode: "create", + laneNamePreset: "issue-num-title", + session: { + prompt: "Investigate and propose a fix for {{trigger.issue.title}}.", + }, + }, + actions: [ + { + type: "agent-session", + modelId: "claude-opus-4-7", + }, + ], + }, + null, + 2, + ); +} + function buildAutomationsPlan(args: string[]): CliPlan { const sub = firstPositional(args) ?? "list"; @@ -1741,8 +1854,14 @@ function buildAutomationsPlan(args: string[]): CliPlan { return { kind: "execute", label: `automations show ${id}`, steps: [actionStep("result", "automations", "get", { id })] }; } + if (sub === "example") { + return { kind: "help", text: automationsExampleText() }; + } + if (sub === "create") { - const draft = parseDraftInput(args); + const allowLegacy = readFlag(args, ["--allow-legacy"]); + const raw = parseDraftInput(args); + const draft = applyLaneFlagsToDraft(migrateLegacyCreateLane(raw, { allowLegacy }), args); return { kind: "execute", label: "automations create", @@ -1752,7 +1871,9 @@ function buildAutomationsPlan(args: string[]): CliPlan { if (sub === "update") { const id = requireValue(readValue(args, ["--id"]) ?? firstPositional(args), "rule id"); - const draft = parseDraftInput(args); + const allowLegacy = readFlag(args, ["--allow-legacy"]); + const raw = parseDraftInput(args); + const draft = applyLaneFlagsToDraft(migrateLegacyCreateLane(raw, { allowLegacy }), args); return { kind: "execute", label: `automations update ${id}`, @@ -1800,12 +1921,14 @@ function buildAutomationsPlan(args: string[]): CliPlan { if (sub === "runs") { const automationId = readValue(args, ["--rule", "--automation", "--id"]); const limit = readIntOption(args, ["--limit"]); + const status = readEnumOption(args, ["--status"], AUTOMATION_RUN_STATUSES, "--status"); return { kind: "execute", label: "automations runs", steps: [actionStep("result", "automations", "listRuns", { ...(automationId ? { automationId } : {}), ...(typeof limit === "number" ? { limit } : {}), + ...(status ? { status } : {}), })], }; } @@ -1815,12 +1938,13 @@ function buildAutomationsPlan(args: string[]): CliPlan { return { kind: "execute", label: `automations run-show ${runId}`, + formatter: "automation-run-detail", steps: [actionStep("result", "automations", "getRunDetail", { runId })], }; } throw new CliUsageError( - "automations supports list, show, create, update, delete, toggle, run, runs, or run-show.", + "automations supports list, show, create, update, delete, toggle, run, runs, run-show, or example.", ); } @@ -2721,6 +2845,43 @@ function cell(value: unknown, width = 42): string { return truncateCell(String(value), width); } +function formatAutomationRunDetail(value: unknown): string { + if (!isRecord(value)) return JSON.stringify(value, null, 2); + const run = isRecord(value.run) ? value.run : value; + const actions = Array.isArray(value.actions) + ? value.actions + : Array.isArray(run.actions) ? run.actions : []; + const header = renderKeyValues("ADE automation run", [ + ["id", run.id], + ["rule", run.automationId ?? run.ruleId], + ["status", run.status], + ["startedAt", run.startedAt], + ["finishedAt", run.finishedAt], + ["lane", run.laneId ?? run.targetLaneId], + ["error", run.errorMessage], + ]); + const rows = actions + .filter((action): action is JsonObject => isRecord(action)) + .map((action) => { + const kind = typeof action.kind === "string" ? action.kind + : typeof action.type === "string" ? action.type + : "action"; + const status = typeof action.status === "string" ? action.status : "?"; + const error = typeof action.errorMessage === "string" ? action.errorMessage : ""; + const output = typeof action.output === "string" ? action.output : ""; + const isLaneSetup = kind === "lane-setup"; + const note = error + ? (isLaneSetup ? `FAILED: ${error}` : error) + : isLaneSetup && output + ? `created lane: ${output}` + : output; + const label = isLaneSetup ? "lane-setup" : kind; + return [label, status, note]; + }); + const table = renderTable(["step", "status", "detail"], rows, "(no actions)"); + return [header, "", "Actions", table].join("\n"); +} + function renderKeyValues(title: string, entries: Array<[string, unknown]>): string { const rows = entries.filter(([, value]) => value !== undefined && value !== null && value !== ""); const labelWidth = Math.max(0, ...rows.map(([label]) => label.length)); @@ -3054,6 +3215,8 @@ function formatTextOutput(value: unknown, formatter: FormatterId | undefined): s return formatProofList(value); case "actions-list": return formatActionsList(value); + case "automation-run-detail": + return formatAutomationRunDetail(value); case "action-result": default: if (isRecord(value)) return renderKeyValues("ADE result", Object.entries(value).slice(0, 24)); diff --git a/apps/desktop/src/main/services/ai/cliExecutableShellPath.test.ts b/apps/desktop/src/main/services/ai/cliExecutableShellPath.test.ts deleted file mode 100644 index d9e8dc4de..000000000 --- a/apps/desktop/src/main/services/ai/cliExecutableShellPath.test.ts +++ /dev/null @@ -1,78 +0,0 @@ -import path from "node:path"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; - -const execFileSyncMock = vi.hoisted(() => vi.fn()); - -vi.mock("node:child_process", async () => { - const actual = await vi.importActual("node:child_process"); - return { - ...actual, - execFileSync: (...args: unknown[]) => execFileSyncMock(...args), - }; -}); - -let augmentProcessPathWithShellAndKnownCliDirs: typeof import("./cliExecutableResolver").augmentProcessPathWithShellAndKnownCliDirs; -const originalPlatform = process.platform; - -function setPlatform(value: NodeJS.Platform): void { - Object.defineProperty(process, "platform", { - value, - configurable: true, - }); -} - -describe("augmentProcessPathWithShellAndKnownCliDirs", () => { - beforeEach(async () => { - vi.resetModules(); - execFileSyncMock.mockReset(); - setPlatform("darwin"); - ({ augmentProcessPathWithShellAndKnownCliDirs } = await import("./cliExecutableResolver")); - }); - - afterEach(() => { - setPlatform(originalPlatform); - }); - - it("merges login and interactive shell PATH entries on macOS", () => { - execFileSyncMock.mockImplementation((_shellPath: string, args: string[]) => { - if (args[0] === "-lc") { - return "noise __ADE_PATH_START__/usr/bin:/bin:/opt/custom/login/bin__ADE_PATH_END__"; - } - if (args[0] === "-ic") { - return "__ADE_PATH_START__/usr/bin:/bin:/Users/test/.interactive/bin__ADE_PATH_END__"; - } - return ""; - }); - - const env: NodeJS.ProcessEnv = { - HOME: "/Users/test", - SHELL: "/bin/zsh", - PATH: "/usr/bin:/bin", - }; - - const nextPath = augmentProcessPathWithShellAndKnownCliDirs({ - env, - includeInteractiveShell: true, - timeoutMs: 250, - }); - - const entries = nextPath.split(path.delimiter); - expect(entries).toContain("/opt/custom/login/bin"); - expect(entries).toContain("/Users/test/.interactive/bin"); - expect(entries).toContain("/Users/test/.npm-global/bin"); - expect(env.PATH).toBe("/usr/bin:/bin"); - expect(nextPath).not.toBe(env.PATH); - expect(execFileSyncMock).toHaveBeenNthCalledWith( - 1, - "/bin/zsh", - expect.any(Array), - expect.objectContaining({ env }), - ); - expect(execFileSyncMock).toHaveBeenNthCalledWith( - 2, - "/bin/zsh", - expect.any(Array), - expect.objectContaining({ env }), - ); - }); -}); diff --git a/apps/desktop/src/main/services/automations/automationHelpers.test.ts b/apps/desktop/src/main/services/automations/automationHelpers.test.ts deleted file mode 100644 index c08baf6f3..000000000 --- a/apps/desktop/src/main/services/automations/automationHelpers.test.ts +++ /dev/null @@ -1,285 +0,0 @@ -import { describe, expect, it } from "vitest"; -import type { AutomationRule, AutomationTrigger } from "../../../shared/types/config"; -import type { TriggerContext } from "./automationService"; -import { - normalizeRuntimeRule, - normalizeTriggerType, - readTriggerPath, - resolvePlaceholders, - triggerMatches, -} from "./automationService"; - -const baseRule: AutomationRule = { - id: "rule-1", - name: "Rule 1", - mode: "review", - triggers: [{ type: "manual" }], - trigger: { type: "manual" }, - executor: { mode: "automation-bot" }, - reviewProfile: "quick", - toolPalette: ["repo", "memory", "mission"], - contextSources: [], - memory: { mode: "none" }, - guardrails: {}, - outputs: { disposition: "comment-only", createArtifact: true }, - verification: { verifyBeforePublish: false, mode: "intervention" }, - billingCode: "auto:rule-1", - actions: [], - enabled: true, -}; - -describe("normalizeTriggerType", () => { - it("aliases legacy git.pr_* to canonical github.pr_*", () => { - expect(normalizeTriggerType("git.pr_opened")).toBe("github.pr_opened"); - expect(normalizeTriggerType("git.pr_updated")).toBe("github.pr_updated"); - expect(normalizeTriggerType("git.pr_merged")).toBe("github.pr_merged"); - expect(normalizeTriggerType("git.pr_closed")).toBe("github.pr_closed"); - }); - - it("maps bare `commit` to git.commit", () => { - expect(normalizeTriggerType("commit" as never)).toBe("git.commit"); - }); - - it("leaves already-canonical triggers untouched", () => { - expect(normalizeTriggerType("github.issue_opened")).toBe("github.issue_opened"); - expect(normalizeTriggerType("github.pr_opened")).toBe("github.pr_opened"); - expect(normalizeTriggerType("schedule")).toBe("schedule"); - expect(normalizeTriggerType("linear.issue_created")).toBe("linear.issue_created"); - }); -}); - -describe("normalizeRuntimeRule", () => { - it("strips per-rule budget fields from guardrails", () => { - const rule = { - ...baseRule, - guardrails: { - ...baseRule.guardrails, - budgetCapUsd: 25, - maxSpendUsd: 40, - budgetUsd: 50, - } as AutomationRule["guardrails"] & { - budgetCapUsd?: number; - maxSpendUsd?: number; - budgetUsd?: number; - }, - }; - - const normalized = normalizeRuntimeRule(rule); - - expect(normalized.guardrails).not.toHaveProperty("budgetCapUsd"); - expect(normalized.guardrails).not.toHaveProperty("maxSpendUsd"); - expect(normalized.guardrails).not.toHaveProperty("budgetUsd"); - }); - - it("canonicalizes legacy git.pr_* triggers to github.pr_*", () => { - const rule = { - ...baseRule, - triggers: [{ type: "git.pr_opened" as const, branch: "main" }], - trigger: { type: "git.pr_opened" as const, branch: "main" }, - }; - - const normalized = normalizeRuntimeRule(rule); - - expect(normalized.triggers[0]?.type).toBe("github.pr_opened"); - expect(normalized.trigger.type).toBe("github.pr_opened"); - }); - - it("preserves persisted verification gates for runtime enforcement", () => { - const rule = { - ...baseRule, - verification: { verifyBeforePublish: true, mode: "dry-run" as const }, - }; - - const normalized = normalizeRuntimeRule(rule); - - expect(normalized.verification).toEqual({ - verifyBeforePublish: true, - mode: "dry-run", - }); - }); - - it("derives includeProjectContext from legacy memory/contextSources", () => { - const none = normalizeRuntimeRule({ - ...baseRule, - memory: { mode: "none" }, - contextSources: [], - }); - expect(none.includeProjectContext).toBe(false); - - const hasMemory = normalizeRuntimeRule({ - ...baseRule, - memory: { mode: "automation-plus-project", ruleScopeKey: "rule-1" }, - contextSources: [], - }); - expect(hasMemory.includeProjectContext).toBe(true); - - const hasContext = normalizeRuntimeRule({ - ...baseRule, - memory: { mode: "none" }, - contextSources: [{ type: "project-memory" }], - }); - expect(hasContext.includeProjectContext).toBe(true); - - const explicitFalse = normalizeRuntimeRule({ - ...baseRule, - includeProjectContext: false, - memory: { mode: "automation-plus-project", ruleScopeKey: "rule-1" }, - contextSources: [{ type: "project-memory" }], - }); - expect(explicitFalse.includeProjectContext).toBe(false); - }); -}); - -describe("readTriggerPath + resolvePlaceholders", () => { - const ctx: TriggerContext = { - triggerType: "github.issue_opened", - issue: { - number: 42, - title: "Payment flow broken", - body: "Repro steps inside.", - author: "arul28", - labels: ["bug", "triage"], - repo: "arul28/ADE", - }, - } as TriggerContext; - - it("reads nested paths with or without the `trigger.` prefix", () => { - expect(readTriggerPath(ctx, "trigger.issue.number")).toBe(42); - expect(readTriggerPath(ctx, "issue.number")).toBe(42); - expect(readTriggerPath(ctx, "trigger.issue.author")).toBe("arul28"); - }); - - it("returns undefined when a segment is missing", () => { - expect(readTriggerPath(ctx, "trigger.pr.number")).toBeUndefined(); - expect(readTriggerPath(ctx, "trigger.issue.does_not_exist")).toBeUndefined(); - expect(readTriggerPath(ctx, "")).toBeUndefined(); - }); - - it("preserves raw type when a string is wholly a single placeholder", () => { - expect(resolvePlaceholders("{{trigger.issue.number}}", ctx)).toBe(42); - expect(resolvePlaceholders("{{trigger.issue.labels}}", ctx)).toEqual(["bug", "triage"]); - }); - - it("templates embedded placeholders and stringifies non-string values", () => { - expect(resolvePlaceholders("Issue #{{trigger.issue.number}}", ctx)).toBe("Issue #42"); - expect(resolvePlaceholders("{{trigger.issue.author}} opened this", ctx)).toBe( - "arul28 opened this", - ); - }); - - it("replaces missing embedded placeholders with the empty string", () => { - expect(resolvePlaceholders("fallback:{{trigger.pr.number}}", ctx)).toBe("fallback:"); - }); - - it("leaves a whole-string placeholder untouched when the path is missing", () => { - expect(resolvePlaceholders("{{trigger.pr.number}}", ctx)).toBe("{{trigger.pr.number}}"); - }); - - it("walks nested objects and arrays", () => { - const tree = { - labels: ["{{trigger.issue.labels}}"], - meta: { - body: "{{trigger.issue.title}}", - author: "{{trigger.issue.author}}", - }, - issueNumber: "{{trigger.issue.number}}", - }; - - const resolved = resolvePlaceholders(tree, ctx); - - expect(resolved).toEqual({ - labels: [["bug", "triage"]], - meta: { - body: "Payment flow broken", - author: "arul28", - }, - issueNumber: 42, - }); - }); - - it("passes non-string primitives through untouched", () => { - expect(resolvePlaceholders(42, ctx)).toBe(42); - expect(resolvePlaceholders(true, ctx)).toBe(true); - expect(resolvePlaceholders(null, ctx)).toBeNull(); - }); -}); - -describe("triggerMatches", () => { - const issueCtx: TriggerContext = { - triggerType: "github.issue_opened", - issue: { - number: 7, - title: "Payment webhook sometimes 500s", - body: "Happens on retry only. Stack trace attached.", - author: "arul28", - labels: ["bug", "payments", "triage"], - repo: "arul28/ADE", - }, - } as TriggerContext; - - const rule = (partial: Partial): AutomationTrigger => ({ - type: "github.issue_opened", - ...partial, - }); - - it("treats labels as a subset check (rule ⊆ event)", () => { - expect(triggerMatches(rule({ labels: ["bug"] }), issueCtx, undefined, undefined)).toBe(true); - expect(triggerMatches(rule({ labels: ["bug", "payments"] }), issueCtx, undefined, undefined)).toBe(true); - expect(triggerMatches(rule({ labels: ["wontfix"] }), issueCtx, undefined, undefined)).toBe(false); - expect(triggerMatches(rule({ labels: ["bug", "wontfix"] }), issueCtx, undefined, undefined)).toBe(false); - }); - - it("ignores label case when matching", () => { - expect(triggerMatches(rule({ labels: ["BUG"] }), issueCtx, undefined, undefined)).toBe(true); - expect(triggerMatches(rule({ labels: ["Payments"] }), issueCtx, undefined, undefined)).toBe(true); - }); - - it("an empty labels filter matches everything", () => { - expect(triggerMatches(rule({ labels: [] }), issueCtx, undefined, undefined)).toBe(true); - expect(triggerMatches(rule({}), issueCtx, undefined, undefined)).toBe(true); - }); - - it("titleRegex matches case-insensitively against issue.title", () => { - expect(triggerMatches(rule({ titleRegex: "webhook" }), issueCtx, undefined, undefined)).toBe(true); - expect(triggerMatches(rule({ titleRegex: "^Payment" }), issueCtx, undefined, undefined)).toBe(true); - expect(triggerMatches(rule({ titleRegex: "deploy failure" }), issueCtx, undefined, undefined)).toBe(false); - }); - - it("bodyRegex matches case-insensitively against issue.body", () => { - expect(triggerMatches(rule({ bodyRegex: "stack trace" }), issueCtx, undefined, undefined)).toBe(true); - expect(triggerMatches(rule({ bodyRegex: "not-in-body" }), issueCtx, undefined, undefined)).toBe(false); - }); - - it("drops the match silently on invalid regex rather than throwing", () => { - expect(triggerMatches(rule({ titleRegex: "[" }), issueCtx, undefined, undefined)).toBe(false); - }); - - it("prefers issue.author over the generic trigger.author for authors[] matching", () => { - expect(triggerMatches(rule({ authors: ["arul28"] }), issueCtx, undefined, undefined)).toBe(true); - expect(triggerMatches(rule({ authors: ["ARUL28"] }), issueCtx, undefined, undefined)).toBe(true); - expect(triggerMatches(rule({ authors: ["other-user"] }), issueCtx, undefined, undefined)).toBe(false); - }); - - it("combines filters — all must pass", () => { - expect( - triggerMatches( - rule({ labels: ["bug"], titleRegex: "payment", authors: ["arul28"] }), - issueCtx, - undefined, - undefined, - ), - ).toBe(true); - expect( - triggerMatches( - rule({ labels: ["bug"], titleRegex: "deploy" }), - issueCtx, - undefined, - undefined, - ), - ).toBe(false); - }); - - it("rejects a mismatched trigger type outright", () => { - expect(triggerMatches(rule({ type: "github.pr_opened" }), issueCtx, undefined, undefined)).toBe(false); - }); -}); diff --git a/apps/desktop/src/main/services/automations/automationService.test.ts b/apps/desktop/src/main/services/automations/automationService.test.ts index ee2c967fc..0a3a5d5d8 100644 --- a/apps/desktop/src/main/services/automations/automationService.test.ts +++ b/apps/desktop/src/main/services/automations/automationService.test.ts @@ -5,7 +5,7 @@ import path from "node:path"; import { createRequire } from "node:module"; import initSqlJs from "sql.js"; import type { Database, SqlJsStatic } from "sql.js"; -import { createAutomationService, triggerMatches } from "./automationService"; +import { createAutomationService, presetToTemplate, triggerMatches } from "./automationService"; type SqlValue = string | number | null | Uint8Array; @@ -1286,4 +1286,290 @@ describe("automationService integration", () => { } }); + describe("laneMode: 'create'", () => { + it("presetToTemplate maps known presets and returns empty for custom/unknown", () => { + expect(presetToTemplate("issue-title")).toBe("{{trigger.issue.title}}"); + expect(presetToTemplate("issue-num-title")).toBe("Issue #{{trigger.issue.number}} – {{trigger.issue.title}}"); + expect(presetToTemplate("pr-title-author")).toBe("{{trigger.pr.title}} – {{trigger.pr.author}}"); + expect(presetToTemplate("custom")).toBe(""); + expect(presetToTemplate(undefined)).toBe(""); + }); + + function buildLaneModeFixtures() { + const { db, raw } = createInMemoryAdeDb(); + const logger = createLogger(); + const projectId = "proj"; + const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-automation-lane-mode-")); + return { db, raw, logger, projectId, projectRoot }; + } + + it("creates a fresh lane via preset when laneMode is 'create' and emits a lane-setup row", async () => { + const { db, raw, logger, projectId, projectRoot } = buildLaneModeFixtures(); + const createLane = vi.fn(async ({ name }: { name: string }) => ({ + id: "lane-fresh", + name, + branchRef: name.replace(/\s+/g, "-").toLowerCase(), + laneType: "feature", + worktreePath: projectRoot, + })); + const createMission = vi.fn(() => ({ id: "mission-x", status: "in_progress", outcomeSummary: null, completedAt: null, lastError: null })); + + const rule = { + id: "issue-create-lane", + name: "Issue create lane", + enabled: true, + mode: "review", + reviewProfile: "quick", + trigger: { type: "manual" as const }, + triggers: [{ type: "manual" as const }], + executor: { mode: "automation-bot", targetId: null }, + toolPalette: [] as const, + contextSources: [], + memory: { mode: "project" as const }, + guardrails: { maxDurationMin: 5 }, + outputs: { disposition: "comment-only" as const, createArtifact: true }, + verification: { verifyBeforePublish: false, mode: "intervention" as const }, + billingCode: "auto:test", + execution: { + kind: "mission" as const, + laneMode: "create" as const, + laneNamePreset: "issue-title" as const, + }, + prompt: "Run the mission.", + }; + + const projectConfigService = { + get: () => ({ trust: { requiresSharedTrust: false }, effective: { automations: [rule], providerMode: "guest" } }) + } as any; + const laneService = { + create: createLane, + list: async () => [{ id: "lane-primary", name: "primary", laneType: "primary" }], + getLaneWorktreePath: () => projectRoot, + getLaneBaseAndBranch: () => ({ baseRef: "main", branchRef: "main", worktreePath: projectRoot }) + } as any; + + const service = createAutomationService({ + db: db as any, logger, projectId, projectRoot, laneService, projectConfigService, + missionService: { create: createMission, patchMetadata: vi.fn() } as any, + aiOrchestratorService: { startMissionRun: vi.fn(async () => undefined) } as any, + }); + + try { + // Inject an issue payload by stuffing trigger context via dispatchIngressTrigger. + // Manual trigger would have no issue payload — use a manual call but seed the + // trigger via service.triggerManually then inspect the create call. + // Instead, directly hit the underlying path by manipulating triggers: use + // triggerManually here and the createLaneForRun fallback (rule.name) will fire. + const run = await service.triggerManually({ id: "issue-create-lane" }); + expect(run.status).toBe("running"); + expect(createLane).toHaveBeenCalledTimes(1); + const args = (createLane as any).mock.calls[0]?.[0] as { name: string }; + // No issue payload on manual triggers — falls back to rule.name. + expect(args.name).toBe("Issue create lane"); + expect(createMission).toHaveBeenCalledWith(expect.objectContaining({ laneId: "lane-fresh" })); + + const setupRows = mapExecRows(raw.exec("select status, action_type from automation_action_results where action_type = 'lane-setup'")); + expect(setupRows.length).toBe(1); + expect(setupRows[0]?.status).toBe("succeeded"); + } finally { + fs.rmSync(projectRoot, { recursive: true, force: true }); + } + }); + + it("appends issue number on collision then a random suffix on a second collision", async () => { + const { db, logger, projectId, projectRoot } = buildLaneModeFixtures(); + const createLane = vi.fn(async ({ name }: { name: string }) => ({ + id: `lane-${name}`, + name, + branchRef: name.replace(/\s+/g, "-").toLowerCase(), + laneType: "feature", + worktreePath: projectRoot, + })); + const createMission = vi.fn(() => ({ id: "mission-x", status: "in_progress", outcomeSummary: null, completedAt: null, lastError: null })); + + // Two existing lanes already collide with "Fix login" AND "Fix login (#427)". + const rule = { + id: "issue-collide", + name: "Issue collide", + enabled: true, + mode: "review", + reviewProfile: "quick", + trigger: { type: "github.issue_opened" as const }, + triggers: [{ type: "github.issue_opened" as const }], + executor: { mode: "automation-bot", targetId: null }, + toolPalette: [] as const, + contextSources: [], + memory: { mode: "project" as const }, + guardrails: { maxDurationMin: 5 }, + outputs: { disposition: "comment-only" as const, createArtifact: true }, + verification: { verifyBeforePublish: false, mode: "intervention" as const }, + billingCode: "auto:test", + execution: { kind: "mission" as const, laneMode: "create" as const, laneNamePreset: "issue-title" as const }, + prompt: "Run.", + }; + + const projectConfigService = { + get: () => ({ trust: { requiresSharedTrust: false }, effective: { automations: [rule], providerMode: "guest" } }) + } as any; + + const laneService = { + create: createLane, + list: async () => [ + { id: "lane-primary", name: "primary", laneType: "primary" }, + { id: "lane-existing", name: "Fix login", laneType: "feature" }, + { id: "lane-existing-2", name: "Fix login (#427)", laneType: "feature" }, + ], + getLaneWorktreePath: () => projectRoot, + getLaneBaseAndBranch: () => ({ baseRef: "main", branchRef: "main", worktreePath: projectRoot }) + } as any; + + const service = createAutomationService({ + db: db as any, logger, projectId, projectRoot, laneService, projectConfigService, + missionService: { create: createMission, patchMetadata: vi.fn() } as any, + aiOrchestratorService: { startMissionRun: vi.fn(async () => undefined) } as any, + }); + + try { + await service.dispatchIngressTrigger({ + source: "github-polling", + eventKey: "x:1", + triggerType: "github.issue_opened", + eventName: "github.issue_opened", + repo: "x/y", + issue: { number: 427, title: "Fix login", author: "a", labels: [], repo: "x/y", url: "https://x" } + } as any); + const args = (createLane as any).mock.calls[0]?.[0] as { name: string }; + // Both "Fix login" and "Fix login (#427)" already exist → falls through to random suffix. + expect(args.name).toMatch(/^Fix login \([0-9a-f]{4}\)$/); + } finally { + fs.rmSync(projectRoot, { recursive: true, force: true }); + } + }); + + it("marks the run failed (no fallback to primary) when createLaneForRun throws", async () => { + const { db, raw, logger, projectId, projectRoot } = buildLaneModeFixtures(); + const createLane = vi.fn(async () => { throw new Error("Disk full"); }); + const createMission = vi.fn(); + + const rule = { + id: "issue-fail", + name: "Issue fail", + enabled: true, + mode: "review", + reviewProfile: "quick", + trigger: { type: "manual" as const }, + triggers: [{ type: "manual" as const }], + executor: { mode: "automation-bot", targetId: null }, + toolPalette: [] as const, + contextSources: [], + memory: { mode: "project" as const }, + guardrails: { maxDurationMin: 5 }, + outputs: { disposition: "comment-only" as const, createArtifact: true }, + verification: { verifyBeforePublish: false, mode: "intervention" as const }, + billingCode: "auto:test", + execution: { kind: "mission" as const, laneMode: "create" as const, laneNamePreset: "issue-title" as const }, + prompt: "Run.", + }; + + const projectConfigService = { + get: () => ({ trust: { requiresSharedTrust: false }, effective: { automations: [rule], providerMode: "guest" } }) + } as any; + const laneService = { + create: createLane, + list: async () => [{ id: "lane-primary", name: "primary", laneType: "primary" }], + getLaneWorktreePath: () => projectRoot, + getLaneBaseAndBranch: () => ({ baseRef: "main", branchRef: "main", worktreePath: projectRoot }) + } as any; + + const service = createAutomationService({ + db: db as any, logger, projectId, projectRoot, laneService, projectConfigService, + missionService: { create: createMission, patchMetadata: vi.fn() } as any, + aiOrchestratorService: { startMissionRun: vi.fn(async () => undefined) } as any, + }); + + try { + await expect(service.triggerManually({ id: "issue-fail" })).rejects.toThrow("Disk full"); + expect(createMission).not.toHaveBeenCalled(); + const runs = mapExecRows(raw.exec("select status, error_message from automation_runs where automation_id = 'issue-fail'")); + expect(runs.length).toBe(1); + expect(runs[0]?.status).toBe("failed"); + expect(String(runs[0]?.error_message ?? "")).toContain("Disk full"); + const setupRows = mapExecRows(raw.exec("select status from automation_action_results where action_type = 'lane-setup'")); + expect(setupRows[0]?.status).toBe("failed"); + } finally { + fs.rmSync(projectRoot, { recursive: true, force: true }); + } + }); + }); + + describe("legacy create-lane migration", () => { + it("collapses a leading create-lane action into laneMode: 'create' on load", async () => { + // Drive the migration through projectConfigService — but the service in tests + // gets a stub config service. Instead, exercise the same coercion logic by + // building a rule whose execution lacks laneMode and whose first action is + // create-lane, then verify the runtime behavior matches "create" mode. + const { db, logger, projectId, projectRoot } = (() => { + const { db } = createInMemoryAdeDb(); + return { db, logger: createLogger(), projectId: "proj", projectRoot: fs.mkdtempSync(path.join(os.tmpdir(), "ade-automation-migrate-")) }; + })(); + // Simulate what projectConfigService.coerce would have produced: + const migratedExecution = { + kind: "built-in" as const, + laneMode: "create" as const, + laneNamePreset: "custom" as const, + laneNameTemplate: "Auto: {{trigger.issue.title}}", + builtIn: { actions: [{ type: "create-lane" as const, laneNameTemplate: "Auto: {{trigger.issue.title}}" }] }, + }; + const createLane = vi.fn(async ({ name }: { name: string }) => ({ + id: "lane-migrated", + name, + branchRef: name.replace(/\s+/g, "-").toLowerCase(), + laneType: "feature", + worktreePath: projectRoot, + })); + const createMission = vi.fn(() => ({ id: "m", status: "in_progress", outcomeSummary: null, completedAt: null, lastError: null })); + const rule = { + id: "migrated", + name: "Migrated", + enabled: true, mode: "review", reviewProfile: "quick", + trigger: { type: "manual" as const }, triggers: [{ type: "manual" as const }], + executor: { mode: "automation-bot", targetId: null }, + toolPalette: [], contextSources: [], memory: { mode: "project" }, + guardrails: { maxDurationMin: 5 }, + outputs: { disposition: "comment-only", createArtifact: true }, + verification: { verifyBeforePublish: false, mode: "intervention" }, + billingCode: "auto:test", + // Migrated rule still keeps the legacy action so unmigrated runners can read it, + // but execution.laneMode === "create" steers the new path. + execution: { ...migratedExecution, kind: "mission" as const }, + prompt: "Run.", + }; + const projectConfigService = { + get: () => ({ trust: { requiresSharedTrust: false }, effective: { automations: [rule], providerMode: "guest" } }) + } as any; + const laneService = { + create: createLane, + list: async () => [{ id: "lane-primary", name: "primary", laneType: "primary" }], + getLaneWorktreePath: () => projectRoot, + getLaneBaseAndBranch: () => ({ baseRef: "main", branchRef: "main", worktreePath: projectRoot }) + } as any; + const service = createAutomationService({ + db: db as any, logger, projectId, projectRoot, laneService, projectConfigService, + missionService: { create: createMission, patchMetadata: vi.fn() } as any, + aiOrchestratorService: { startMissionRun: vi.fn(async () => undefined) } as any, + }); + try { + await service.triggerManually({ id: "migrated" }); + expect(createLane).toHaveBeenCalledTimes(1); + // Manual trigger has no issue.title → embedded placeholder resolves to + // empty, leaving the literal prefix "Auto:" — verify the migrated path + // produced *some* lane and the leading template was honored. + const args = (createLane as any).mock.calls[0]?.[0] as { name: string }; + expect(args.name).toMatch(/^Auto:/); + } finally { + fs.rmSync(projectRoot, { recursive: true, force: true }); + } + }); + }); + }); diff --git a/apps/desktop/src/main/services/automations/automationService.ts b/apps/desktop/src/main/services/automations/automationService.ts index 944ec405a..7cbe03d33 100644 --- a/apps/desktop/src/main/services/automations/automationService.ts +++ b/apps/desktop/src/main/services/automations/automationService.ts @@ -629,9 +629,17 @@ export function normalizeRuntimeRule(rule: AutomationRule): AutomationRule { const rawExecution = rule.execution ?? (legacyActions.length > 0 ? { kind: "built-in" as const, builtIn: { actions: legacyActions } } : { kind: "mission" as const }); + const sharedLaneFields = { + ...(rawExecution.laneMode ? { laneMode: rawExecution.laneMode } : {}), + ...(rawExecution.laneNamePreset ? { laneNamePreset: rawExecution.laneNamePreset } : {}), + ...(rawExecution.laneNamePreset === "custom" && rawExecution.laneNameTemplate + ? { laneNameTemplate: rawExecution.laneNameTemplate } + : {}), + }; const normalizedExecution: AutomationExecution = rawExecution.kind === "built-in" ? { kind: "built-in", + ...sharedLaneFields, ...(rawExecution.targetLaneId ? { targetLaneId: rawExecution.targetLaneId } : {}), builtIn: { actions: rawExecution.builtIn?.actions?.length ? rawExecution.builtIn.actions : legacyActions, @@ -640,11 +648,13 @@ export function normalizeRuntimeRule(rule: AutomationRule): AutomationRule { : rawExecution.kind === "agent-session" ? { kind: "agent-session", + ...sharedLaneFields, ...(rawExecution.targetLaneId ? { targetLaneId: rawExecution.targetLaneId } : {}), ...(rawExecution.session ? { session: rawExecution.session } : {}), } : { kind: "mission", + ...sharedLaneFields, ...(rawExecution.targetLaneId ? { targetLaneId: rawExecution.targetLaneId } : {}), ...(rawExecution.mission ? { mission: rawExecution.mission } : {}), }; @@ -703,6 +713,23 @@ function summarizeLegacyActions(actions: AutomationAction[]): string { return actions.map((action) => action.type).join(", "); } +/** + * Map a lane-name preset to a `{{trigger.*}}` template string. `"custom"` + * is a sentinel — callers should pass the user's `laneNameTemplate` instead. + */ +export function presetToTemplate(preset: string | undefined | null): string { + switch (preset) { + case "issue-title": + return "{{trigger.issue.title}}"; + case "issue-num-title": + return "Issue #{{trigger.issue.number}} – {{trigger.issue.title}}"; + case "pr-title-author": + return "{{trigger.pr.title}} – {{trigger.pr.author}}"; + default: + return ""; + } +} + function resolveTemplateString(template: string | undefined | null, trigger: TriggerContext): string { const resolved = resolvePlaceholders(template ?? "", trigger); if (typeof resolved === "string") return resolved.trim(); @@ -1794,7 +1821,7 @@ export function createAutomationService({ if (!agentChatServiceRef) { return { status: "failed", output: "Agent chat service is unavailable." }; } - const laneId = await resolveExecutionLaneId(rule, trigger, action); + const laneId = await resolveExecutionLaneId(rule, trigger, action, runId); if (!laneId) { return { status: "failed", output: "No lane is available for this automation run." }; } @@ -2023,8 +2050,98 @@ export function createAutomationService({ } }; - const resolveExecutionLaneId = async (rule: AutomationRule, trigger: TriggerContext, action?: AutomationAction | null): Promise => { - const configuredLaneId = trimToNull(action?.targetLaneId) ?? trimToNull(rule.execution?.targetLaneId); + /** + * Spawn a fresh lane for a single automation run. Resolves the user's + * preset/template via {@link resolvePlaceholders}; if a sibling lane already + * carries the same name, appends `#NN` (or short timestamp for non-issue + * triggers); if that *still* collides, appends a 4-char random suffix. + * Returns the new lane id. Throws on lane-service failure (caller marks + * the run failed; no fallback to primary). + */ + const createLaneForRun = async (rule: AutomationRule, trigger: TriggerContext): Promise<{ laneId: string; laneName: string }> => { + const preset = rule.execution?.laneNamePreset; + const template = preset && preset !== "custom" + ? presetToTemplate(preset) + : (rule.execution?.laneNameTemplate ?? ""); + const rendered = resolveTemplateString(template, trigger); + const fallbackName = trigger.issue?.title ?? trigger.pr?.title ?? trigger.summary ?? rule.name; + const baseName = (rendered && !/\{\{[^}]+\}\}/.test(rendered) ? rendered : "").trim() || fallbackName.trim(); + if (!baseName) { + throw new Error("Lane name template resolved to an empty string."); + } + + const existingLanes = await laneService.list({ includeArchived: false }); + const existingNames = new Set(existingLanes.map((lane: { name?: string | null }) => (lane.name ?? "").trim().toLowerCase())); + + let candidate = baseName; + if (existingNames.has(candidate.toLowerCase())) { + const issueOrPrNumber = trigger.issue?.number ?? trigger.pr?.number; + const suffix = typeof issueOrPrNumber === "number" + ? `#${issueOrPrNumber}` + : new Date().toISOString().replace(/[-:T.Z]/g, "").slice(0, 12); + candidate = `${baseName} (${suffix})`; + } + if (existingNames.has(candidate.toLowerCase())) { + const random = randomUUID().replace(/-/g, "").slice(0, 4); + candidate = `${baseName} (${random})`; + } + + const description = [ + trigger.issue ? `GitHub issue #${trigger.issue.number}` : null, + trigger.issue?.url ?? trigger.pr?.url ?? null, + trigger.summary ?? null, + ].filter(Boolean).join("\n"); + + const lane = await laneService.create({ + name: candidate, + description, + }); + trigger.laneId = lane.id; + trigger.laneName = lane.name; + trigger.branch = lane.branchRef; + return { laneId: lane.id, laneName: lane.name }; + }; + + /** + * Resolve which lane an automation should run in. When the rule opts into + * `execution.laneMode === "create"`, allocate a fresh lane via + * {@link createLaneForRun} and (if a runId is provided) record a synthetic + * `lane-setup` row in `automation_action_results` so success / failure has + * a visible line in the run-detail UI. On failure of the create path the + * caller MUST mark the run failed — we deliberately do not fall back to + * the primary lane (work would silently land in the wrong place). + */ + const resolveExecutionLaneId = async ( + rule: AutomationRule, + trigger: TriggerContext, + action?: AutomationAction | null, + runId?: string | null, + ): Promise => { + const actionLaneId = trimToNull(action?.targetLaneId); + if (actionLaneId) return actionLaneId; + + if (rule.execution?.laneMode === "create") { + const setupActionId = runId ? insertAction(runId, -1, "lane-setup") : null; + try { + const { laneId, laneName } = await createLaneForRun(rule, trigger); + if (setupActionId) { + finishAction({ + id: setupActionId, + status: "succeeded", + output: JSON.stringify({ laneId, laneName }), + }); + } + return laneId; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + if (setupActionId) { + finishAction({ id: setupActionId, status: "failed", errorMessage: message }); + } + throw error; + } + } + + const configuredLaneId = trimToNull(rule.execution?.targetLaneId); if (configuredLaneId) return configuredLaneId; const triggerLaneId = trimToNull(trigger.laneId); @@ -2067,11 +2184,6 @@ export function createAutomationService({ throw new Error("Agent chat service is unavailable"); } - const laneId = await resolveExecutionLaneId(args.rule, args.trigger); - if (!laneId) { - throw new Error("No lane is available for this automation run."); - } - const { modelId, modelDescriptor, providerGroup, budgetProvider } = resolveAutomationModelDescriptor(args.rule); const resolvedChat = resolveChatProviderForDescriptor(modelDescriptor); const budgetCheck = budgetCapServiceRef?.checkBudget( @@ -2086,7 +2198,6 @@ export function createAutomationService({ const briefing = await buildBriefing(args.rule, args.trigger); const linkedProcedureIds = briefing?.usedProcedureIds ?? []; const confidence = computeConfidence(args.rule, linkedProcedureIds.length); - const prompt = buildMissionPrompt({ rule: args.rule, trigger: args.trigger, executionLaneId: laneId, briefing }); const existingRunRow = args.existingRunId ? loadRunRow(args.existingRunId) : null; const run = existingRunRow ? toRun(existingRunRow) @@ -2100,6 +2211,23 @@ export function createAutomationService({ ingressEventId: args.trigger.ingressEventId ?? null, }); + let laneId: string | null; + try { + laneId = await resolveExecutionLaneId(args.rule, args.trigger, null, run.id); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + updateRun(run.id, { ended_at: nowIso(), status: "failed", error_message: message }); + emit({ type: "runs-updated", automationId: args.rule.id, runId: run.id }); + throw error; + } + if (!laneId) { + const message = "No lane is available for this automation run."; + updateRun(run.id, { ended_at: nowIso(), status: "failed", error_message: message }); + emit({ type: "runs-updated", automationId: args.rule.id, runId: run.id }); + throw new Error(message); + } + const prompt = buildMissionPrompt({ rule: args.rule, trigger: args.trigger, executionLaneId: laneId, briefing }); + const actionId = insertAction(run.id, 0, "agent-session"); const permissionConfig = buildPermissionConfig(args.rule, { publishPhase: false }); const verificationRequired = requiresPublishGate(args.rule); @@ -2252,7 +2380,30 @@ export function createAutomationService({ throw new Error(budgetCheck.reason ?? "Budget cap blocked automation run."); } - const laneId = await resolveExecutionLaneId(args.rule, args.trigger); + const existingRunRowEarly = args.existingRunId ? loadRunRow(args.existingRunId) : null; + const earlyRun = existingRunRowEarly + ? toRun(existingRunRowEarly) + : insertRun({ + rule: args.rule, + trigger: args.trigger, + actionsTotal: 1, + queueStatus: "pending-review", + confidence, + linkedProcedureIds, + summary: args.rule.prompt?.trim() || `${args.rule.mode} automation dispatched`, + queueItemId: args.existingQueueItemId ?? null, + ingressEventId: args.trigger.ingressEventId ?? null, + }); + + let laneId: string | null; + try { + laneId = await resolveExecutionLaneId(args.rule, args.trigger, null, earlyRun.id); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + updateRun(earlyRun.id, { ended_at: nowIso(), status: "failed", error_message: message }); + emit({ type: "runs-updated", automationId: args.rule.id, runId: earlyRun.id }); + throw error; + } const prompt = buildMissionPrompt({ rule: args.rule, trigger: args.trigger, executionLaneId: laneId, briefing }); const mission = missionServiceRef.create({ title: `${args.rule.name} · ${args.rule.mode}`, @@ -2296,21 +2447,8 @@ export function createAutomationService({ } }); - const existingRunRow = args.existingRunId ? loadRunRow(args.existingRunId) : null; - const run = existingRunRow - ? toRun(existingRunRow) - : insertRun({ - rule: args.rule, - trigger: args.trigger, - actionsTotal: 1, - queueStatus: "pending-review", - confidence, - linkedProcedureIds, - summary: args.rule.prompt?.trim() || `${args.rule.mode} automation dispatched`, - missionId: mission.id, - queueItemId: args.existingQueueItemId ?? null, - ingressEventId: args.trigger.ingressEventId ?? null, - }); + const run = earlyRun; + updateRun(run.id, { mission_id: mission.id }); if (args.existingRunId) { updateRun(args.existingRunId, { diff --git a/apps/desktop/src/main/services/cli/windowsPackaging.test.ts b/apps/desktop/src/main/services/cli/windowsPackaging.test.ts deleted file mode 100644 index 3f23cf418..000000000 --- a/apps/desktop/src/main/services/cli/windowsPackaging.test.ts +++ /dev/null @@ -1,74 +0,0 @@ -import fs from "node:fs"; -import path from "node:path"; -import { spawnSync } from "node:child_process"; -import { parse as parseYaml } from "yaml"; -import { describe, expect, it } from "vitest"; - -const desktopRoot = path.resolve(__dirname, "../../../../"); -const repoRoot = path.resolve(desktopRoot, "..", ".."); - -describe("Windows packaging", () => { - it("keeps the packaged Windows wrapper on a shared runtime-env path", () => { - const wrapperPath = path.join(desktopRoot, "scripts", "ade-cli-windows-wrapper.cmd"); - const wrapper = fs.readFileSync(wrapperPath, "utf8"); - - expect(wrapper).toContain('set "NODE_PATH_VALUE=%RESOURCES_DIR%\\app.asar.unpacked\\node_modules;%RESOURCES_DIR%\\app.asar\\node_modules"'); - expect(wrapper).toContain('call :run_with_runtime_env "%ADE_CLI_NODE%" "%CLI_JS%" %*'); - expect(wrapper).toContain('call :run_with_runtime_env "%APP_EXE%" "%CLI_JS%" %*'); - expect(wrapper).toContain('call :run_with_runtime_env node "%CLI_JS%" %*'); - expect(wrapper).toContain('if defined NODE_PATH_VALUE set "NODE_PATH=%NODE_PATH_VALUE%"'); - }); - - it("keeps the Windows install-path shim callable and exit-code preserving", () => { - const installerPath = path.join(desktopRoot, "scripts", "ade-cli-install-path.cmd"); - const installer = fs.readFileSync(installerPath, "utf8"); - - expect(installer).toContain('echo call "%ADE_BIN%" %%*'); - expect(installer).toContain("echo exit /b %%ERRORLEVEL%%"); - }); - - it("pins the Windows desktop build to x64 and unpacks sql.js for node fallback", () => { - const packageJsonPath = path.join(desktopRoot, "package.json"); - const pkg = JSON.parse(fs.readFileSync(packageJsonPath, "utf8")); - - expect(pkg.scripts["dist:win"]).toContain("validate:win:release"); - expect(pkg.build.asarUnpack).toContain("node_modules/sql.js/**/*"); - expect(pkg.build.win.icon).toBe("build/icon.ico"); - expect(pkg.build.win.target).toEqual([ - { - target: "nsis", - arch: ["x64"], - }, - ]); - }); - - it("passes the Windows artifact preflight", () => { - const validateScriptPath = path.join(desktopRoot, "scripts", "validate-win-artifacts.mjs"); - const result = spawnSync(process.execPath, [validateScriptPath, "--mode=preflight"], { - cwd: desktopRoot, - encoding: "utf8", - }); - - expect(result.status).toBe(0); - expect(result.stderr).toBe(""); - expect(result.stdout).toContain("Windows package inputs are present."); - }); - - it("builds and publishes Windows release artifacts in release-core", () => { - const workflowPath = path.join(repoRoot, ".github", "workflows", "release-core.yml"); - const workflow = parseYaml(fs.readFileSync(workflowPath, "utf8")); - const winJob = workflow.jobs["build-win-release"]; - const publishJob = workflow.jobs["publish-release"]; - - expect(winJob["runs-on"]).toBe("windows-latest"); - expect(winJob.steps.some((step: { run?: string }) => step.run?.includes("npm run dist:win"))).toBe(true); - - const winUploadStep = winJob.steps.find((step: { name?: string }) => step.name === "Upload validated Windows artifacts to workflow run"); - expect(winUploadStep.with.path).toContain("apps/desktop/release/latest.yml"); - - expect(publishJob.needs).toEqual(expect.arrayContaining(["build-mac-release", "build-win-release"])); - const publishStep = publishJob.steps.find((step: { name?: string }) => step.name === "Create or update draft GitHub release"); - expect(publishStep.run).toContain("release-assets/win/latest.yml"); - expect(publishStep.run).toContain("release-assets/win/*.exe.blockmap"); - }); -}); diff --git a/apps/desktop/src/main/services/config/projectConfigService.automationExecution.test.ts b/apps/desktop/src/main/services/config/projectConfigService.automationExecution.test.ts new file mode 100644 index 000000000..1c5eda9ca --- /dev/null +++ b/apps/desktop/src/main/services/config/projectConfigService.automationExecution.test.ts @@ -0,0 +1,105 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import YAML from "yaml"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { createProjectConfigService } from "./projectConfigService"; + +function makeDb() { + const store = new Map(); + return { + getJson: vi.fn((key: string) => (store.has(key) ? store.get(key) : null)), + setJson: vi.fn((key: string, value: unknown) => { + store.set(key, value); + }), + run: vi.fn(), + } as any; +} + +function makeLogger() { + return { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + } as any; +} + +describe("projectConfigService automation execution normalization", () => { + const tempDirs: string[] = []; + + afterEach(() => { + while (tempDirs.length > 0) { + const dir = tempDirs.pop(); + if (!dir) break; + fs.rmSync(dir, { recursive: true, force: true }); + } + }); + + it("preserves lane creation fields from config", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "ade-project-config-automation-execution-")); + tempDirs.push(root); + + const adeDir = path.join(root, ".ade"); + fs.mkdirSync(adeDir, { recursive: true }); + + fs.writeFileSync( + path.join(adeDir, "ade.yaml"), + YAML.stringify({ + version: 1, + processes: [], + stackButtons: [], + testSuites: [], + laneOverlayPolicies: [], + automations: [ + { + id: "custom-lane-rule", + trigger: { type: "manual" }, + execution: { + kind: "mission", + laneMode: "create", + laneNamePreset: "custom", + laneNameTemplate: "Auto {{trigger.issue.title}}", + mission: { title: "Run mission" }, + }, + }, + { + id: "preset-lane-rule", + trigger: { type: "manual" }, + execution: { + kind: "agent-session", + laneMode: "nope", + laneNamePreset: "issue-title", + laneNameTemplate: "Should be dropped", + }, + }, + ], + }), + "utf8", + ); + + const service = createProjectConfigService({ + projectRoot: root, + adeDir, + projectId: "project-automation-execution", + db: makeDb(), + logger: makeLogger(), + }); + + const [customRule, presetRule] = service.get().effective.automations; + + expect(customRule.execution).toMatchObject({ + kind: "mission", + laneMode: "create", + laneNamePreset: "custom", + laneNameTemplate: "Auto {{trigger.issue.title}}", + mission: { title: "Run mission" }, + }); + expect(presetRule.execution).toMatchObject({ + kind: "agent-session", + laneMode: "reuse", + laneNamePreset: "issue-title", + }); + expect(presetRule.execution?.laneNameTemplate).toBeUndefined(); + }); +}); diff --git a/apps/desktop/src/main/services/config/projectConfigService.ts b/apps/desktop/src/main/services/config/projectConfigService.ts index 9bff3d27c..dd80f9a70 100644 --- a/apps/desktop/src/main/services/config/projectConfigService.ts +++ b/apps/desktop/src/main/services/config/projectConfigService.ts @@ -524,6 +524,26 @@ function coerceAutomationExecution(value: unknown): AutomationExecution | undefi if (!kind) return undefined; const targetLaneId = asString(value.targetLaneId)?.trim() || undefined; + const laneModeRaw = asString(value.laneMode)?.trim(); + const laneMode: AutomationExecution["laneMode"] = laneModeRaw === "create" || laneModeRaw === "reuse" + ? laneModeRaw + : undefined; + const laneNamePresetRaw = asString(value.laneNamePreset)?.trim(); + const laneNamePreset: AutomationExecution["laneNamePreset"] = laneNamePresetRaw === "issue-title" || + laneNamePresetRaw === "issue-num-title" || + laneNamePresetRaw === "pr-title-author" || + laneNamePresetRaw === "custom" + ? laneNamePresetRaw + : undefined; + const laneNameTemplate = laneNamePreset === "custom" + ? asString(value.laneNameTemplate)?.trim() || undefined + : undefined; + const sharedLaneFields = { + ...(laneMode ? { laneMode } : {}), + ...(laneNamePreset ? { laneNamePreset } : {}), + ...(laneNameTemplate ? { laneNameTemplate } : {}), + }; + if (kind === "agent-session") { const session = isRecord(value.session) ? { @@ -535,6 +555,7 @@ function coerceAutomationExecution(value: unknown): AutomationExecution | undefi : undefined; return { kind, + ...sharedLaneFields, ...(targetLaneId ? { targetLaneId } : {}), ...(session && Object.keys(session).length ? { session } : {}), }; @@ -546,6 +567,7 @@ function coerceAutomationExecution(value: unknown): AutomationExecution | undefi : undefined; return { kind, + ...sharedLaneFields, ...(targetLaneId ? { targetLaneId } : {}), ...(mission ? { mission } : {}), }; @@ -556,6 +578,7 @@ function coerceAutomationExecution(value: unknown): AutomationExecution | undefi : []; return { kind, + ...sharedLaneFields, ...(targetLaneId ? { targetLaneId } : {}), builtIn: { actions }, }; @@ -2216,9 +2239,25 @@ function resolveEffectiveConfig(shared: ProjectConfigFile, local: ProjectConfigF const automations: AutomationRule[] = mergedAutomations.map((entry) => { const triggers = coerceAutomationTriggers(entry.triggers, entry.trigger); const legacyTrigger = coerceAutomationTrigger(entry.trigger); - const execution = entry.execution ?? ((entry.actions?.length ?? 0) > 0 + const baseExecution = entry.execution ?? ((entry.actions?.length ?? 0) > 0 ? { kind: "built-in" as const, builtIn: { actions: entry.actions ?? [] } } : { kind: "mission" as const }); + // Lane-mode migration: legacy rules with a leading `create-lane` action collapse + // into `execution.laneMode: "create"`, carrying the action's name template forward + // as a "custom" preset. Rules without that action default to "reuse". + const firstAction = baseExecution.kind === "built-in" + ? (baseExecution.builtIn?.actions?.[0] ?? null) + : (entry.actions?.[0] ?? null); + const execution = baseExecution.laneMode + ? baseExecution + : firstAction?.type === "create-lane" + ? { + ...baseExecution, + laneMode: "create" as const, + laneNamePreset: "custom" as const, + ...(firstAction.laneNameTemplate ? { laneNameTemplate: firstAction.laneNameTemplate } : {}), + } + : { ...baseExecution, laneMode: "reuse" as const }; return { id: entry.id.trim(), diff --git a/apps/desktop/src/main/services/cto/ctoStateService.test.ts b/apps/desktop/src/main/services/cto/ctoState.test.ts similarity index 79% rename from apps/desktop/src/main/services/cto/ctoStateService.test.ts rename to apps/desktop/src/main/services/cto/ctoState.test.ts index d67e2003b..a515819ff 100644 --- a/apps/desktop/src/main/services/cto/ctoStateService.test.ts +++ b/apps/desktop/src/main/services/cto/ctoState.test.ts @@ -3,9 +3,12 @@ import os from "node:os"; import path from "node:path"; import { describe, expect, it } from "vitest"; import { buildAdeGitignore } from "../../../shared/adeLayout"; +import type { LinearSyncConfig, LinearWorkflowConfig } from "../../../shared/types"; import { createMemoryService } from "../memory/memoryService"; import { openKvDb } from "../state/kvDb"; import { createCtoStateService } from "./ctoStateService"; +import { createFlowPolicyService } from "./flowPolicyService"; +import { createLinearWorkflowFileService } from "./linearWorkflowFileService"; function createLogger() { return { @@ -16,7 +19,7 @@ function createLogger() { } as any; } -async function createFixture() { +async function createStateFixture() { const root = fs.mkdtempSync(path.join(os.tmpdir(), "ade-cto-state-")); const adeDir = path.join(root, ".ade"); fs.mkdirSync(adeDir, { recursive: true }); @@ -26,8 +29,8 @@ async function createFixture() { return { root, adeDir, db, projectId }; } -async function createFixtureWithMemory() { - const fixture = await createFixture(); +async function createStateFixtureWithMemory() { + const fixture = await createStateFixture(); fixture.db.run( `INSERT OR IGNORE INTO projects(id, root_path, display_name, default_base_ref, created_at, last_opened_at) VALUES (?, ?, ?, ?, ?, ?)`, [fixture.projectId, fixture.root, "test-project", "main", new Date().toISOString(), new Date().toISOString()] @@ -38,7 +41,7 @@ async function createFixtureWithMemory() { describe("ctoStateService", () => { it("creates default CTO identity/core memory when absent", async () => { - const fixture = await createFixture(); + const fixture = await createStateFixture(); const service = createCtoStateService({ db: fixture.db, projectId: fixture.projectId, @@ -64,7 +67,7 @@ describe("ctoStateService", () => { }); it("recreates files from DB-only state", async () => { - const fixture = await createFixture(); + const fixture = await createStateFixture(); const identityPayload = { name: "CTO", version: 7, @@ -115,7 +118,7 @@ describe("ctoStateService", () => { }); it("recreates DB rows from file-only state", async () => { - const fixture = await createFixture(); + const fixture = await createStateFixture(); const ctoDir = path.join(fixture.adeDir, "cto"); fs.mkdirSync(ctoDir, { recursive: true }); fs.writeFileSync( @@ -176,7 +179,7 @@ describe("ctoStateService", () => { }); it("uses newer doc and prefers file when timestamps tie", async () => { - const fixture = await createFixture(); + const fixture = await createStateFixture(); const ctoDir = path.join(fixture.adeDir, "cto"); fs.mkdirSync(ctoDir, { recursive: true }); @@ -255,7 +258,7 @@ describe("ctoStateService", () => { }); it("keeps session log integrity and backfills DB from jsonl", async () => { - const fixture = await createFixture(); + const fixture = await createStateFixture(); const service = createCtoStateService({ db: fixture.db, projectId: fixture.projectId, @@ -294,7 +297,7 @@ describe("ctoStateService", () => { }); it("normalizes legacy full_mcp session logs as full tooling", async () => { - const fixture = await createFixture(); + const fixture = await createStateFixture(); const ctoDir = path.join(fixture.adeDir, "cto"); fs.mkdirSync(ctoDir, { recursive: true }); fs.writeFileSync( @@ -324,7 +327,7 @@ describe("ctoStateService", () => { }); it("tracks subordinate activity and exposes it in CTO reconstruction context", async () => { - const fixture = await createFixture(); + const fixture = await createStateFixture(); const service = createCtoStateService({ db: fixture.db, projectId: fixture.projectId, @@ -356,7 +359,7 @@ describe("ctoStateService", () => { }); it("generates long-term memory docs from core memory and promoted durable memories", async () => { - const fixture = await createFixtureWithMemory(); + const fixture = await createStateFixtureWithMemory(); const service = createCtoStateService({ db: fixture.db, projectId: fixture.projectId, @@ -390,7 +393,7 @@ describe("ctoStateService", () => { }); it("appendDailyLog creates the directory and file with timestamped entry", async () => { - const fixture = await createFixture(); + const fixture = await createStateFixture(); const service = createCtoStateService({ db: fixture.db, projectId: fixture.projectId, @@ -412,7 +415,7 @@ describe("ctoStateService", () => { }); it("appendDailyLog appends to existing log", async () => { - const fixture = await createFixture(); + const fixture = await createStateFixture(); const service = createCtoStateService({ db: fixture.db, projectId: fixture.projectId, @@ -433,7 +436,7 @@ describe("ctoStateService", () => { }); it("readDailyLog returns null for non-existent date", async () => { - const fixture = await createFixture(); + const fixture = await createStateFixture(); const service = createCtoStateService({ db: fixture.db, projectId: fixture.projectId, @@ -446,7 +449,7 @@ describe("ctoStateService", () => { }); it("readDailyLog reads back what was written", async () => { - const fixture = await createFixture(); + const fixture = await createStateFixture(); const service = createCtoStateService({ db: fixture.db, projectId: fixture.projectId, @@ -462,7 +465,7 @@ describe("ctoStateService", () => { }); it("listDailyLogs returns dates in reverse chronological order", async () => { - const fixture = await createFixture(); + const fixture = await createStateFixture(); const service = createCtoStateService({ db: fixture.db, projectId: fixture.projectId, @@ -480,7 +483,7 @@ describe("ctoStateService", () => { }); it("listDailyLogs respects the limit parameter", async () => { - const fixture = await createFixture(); + const fixture = await createStateFixture(); const service = createCtoStateService({ db: fixture.db, projectId: fixture.projectId, @@ -500,7 +503,7 @@ describe("ctoStateService", () => { }); it("appendContinuityCheckpoint writes a compaction carry-forward into the daily log", async () => { - const fixture = await createFixture(); + const fixture = await createStateFixture(); const service = createCtoStateService({ db: fixture.db, projectId: fixture.projectId, @@ -526,7 +529,7 @@ describe("ctoStateService", () => { }); it("preserves onboarding state and extended identity fields across reloads", async () => { - const fixture = await createFixture(); + const fixture = await createStateFixture(); const service = createCtoStateService({ db: fixture.db, projectId: fixture.projectId, @@ -566,7 +569,7 @@ describe("ctoStateService", () => { }); it("builds a structured CTO prompt preview with immutable doctrine and preset overlay", async () => { - const fixture = await createFixture(); + const fixture = await createStateFixture(); const service = createCtoStateService({ db: fixture.db, projectId: fixture.projectId, @@ -599,7 +602,7 @@ describe("ctoStateService", () => { }); it("uses the custom personality overlay without removing the immutable doctrine", async () => { - const fixture = await createFixture(); + const fixture = await createStateFixture(); const service = createCtoStateService({ db: fixture.db, projectId: fixture.projectId, @@ -621,3 +624,120 @@ describe("ctoStateService", () => { fixture.db.close(); }); }); + +async function createFlowPolicyFixture() { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "ade-flow-policy-")); + const adeDir = path.join(root, ".ade"); + fs.mkdirSync(adeDir, { recursive: true }); + const dbPath = path.join(adeDir, "ade.db"); + const db = await openKvDb(dbPath, createLogger()); + const projectId = "project-flow-policy"; + const legacyConfig: LinearSyncConfig = { + enabled: true, + projects: [{ slug: "acme-platform", defaultWorker: "backend-dev" }], + autoDispatch: { default: "auto", rules: [{ id: "rule-1", action: "auto", match: { labels: ["bug"] } }] }, + }; + const projectConfigService = { + getEffective: () => ({ linearSync: legacyConfig }), + }; + const workflowFileService = createLinearWorkflowFileService({ projectRoot: root }); + return { db, root, projectId, projectConfigService, workflowFileService }; +} + +describe("flowPolicyService", () => { + it("bootstraps from generated migration, saves repo workflows, and rolls back revisions", async () => { + const fixture = await createFlowPolicyFixture(); + const service = createFlowPolicyService({ + db: fixture.db, + projectId: fixture.projectId, + projectConfigService: fixture.projectConfigService, + workflowFileService: fixture.workflowFileService, + }); + + const bootstrapped = service.getPolicy(); + expect(bootstrapped.workflows.length).toBeGreaterThan(0); + expect(bootstrapped.migration?.needsSave).toBe(true); + expect(bootstrapped.intake.activeStateTypes).toEqual(["backlog", "unstarted", "started"]); + expect(bootstrapped.intake.terminalStateTypes).toEqual(["completed", "canceled"]); + + const toSave: LinearWorkflowConfig = { + ...bootstrapped, + workflows: bootstrapped.workflows.map((workflow, index) => ({ + ...workflow, + priority: 200 - index, + })), + intake: { + projectSlugs: ["acme-platform"], + activeStateTypes: ["backlog", "unstarted"], + terminalStateTypes: ["completed", "canceled"], + }, + }; + + const saved = service.savePolicy(toSave, "user-a"); + expect(saved.source).toBe("repo"); + expect(saved.intake.projectSlugs).toEqual(["acme-platform"]); + expect(saved.intake.activeStateTypes).toEqual(["backlog", "unstarted"]); + expect(fs.readdirSync(path.join(fixture.root, ".ade", "workflows", "linear")).some((entry) => entry.endsWith(".yaml"))).toBe(true); + + const revisions = service.listRevisions(10); + expect(revisions.length).toBe(2); + expect(revisions[0]?.actor).toBe("user-a"); + + const bootstrapRevision = revisions.find((revision) => revision.actor === "bootstrap"); + expect(bootstrapRevision).toBeTruthy(); + const rolledBack = service.rollbackRevision(bootstrapRevision!.id, "user-b"); + expect(rolledBack.workflows[0]?.name).toBeTruthy(); + expect(service.listRevisions(10)[0]?.actor).toBe("user-b"); + + fixture.db.close(); + }); + + it("validates duplicate workflow ids", async () => { + const fixture = await createFlowPolicyFixture(); + const service = createFlowPolicyService({ + db: fixture.db, + projectId: fixture.projectId, + projectConfigService: fixture.projectConfigService, + workflowFileService: fixture.workflowFileService, + }); + + const validation = service.validatePolicy({ + version: 1, + source: "generated", + intake: { + projectSlugs: ["acme-platform"], + activeStateTypes: ["backlog", "unstarted", "started"], + terminalStateTypes: ["completed", "canceled"], + }, + settings: { ctoLinearAssigneeName: "CTO", ctoLinearAssigneeAliases: ["cto"] }, + workflows: [ + { + id: "dup", + name: "One", + enabled: true, + priority: 100, + triggers: { assignees: ["CTO"] }, + target: { type: "mission" }, + steps: [{ id: "launch", type: "launch_target" }], + }, + { + id: "DUP", + name: "Two", + enabled: true, + priority: 90, + triggers: { assignees: ["CTO"] }, + target: { type: "review_gate" }, + steps: [{ id: "launch", type: "launch_target" }], + }, + ], + files: [], + migration: { hasLegacyConfig: false, needsSave: true }, + legacyConfig: null, + }); + + expect(validation.ok).toBe(false); + expect(validation.issues.join(" ")).toContain("Duplicate workflow id"); + + fixture.db.close(); + }); +}); diff --git a/apps/desktop/src/main/services/cto/ctoWorkerLifecycle.test.ts b/apps/desktop/src/main/services/cto/ctoWorkerLifecycle.test.ts new file mode 100644 index 000000000..affd5936f --- /dev/null +++ b/apps/desktop/src/main/services/cto/ctoWorkerLifecycle.test.ts @@ -0,0 +1,2343 @@ +import YAML from "yaml"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import type { AgentIdentity } from "../../../shared/types"; +import type { AgentIdentity, WorkerAgentRunStatus, WorkerAgentWakeupReason } from "../../../shared/types"; +import { EventEmitter } from "node:events"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { createOpenclawBridgeService } from "./openclawBridgeService"; +import { createWorkerAdapterRuntimeService } from "./workerAdapterRuntimeService"; +import { createWorkerAgentService } from "./workerAgentService"; +import { createWorkerBudgetService } from "./workerBudgetService"; +import { createWorkerHeartbeatService } from "./workerHeartbeatService"; +import { createWorkerRevisionService } from "./workerRevisionService"; +import { createWorkerTaskSessionService } from "./workerTaskSessionService"; +import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; +import { describe, expect, it, vi, afterEach } from "vitest"; +import { openKvDb } from "../state/kvDb"; +import { openKvDb, type AdeDb } from "../state/kvDb"; + +describe("workerHeartbeatService (file group)", () => { + + function createLogger() { + return { + debug: () => {}, + info: () => {}, + warn: () => {}, + error: () => {}, + } as any; + } + + function nowIso(): string { + return new Date().toISOString(); + } + + async function waitForCondition(assertion: () => void, timeoutMs = 3_000, intervalMs = 15): Promise { + const deadline = Date.now() + timeoutMs; + let lastError: unknown = null; + while (Date.now() <= deadline) { + try { + assertion(); + return; + } catch (error) { + lastError = error; + } + await new Promise((resolve) => setTimeout(resolve, intervalMs)); + } + throw lastError instanceof Error ? lastError : new Error("Condition timed out."); + } + + function insertRunRow( + db: AdeDb, + input: { + id: string; + projectId: string; + agentId: string; + status: WorkerAgentRunStatus; + wakeupReason?: WorkerAgentWakeupReason; + taskKey?: string | null; + issueKey?: string | null; + executionRunId?: string | null; + executionLockedAt?: string | null; + contextJson?: string | null; + resultJson?: string | null; + errorMessage?: string | null; + startedAt?: string | null; + finishedAt?: string | null; + createdAt?: string; + updatedAt?: string; + } + ): void { + const createdAt = input.createdAt ?? nowIso(); + const updatedAt = input.updatedAt ?? createdAt; + db.run( + ` + insert into worker_agent_runs( + id, project_id, agent_id, status, wakeup_reason, task_key, issue_key, execution_run_id, execution_locked_at, + context_json, result_json, error_message, started_at, finished_at, created_at, updated_at + ) + values(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `, + [ + input.id, + input.projectId, + input.agentId, + input.status, + input.wakeupReason ?? "manual", + input.taskKey ?? null, + input.issueKey ?? null, + input.executionRunId ?? null, + input.executionLockedAt ?? null, + input.contextJson ?? "{}", + input.resultJson ?? null, + input.errorMessage ?? null, + input.startedAt ?? null, + input.finishedAt ?? null, + createdAt, + updatedAt, + ] + ); + } + + async function createFixture(options: { + runtimeRun?: ReturnType; + memoryService?: { + getMemoryBudget: ReturnType; + }; + ctoStateService?: { + appendSubordinateActivity: ReturnType; + }; + autoStart?: boolean; + staleLockMs?: number; + maintenanceIntervalMs?: number; + } = {}) { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "ade-worker-heartbeat-")); + const adeDir = path.join(root, ".ade"); + fs.mkdirSync(adeDir, { recursive: true }); + const db = await openKvDb(path.join(adeDir, "ade.db"), createLogger()); + const projectId = "project-heartbeat-test"; + const workerAgentService = createWorkerAgentService({ + db, + projectId, + adeDir, + }); + const workerTaskSessionService = createWorkerTaskSessionService({ + db, + projectId, + }); + const runtimeRun = options.runtimeRun ?? vi.fn(async () => ({ + ok: true, + adapterType: "codex-local", + effectiveSurface: "process", + statusCode: 200, + outputText: "HEARTBEAT_OK", + provider: "codex", + modelId: "openai/gpt-5.3-codex", + continuation: null, + usage: null, + })); + const runtimeAdapter = { + run: vi.fn(async (...runtimeArgs: any[]) => { + const result = await runtimeRun(...runtimeArgs) as Record; + return { + effectiveSurface: "process", + provider: null, + modelId: null, + sessionId: null, + continuation: null, + ...result, + }; + }), + }; + const recordCostEvent = vi.fn(); + const heartbeat = createWorkerHeartbeatService({ + db, + projectId, + workerAgentService, + workerTaskSessionService, + workerAdapterRuntimeService: runtimeAdapter as any, + workerBudgetService: { recordCostEvent } as any, + memoryService: options.memoryService as any, + ctoStateService: options.ctoStateService as any, + logger: createLogger(), + autoStart: options.autoStart ?? false, + staleLockMs: options.staleLockMs, + maintenanceIntervalMs: options.maintenanceIntervalMs, + }); + + const createWorker = (overrides: Partial & { name: string }): AgentIdentity => { + return workerAgentService.saveAgent({ + id: overrides.id, + name: overrides.name, + role: overrides.role ?? "engineer", + title: overrides.title, + reportsTo: overrides.reportsTo, + capabilities: overrides.capabilities ?? [], + adapterType: overrides.adapterType ?? "codex-local", + adapterConfig: (overrides.adapterConfig as Record | undefined) ?? { model: "gpt-5.3-codex" }, + runtimeConfig: (overrides.runtimeConfig as Record | undefined) ?? { + heartbeat: { + enabled: true, + intervalSec: 60, + wakeOnDemand: true, + }, + }, + status: overrides.status, + budgetMonthlyCents: overrides.budgetMonthlyCents, + }); + }; + + const dispose = async () => { + await heartbeat.dispose(); + db.close(); + }; + + return { + root, + adeDir, + db, + projectId, + workerAgentService, + workerTaskSessionService, + heartbeat, + runtimeRun, + recordCostEvent, + createWorker, + dispose, + }; + } + + afterEach(() => { + vi.clearAllTimers(); + vi.useRealTimers(); + }); + + describe("workerHeartbeatService", () => { + it("timer wake fires at configured interval", async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-03-05T12:00:00.000Z")); + const fixture = await createFixture(); + const worker = fixture.createWorker({ + name: "Timer Worker", + runtimeConfig: { + heartbeat: { + enabled: true, + intervalSec: 1, + wakeOnDemand: true, + }, + }, + }); + + fixture.heartbeat.syncFromConfig(); + await vi.advanceTimersByTimeAsync(1_200); + + // Assert directly after advancing fake timers -- waitForCondition cannot + // work here because its internal setTimeout/Date.now rely on real timers. + const runs = fixture.heartbeat.listRuns({ agentId: worker.id, limit: 5 }); + expect(runs.length).toBeGreaterThan(0); + expect(runs[0]?.wakeupReason).toBe("timer"); + expect(runs[0]?.status).toBe("completed"); + await fixture.dispose(); + }); + + it("active-hours gate blocks timer wakes outside configured window", async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-03-05T12:00:00.000Z")); + const fixture = await createFixture(); + const worker = fixture.createWorker({ + name: "Active Hours Timer Worker", + runtimeConfig: { + heartbeat: { + enabled: true, + intervalSec: 30, + wakeOnDemand: true, + activeHours: { + start: "00:00", + end: "00:01", + timezone: "UTC", + }, + }, + }, + }); + + const wake = await fixture.heartbeat.triggerWakeup({ + agentId: worker.id, + reason: "timer", + }); + expect(wake.status).toBe("deferred"); + expect(fixture.runtimeRun).not.toHaveBeenCalled(); + await fixture.dispose(); + }); + + it("on-demand wake also respects active-hours gate", async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-03-05T12:00:00.000Z")); + const fixture = await createFixture(); + const worker = fixture.createWorker({ + name: "Active Hours Manual Worker", + runtimeConfig: { + heartbeat: { + enabled: true, + intervalSec: 60, + wakeOnDemand: true, + activeHours: { + start: "00:00", + end: "00:01", + timezone: "UTC", + }, + }, + }, + }); + + const wake = await fixture.heartbeat.triggerWakeup({ + agentId: worker.id, + reason: "manual", + prompt: "Please inspect current assignment", + }); + expect(wake.status).toBe("deferred"); + expect(fixture.runtimeRun).not.toHaveBeenCalled(); + await fixture.dispose(); + }); + + it("cheap-check timer run with no change skips adapter escalation", async () => { + const fixture = await createFixture(); + const worker = fixture.createWorker({ name: "Cheap Check Worker" }); + + const wake = await fixture.heartbeat.triggerWakeup({ + agentId: worker.id, + reason: "timer", + context: { hasChanges: false, eventCount: 0 }, + }); + expect(wake.status).toBe("completed"); + expect(fixture.runtimeRun).not.toHaveBeenCalled(); + await fixture.dispose(); + }); + + it("records HEARTBEAT_OK results without errors", async () => { + const runtimeRun = vi.fn(async () => ({ + ok: true, + adapterType: "codex-local", + statusCode: 200, + outputText: "HEARTBEAT_OK", + usage: null, + })); + const fixture = await createFixture({ runtimeRun }); + const worker = fixture.createWorker({ name: "Heartbeat Ok Worker" }); + + const wake = await fixture.heartbeat.triggerWakeup({ + agentId: worker.id, + reason: "manual", + prompt: "Check for urgent events", + }); + const run = fixture.heartbeat.listRuns({ agentId: worker.id, limit: 5 }).find((entry) => entry.id === wake.runId); + expect(run?.status).toBe("completed"); + expect((run?.result as Record)?.heartbeatOk).toBe(true); + expect((run?.result as Record)?.outputPreview).toBe("HEARTBEAT_OK"); + await fixture.dispose(); + }); + + it("injects worker reconstruction, task session, and project memory into runtime prompts", async () => { + const runtimeRun = vi.fn(async () => ({ + ok: true, + adapterType: "codex-local", + statusCode: 200, + outputText: "looked good", + usage: null, + })); + const memoryService = { + getMemoryBudget: vi.fn(() => ([ + { + category: "pattern", + content: "Reuse the issue lock before starting a second worker on the same issue.", + }, + ])), + }; + const fixture = await createFixture({ runtimeRun, memoryService }); + const worker = fixture.createWorker({ name: "Memory Rich Worker" }); + fixture.workerAgentService.updateCoreMemory(worker.id, { + projectSummary: "Owns worker-side issue triage and escalation.", + criticalConventions: ["Prefer HEARTBEAT_OK when there is no actionable work."], + activeFocus: ["Issue triage"], + }); + + await fixture.heartbeat.triggerWakeup({ + agentId: worker.id, + reason: "manual", + taskKey: "task:memory-rich", + issueKey: "ISSUE-900", + prompt: "Inspect the issue queue and decide whether escalation is needed.", + context: { queue: "bugs", severity: "high" }, + }); + + expect(runtimeRun).toHaveBeenCalledTimes(1); + const firstCall = (runtimeRun.mock.calls as Array)[0]?.[0] as { prompt?: string } | undefined; + const prompt = String(firstCall?.prompt ?? ""); + expect(prompt).toContain("System context (worker reconstruction, do not echo verbatim):"); + expect(prompt).toContain("Owns worker-side issue triage and escalation."); + expect(prompt).toContain("Project memory highlights:"); + expect(prompt).toContain("Reuse the issue lock before starting a second worker on the same issue."); + expect(prompt).toContain("Task session state:"); + expect(prompt).toContain("task:memory-rich"); + expect(prompt).toContain("Current wakeup request:"); + await fixture.dispose(); + }); + + it("appends worker session logs after escalated runs so reconstruction memory compounds", async () => { + const runtimeRun = vi.fn(async () => ({ + ok: true, + adapterType: "codex-local", + statusCode: 200, + outputText: "Reviewed alerts and found no actionable follow-up.", + usage: null, + })); + const fixture = await createFixture({ runtimeRun }); + const worker = fixture.createWorker({ name: "Session Log Worker" }); + + await fixture.heartbeat.triggerWakeup({ + agentId: worker.id, + reason: "manual", + taskKey: "task:session-log", + prompt: "Review the alert backlog.", + }); + + const sessions = fixture.workerAgentService.listSessionLogs(worker.id, 10); + expect(sessions.length).toBe(1); + expect(sessions[0]?.summary).toContain("Wake reason: manual."); + expect(sessions[0]?.summary).toContain("task:session-log"); + await fixture.dispose(); + }); + + it("propagates meaningful worker runs into CTO subordinate activity", async () => { + const runtimeRun = vi.fn(async () => ({ + ok: true, + adapterType: "codex-local", + statusCode: 200, + outputText: "Reviewed alerts and found no actionable follow-up.", + usage: null, + })); + const ctoStateService = { + appendSubordinateActivity: vi.fn(), + }; + const fixture = await createFixture({ runtimeRun, ctoStateService }); + const worker = fixture.createWorker({ name: "Digest Worker" }); + + await fixture.heartbeat.triggerWakeup({ + agentId: worker.id, + reason: "manual", + taskKey: "task:cto-digest", + issueKey: "ISSUE-42", + prompt: "Review the alert backlog.", + }); + + expect(ctoStateService.appendSubordinateActivity).toHaveBeenCalledWith( + expect.objectContaining({ + agentId: worker.id, + agentName: "Digest Worker", + activityType: "worker_run", + taskKey: "task:cto-digest", + issueKey: "ISSUE-42", + }) + ); + await fixture.dispose(); + }); + + it("coalesces duplicate wakeups while same task is running", async () => { + let resolveFirst!: (value: { + ok: boolean; + adapterType: string; + statusCode: number; + outputText: string; + usage: null; + }) => void; + const runtimeRun = vi + .fn() + .mockImplementationOnce( + () => + new Promise((resolve) => { + resolveFirst = resolve; + }) + ) + .mockResolvedValue({ + ok: true, + adapterType: "codex-local", + statusCode: 200, + outputText: "done", + usage: null, + }); + + const fixture = await createFixture({ runtimeRun }); + const worker = fixture.createWorker({ name: "Coalescing Worker" }); + + const firstWakePromise = fixture.heartbeat.triggerWakeup({ + agentId: worker.id, + reason: "manual", + taskKey: "task:same", + issueKey: "ISSUE-123", + prompt: "Work on issue 123", + context: { source: "first" }, + }); + + await waitForCondition(() => { + const running = fixture.heartbeat + .listRuns({ agentId: worker.id, limit: 10 }) + .find((run) => run.status === "running"); + expect(running).toBeTruthy(); + }); + + const secondWake = await fixture.heartbeat.triggerWakeup({ + agentId: worker.id, + reason: "manual", + taskKey: "task:same", + issueKey: "ISSUE-123", + context: { source: "second" }, + }); + expect(secondWake.status).toBe("skipped"); + + resolveFirst({ + ok: true, + adapterType: "codex-local", + statusCode: 200, + outputText: "complete", + usage: null, + }); + const firstWake = await firstWakePromise; + + await waitForCondition(() => { + const firstRun = fixture.heartbeat.listRuns({ agentId: worker.id, limit: 10 }).find((run) => run.id === firstWake.runId); + expect(firstRun?.status).toBe("completed"); + }); + + const latestRuns = fixture.heartbeat.listRuns({ agentId: worker.id, limit: 10 }); + const firstRun = latestRuns.find((run) => run.id === firstWake.runId); + const coalesced = (firstRun?.context.coalescedWakeups as unknown[]) ?? []; + expect(coalesced.length).toBe(1); + await fixture.dispose(); + }); + + it("promotes deferred wake after active run completes", async () => { + let resolveFirst!: (value: { + ok: boolean; + adapterType: string; + statusCode: number; + outputText: string; + usage: null; + }) => void; + const runtimeRun = vi + .fn() + .mockImplementationOnce( + () => + new Promise((resolve) => { + resolveFirst = resolve; + }) + ) + .mockResolvedValue({ + ok: true, + adapterType: "codex-local", + statusCode: 200, + outputText: "second-complete", + usage: null, + }); + const fixture = await createFixture({ runtimeRun }); + const worker = fixture.createWorker({ name: "Deferred Promotion Worker" }); + + const firstWakePromise = fixture.heartbeat.triggerWakeup({ + agentId: worker.id, + reason: "manual", + taskKey: "task:first", + issueKey: "ISSUE-A", + prompt: "first task", + }); + await waitForCondition(() => { + const running = fixture.heartbeat.listRuns({ agentId: worker.id, limit: 10 }).find((run) => run.status === "running"); + expect(running).toBeTruthy(); + }); + + const secondWake = await fixture.heartbeat.triggerWakeup({ + agentId: worker.id, + reason: "manual", + taskKey: "task:second", + issueKey: "ISSUE-B", + prompt: "second task", + }); + expect(secondWake.status).toBe("deferred"); + + resolveFirst({ + ok: true, + adapterType: "codex-local", + statusCode: 200, + outputText: "first-complete", + usage: null, + }); + await firstWakePromise; + + await waitForCondition(() => { + const promoted = fixture.heartbeat.listRuns({ agentId: worker.id, limit: 10 }).find((run) => run.id === secondWake.runId); + expect(promoted?.status).toBe("completed"); + }); + expect(runtimeRun).toHaveBeenCalledTimes(2); + await fixture.dispose(); + }); + + it("reaps orphaned queued/running runs on startup and promotes deferred work", async () => { + const runtimeRun = vi.fn(async () => ({ + ok: true, + adapterType: "codex-local", + statusCode: 200, + outputText: "startup-recovered", + usage: null, + })); + const fixture = await createFixture({ runtimeRun }); + const worker = fixture.createWorker({ name: "Startup Recovery Worker" }); + const timestamp = "2026-03-05T00:00:00.000Z"; + + insertRunRow(fixture.db, { + id: "orphan-queued", + projectId: fixture.projectId, + agentId: worker.id, + status: "queued", + wakeupReason: "startup_recovery", + createdAt: timestamp, + updatedAt: timestamp, + }); + insertRunRow(fixture.db, { + id: "orphan-running", + projectId: fixture.projectId, + agentId: worker.id, + status: "running", + wakeupReason: "startup_recovery", + executionRunId: "exec-orphan", + executionLockedAt: timestamp, + createdAt: timestamp, + updatedAt: timestamp, + }); + insertRunRow(fixture.db, { + id: "recoverable-deferred", + projectId: fixture.projectId, + agentId: worker.id, + status: "deferred", + wakeupReason: "deferred_promotion", + taskKey: "task:recover", + issueKey: "ISSUE-RECOVER", + contextJson: JSON.stringify({ prompt: "recover deferred task" }), + createdAt: timestamp, + updatedAt: timestamp, + }); + + await fixture.heartbeat.reapOrphansOnStartup(); + + await waitForCondition(() => { + const runs = fixture.heartbeat.listRuns({ agentId: worker.id, limit: 20 }); + expect(runs.find((run) => run.id === "orphan-queued")?.status).toBe("failed"); + expect(runs.find((run) => run.id === "orphan-running")?.status).toBe("failed"); + expect(runs.find((run) => run.id === "recoverable-deferred")?.status).toBe("completed"); + }); + + await fixture.dispose(); + }); + + it("issue lock checkout blocks parallel run for same issue", async () => { + let resolveFirst!: (value: { + ok: boolean; + adapterType: string; + statusCode: number; + outputText: string; + usage: null; + }) => void; + const runtimeRun = vi + .fn() + .mockImplementationOnce( + () => + new Promise((resolve) => { + resolveFirst = resolve; + }) + ) + .mockResolvedValue({ + ok: true, + adapterType: "codex-local", + statusCode: 200, + outputText: "done", + usage: null, + }); + const fixture = await createFixture({ runtimeRun }); + const workerA = fixture.createWorker({ name: "Issue Locker A" }); + const workerB = fixture.createWorker({ name: "Issue Locker B" }); + + const firstWakePromise = fixture.heartbeat.triggerWakeup({ + agentId: workerA.id, + reason: "manual", + issueKey: "ISSUE-LOCK", + prompt: "worker A task", + }); + await waitForCondition(() => { + const running = fixture.heartbeat.listRuns({ agentId: workerA.id, limit: 10 }).find((run) => run.status === "running"); + expect(running).toBeTruthy(); + }); + + const secondWake = await fixture.heartbeat.triggerWakeup({ + agentId: workerB.id, + reason: "manual", + issueKey: "ISSUE-LOCK", + prompt: "worker B task", + }); + expect(secondWake.status).toBe("deferred"); + + resolveFirst({ + ok: true, + adapterType: "codex-local", + statusCode: 200, + outputText: "finish", + usage: null, + }); + await firstWakePromise; + + const secondRun = fixture.heartbeat.listRuns({ agentId: workerB.id, limit: 10 }).find((run) => run.id === secondWake.runId); + expect(secondRun?.status).toBe("deferred"); + await fixture.dispose(); + }); + + it("adopts stale issue lock and fails stale owner run", async () => { + const fixture = await createFixture({ staleLockMs: 50 }); + const workerA = fixture.createWorker({ name: "Stale Owner" }); + const workerB = fixture.createWorker({ name: "Stale Adopter" }); + const staleAt = new Date(Date.now() - 120_000).toISOString(); + insertRunRow(fixture.db, { + id: "stale-running-run", + projectId: fixture.projectId, + agentId: workerA.id, + status: "running", + wakeupReason: "manual", + issueKey: "ISSUE-STALE", + executionRunId: "exec-stale", + executionLockedAt: staleAt, + createdAt: staleAt, + updatedAt: staleAt, + }); + + const wake = await fixture.heartbeat.triggerWakeup({ + agentId: workerB.id, + reason: "manual", + issueKey: "ISSUE-STALE", + prompt: "adopt stale lock", + }); + expect(wake.status).toBe("completed"); + + const staleRun = fixture.heartbeat.listRuns({ agentId: workerA.id, limit: 10 }).find((run) => run.id === "stale-running-run"); + expect(staleRun?.status).toBe("failed"); + expect(staleRun?.errorMessage).toContain("adopted"); + await fixture.dispose(); + }); + + it("waits for direct wakeup dispatches during dispose", async () => { + let releaseRun: () => void = () => { + throw new Error("Expected wakeup runtime to be blocked."); + }; + const runtimeRun = vi.fn(async () => { + await new Promise((resolve) => { + releaseRun = resolve; + }); + return { + ok: true, + adapterType: "codex-local", + effectiveSurface: "process", + statusCode: 200, + outputText: "completed wake", + provider: "codex", + modelId: "openai/gpt-5.3-codex", + continuation: null, + usage: null, + }; + }); + const fixture = await createFixture({ runtimeRun }); + const worker = fixture.createWorker({ name: "Direct Wake Worker" }); + + const wake = fixture.heartbeat.triggerWakeup({ + agentId: worker.id, + reason: "manual", + prompt: "run slowly", + }); + await waitForCondition(() => { + expect(runtimeRun).toHaveBeenCalledTimes(1); + }); + + let disposeSettled = false; + const dispose = fixture.heartbeat.dispose().then(() => { + disposeSettled = true; + }); + await Promise.resolve(); + expect(disposeSettled).toBe(false); + + releaseRun(); + await wake; + await dispose; + expect(disposeSettled).toBe(true); + fixture.db.close(); + }); + + it("reuses persisted worker continuation handles across repeated wakeups on the same delegated task", async () => { + const runtimeRun = vi.fn() + .mockResolvedValueOnce({ + ok: true, + adapterType: "codex-local", + effectiveSurface: "codex_app_server", + statusCode: 200, + outputText: "completed first wake", + provider: "codex", + modelId: "openai/gpt-5.3-codex", + sessionId: "session-1", + continuation: { + surface: "codex_app_server", + provider: "codex", + modelId: "openai/gpt-5.3-codex", + sessionId: "session-1", + threadId: "thread-1", + }, + usage: null, + }) + .mockResolvedValueOnce({ + ok: true, + adapterType: "codex-local", + effectiveSurface: "codex_app_server", + statusCode: 200, + outputText: "completed second wake", + provider: "codex", + modelId: "openai/gpt-5.3-codex", + sessionId: "session-1", + continuation: { + surface: "codex_app_server", + provider: "codex", + modelId: "openai/gpt-5.3-codex", + sessionId: "session-1", + threadId: "thread-1", + }, + usage: null, + }); + const fixture = await createFixture({ runtimeRun }); + const worker = fixture.createWorker({ name: "Continuation Worker" }); + const taskKey = fixture.workerTaskSessionService.deriveTaskKey({ + agentId: worker.id, + workflowRunId: "linear-run-1", + laneId: "lane-123", + linearIssueId: "issue-123", + summary: "Resume delegated work", + }); + + await fixture.heartbeat.triggerWakeup({ + agentId: worker.id, + reason: "assignment", + taskKey, + issueKey: "ABC-123", + prompt: "first wake", + context: { + runId: "linear-run-1", + laneId: "lane-123", + issueId: "issue-123", + issueTitle: "Resume delegated work", + }, + }); + + const persisted = fixture.workerTaskSessionService.getTaskSession(worker.id, worker.adapterType, taskKey); + expect((persisted?.payload as Record)?.continuity?.handle).toMatchObject({ + sessionId: "session-1", + threadId: "thread-1", + }); + + await fixture.heartbeat.triggerWakeup({ + agentId: worker.id, + reason: "assignment", + taskKey, + issueKey: "ABC-123", + prompt: "second wake", + context: { + runId: "linear-run-1", + laneId: "lane-123", + issueId: "issue-123", + issueTitle: "Resume delegated work", + }, + }); + + expect(runtimeRun).toHaveBeenCalledTimes(2); + const secondCall = (runtimeRun.mock.calls as Array)[1]?.[0] as Record; + expect(secondCall.laneId).toBe("lane-123"); + expect(secondCall.continuation).toMatchObject({ + sessionId: "session-1", + threadId: "thread-1", + surface: "codex_app_server", + }); + + await fixture.dispose(); + }); + }); + +}); + +describe("workerAdapterRuntimeService (file group)", () => { + + type SpawnStubCapture = { + command: string; + args: string[]; + stdinWritten: string; + }; + + function makeAgent(overrides: Partial): AgentIdentity { + return { + id: "agent-1", + name: "Worker", + slug: "worker", + role: "engineer", + reportsTo: null, + capabilities: [], + status: "idle", + adapterType: "process", + adapterConfig: {}, + runtimeConfig: {}, + budgetMonthlyCents: 0, + spentMonthlyCents: 0, + createdAt: "2026-03-05T00:00:00.000Z", + updatedAt: "2026-03-05T00:00:00.000Z", + deletedAt: null, + ...overrides, + }; + } + + function createSpawnStub(output = "ok"): { + spawn: any; + capture: SpawnStubCapture; + } { + const capture: SpawnStubCapture = { + command: "", + args: [], + stdinWritten: "", + }; + const spawn = vi.fn((command: string, args: string[]) => { + capture.command = command; + capture.args = [...args]; + const stdout = new EventEmitter(); + const stderr = new EventEmitter(); + const child = new EventEmitter() as any; + child.stdout = stdout; + child.stderr = stderr; + child.stdin = { + write: (chunk: string) => { + capture.stdinWritten += chunk; + }, + end: () => {}, + }; + child.kill = vi.fn(); + queueMicrotask(() => { + stdout.emit("data", output); + child.emit("close", 0, null); + }); + return child; + }); + return { spawn, capture }; + } + + function createSession(id: string, provider: "claude" | "codex" | "opencode", model: string, modelId: string) { + return { + id, + laneId: "lane-1", + provider, + model, + modelId, + status: "idle" as const, + createdAt: "2026-03-05T00:00:00.000Z", + lastActivityAt: "2026-03-05T00:00:00.000Z", + }; + } + + describe("workerAdapterRuntimeService", () => { + it("runs claude-local through CLI spawn path", async () => { + const { spawn, capture } = createSpawnStub("claude-output"); + const service = createWorkerAdapterRuntimeService({ spawnImpl: spawn as any }); + const result = await service.run({ + agent: makeAgent({ + adapterType: "claude-local", + adapterConfig: { model: "sonnet", cliArgs: ["--json"] }, + }), + prompt: "hello", + }); + + expect(capture.command).toBe("claude"); + expect(capture.args).toEqual(["--model", "sonnet", "--json"]); + expect(capture.stdinWritten).toContain("hello"); + expect(result.ok).toBe(true); + expect(result.effectiveSurface).toBe("process"); + expect(result.outputText).toContain("claude-output"); + }); + + it("runs codex-local through CLI spawn path", async () => { + const { spawn, capture } = createSpawnStub("codex-output"); + const service = createWorkerAdapterRuntimeService({ spawnImpl: spawn as any }); + const result = await service.run({ + agent: makeAgent({ + adapterType: "codex-local", + adapterConfig: { model: "gpt-5.3-codex", cliArgs: ["--json"] }, + }), + prompt: "fix this", + }); + + expect(path.basename(capture.command)).toBe("codex"); + expect(capture.args).toEqual(["--model", "gpt-5.3-codex", "--json"]); + expect(result.ok).toBe(true); + expect(result.effectiveSurface).toBe("process"); + expect(result.outputText).toContain("codex-output"); + }); + + it("reuses Claude SDK session handles through the shared chat surface", async () => { + const ensureIdentitySession = vi.fn(async () => + createSession("session-claude-1", "claude", "claude-sonnet-4-6", "anthropic/claude-sonnet-4-6") + ); + const runSessionTurn = vi.fn(async () => ({ + sessionId: "session-claude-1", + provider: "claude", + model: "claude-sonnet-4-6", + modelId: "anthropic/claude-sonnet-4-6", + outputText: "claude session output", + sdkSessionId: "sdk-session-1", + })); + const service = createWorkerAdapterRuntimeService({ + getAgentChatService: () => ({ ensureIdentitySession, runSessionTurn }), + }); + + const result = await service.run({ + agent: makeAgent({ + adapterType: "claude-local", + adapterConfig: { modelId: "anthropic/claude-sonnet-4-6" }, + }), + laneId: "lane-1", + prompt: "resume the delegated issue", + }); + + expect(ensureIdentitySession).toHaveBeenCalledWith({ + identityKey: "agent:agent-1", + laneId: "lane-1", + modelId: "anthropic/claude-sonnet-4-6", + reuseExisting: true, + }); + expect(result.effectiveSurface).toBe("claude_sdk"); + expect(result.continuation).toMatchObject({ + surface: "claude_sdk", + sessionId: "session-claude-1", + sdkSessionId: "sdk-session-1", + }); + }); + + it("reuses Codex app-server thread handles through the shared chat surface", async () => { + const ensureIdentitySession = vi.fn(async () => + createSession("session-codex-1", "codex", "gpt-5.3-codex", "openai/gpt-5.3-codex") + ); + const runSessionTurn = vi.fn(async () => ({ + sessionId: "session-codex-1", + provider: "codex", + model: "gpt-5.3-codex", + modelId: "openai/gpt-5.3-codex", + outputText: "codex session output", + threadId: "thread-77", + })); + const service = createWorkerAdapterRuntimeService({ + getAgentChatService: () => ({ ensureIdentitySession, runSessionTurn }), + }); + + const result = await service.run({ + agent: makeAgent({ + adapterType: "codex-local", + adapterConfig: { modelId: "openai/gpt-5.3-codex" }, + }), + laneId: "lane-1", + prompt: "resume the delegated issue", + }); + + expect(result.effectiveSurface).toBe("codex_app_server"); + expect(result.continuation).toMatchObject({ + surface: "codex_app_server", + sessionId: "session-codex-1", + threadId: "thread-77", + }); + }); + + it("reuses opencode chat sessions for API-key or local-model workers", async () => { + const ensureIdentitySession = vi.fn(async () => + createSession("session-opencode-1", "opencode", "gpt-5.4-mini", "openai/gpt-5.4-mini") + ); + const runSessionTurn = vi.fn(async () => ({ + sessionId: "session-opencode-1", + provider: "opencode", + model: "gpt-5.4-mini", + modelId: "openai/gpt-5.4-mini", + outputText: "opencode chat output", + })); + const service = createWorkerAdapterRuntimeService({ + getAgentChatService: () => ({ ensureIdentitySession, runSessionTurn }), + }); + + const result = await service.run({ + agent: makeAgent({ + adapterType: "process", + adapterConfig: { modelId: "openai/gpt-5.4-mini" }, + }), + continuation: { + surface: "unified_chat", + sessionId: "session-opencode-1", + }, + prompt: "continue the same worker context", + }); + + expect(ensureIdentitySession).not.toHaveBeenCalled(); + expect(runSessionTurn).toHaveBeenCalledWith({ + sessionId: "session-opencode-1", + text: expect.stringContaining("continue the same worker context"), + timeoutMs: 300000, + }); + const firstCall = runSessionTurn.mock.calls[0] as unknown as [{ text: string }] | undefined; + expect(firstCall?.[0]?.text).toContain("## ADE CLI"); + expect(firstCall?.[0]?.text).toContain("Before saying an ADE task is blocked"); + expect(result.effectiveSurface).toBe("unified_chat"); + expect(result.continuation).toMatchObject({ + surface: "unified_chat", + sessionId: "session-opencode-1", + modelId: "openai/gpt-5.4-mini", + }); + }); + + it("sends openclaw-webhook request with resolved env header", async () => { + process.env.OPENCLAW_WEBHOOK_TOKEN = "secret-token"; + const fetchMock = vi.fn(async (_url: string, init?: RequestInit) => { + return { + ok: true, + status: 200, + text: async () => JSON.stringify({ output: "webhook-ok" }), + } as any; + }); + const service = createWorkerAdapterRuntimeService({ fetchImpl: fetchMock as any }); + const result = await service.run({ + agent: makeAgent({ + adapterType: "openclaw-webhook", + adapterConfig: { + url: "https://example.com/hook", + headers: { + Authorization: "Bearer ${env:OPENCLAW_WEBHOOK_TOKEN}", + }, + }, + }), + prompt: "run remote", + }); + + expect(fetchMock).toHaveBeenCalledTimes(1); + const [, init] = fetchMock.mock.calls[0] as [string, RequestInit]; + expect((init.headers as Record).Authorization).toBe("Bearer secret-token"); + expect(result.ok).toBe(true); + expect(result.outputText).toBe("webhook-ok"); + }); + + it("runs process adapter and blocks unsafe commands", async () => { + const { spawn } = createSpawnStub("process-output"); + const service = createWorkerAdapterRuntimeService({ spawnImpl: spawn as any }); + const ok = await service.run({ + agent: makeAgent({ + adapterType: "process", + adapterConfig: { command: "echo", args: ["hello"] }, + }), + prompt: "test", + }); + expect(ok.ok).toBe(true); + expect(ok.outputText).toContain("process-output"); + + await expect( + service.run({ + agent: makeAgent({ + adapterType: "process", + adapterConfig: { command: "rm -rf /" }, + }), + prompt: "test", + }) + ).rejects.toThrow(/unsafe/i); + }); + }); + +}); + +describe("workerAgentService (file group)", () => { + + function createLogger() { + return { + debug: () => {}, + info: () => {}, + warn: () => {}, + error: () => {}, + } as any; + } + + async function createFixture() { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "ade-worker-agents-")); + const adeDir = path.join(root, ".ade"); + fs.mkdirSync(adeDir, { recursive: true }); + const dbPath = path.join(adeDir, "ade.db"); + const db = await openKvDb(dbPath, createLogger()); + const projectId = "project-test"; + const service = createWorkerAgentService({ + db, + projectId, + adeDir, + }); + return { root, adeDir, db, projectId, service }; + } + + describe("workerAgentService", () => { + it("creates, edits, and removes worker agents with unlink-on-delete", async () => { + const fixture = await createFixture(); + const manager = fixture.service.saveAgent({ + name: "Backend Lead", + role: "engineer", + adapterType: "claude-local", + adapterConfig: { model: "sonnet" }, + }); + const report = fixture.service.saveAgent({ + name: "Backend IC", + role: "engineer", + reportsTo: manager.id, + adapterType: "codex-local", + adapterConfig: { model: "gpt-5.3-codex" }, + }); + + const edited = fixture.service.saveAgent({ + id: report.id, + name: "Backend Engineer", + role: "engineer", + reportsTo: manager.id, + adapterType: "codex-local", + adapterConfig: { model: "gpt-5.3-codex-spark" }, + capabilities: ["api", "tests"], + }); + expect(edited.name).toBe("Backend Engineer"); + expect(edited.capabilities).toEqual(["api", "tests"]); + + fixture.service.removeAgent(manager.id); + const unlinked = fixture.service.getAgent(report.id); + expect(unlinked?.reportsTo).toBeNull(); + expect(fixture.service.getAgent(manager.id)).toBeNull(); + expect(fixture.service.getAgent(manager.id, { includeDeleted: true })?.deletedAt).toBeTruthy(); + + fixture.db.close(); + }); + + it("reconstructs org tree and chain-of-command", async () => { + const fixture = await createFixture(); + const lead = fixture.service.saveAgent({ + name: "Lead", + role: "engineer", + adapterType: "claude-local", + adapterConfig: {}, + }); + const mid = fixture.service.saveAgent({ + name: "Mid", + role: "engineer", + reportsTo: lead.id, + adapterType: "claude-local", + adapterConfig: {}, + }); + const junior = fixture.service.saveAgent({ + name: "Junior", + role: "qa", + reportsTo: mid.id, + adapterType: "process", + adapterConfig: { command: "echo" }, + }); + + const tree = fixture.service.listOrgTree(); + expect(tree.length).toBe(1); + expect(tree[0]?.id).toBe(lead.id); + expect(tree[0]?.reports[0]?.id).toBe(mid.id); + expect(tree[0]?.reports[0]?.reports[0]?.id).toBe(junior.id); + + const chain = fixture.service.getChainOfCommand(junior.id); + expect(chain.map((entry) => entry.id)).toEqual([junior.id, mid.id, lead.id]); + + fixture.db.close(); + }); + + it("blocks cycle creation and 50-hop overflow", async () => { + const fixture = await createFixture(); + const a = fixture.service.saveAgent({ + name: "A", + role: "engineer", + adapterType: "claude-local", + adapterConfig: {}, + }); + const b = fixture.service.saveAgent({ + name: "B", + role: "engineer", + reportsTo: a.id, + adapterType: "claude-local", + adapterConfig: {}, + }); + + expect(() => + fixture.service.saveAgent({ + id: a.id, + name: "A", + role: "engineer", + reportsTo: b.id, + adapterType: "claude-local", + adapterConfig: {}, + }) + ).toThrow(/cycle/i); + + let parentId = b.id; + for (let i = 0; i < 49; i += 1) { + const node = fixture.service.saveAgent({ + name: `worker-${i}`, + role: "general", + reportsTo: parentId, + adapterType: "process", + adapterConfig: { command: "echo" }, + }); + parentId = node.id; + } + + expect(() => + fixture.service.saveAgent({ + name: "overflow-node", + role: "general", + reportsTo: parentId, + adapterType: "process", + adapterConfig: { command: "echo" }, + }) + ).toThrow(/50 hops/i); + + fixture.db.close(); + }); + + it("rejects raw secret-like adapter config values", async () => { + const fixture = await createFixture(); + expect(() => + fixture.service.saveAgent({ + name: "Remote", + role: "researcher", + adapterType: "openclaw-webhook", + adapterConfig: { + url: "https://example.com/hook", + headers: { + Authorization: "Bearer sk-secret-value", + }, + }, + }) + ).toThrow(/raw secret-like value/i); + + const ok = fixture.service.saveAgent({ + name: "Remote 2", + role: "researcher", + adapterType: "openclaw-webhook", + adapterConfig: { + url: "https://example.com/hook", + headers: { + Authorization: "Bearer ${env:OPENCLAW_WEBHOOK_TOKEN}", + }, + }, + }); + expect(ok.id).toBeTruthy(); + + fixture.db.close(); + }); + + it("normalizes legacy full_mcp worker session logs as full tooling", async () => { + const fixture = await createFixture(); + const worker = fixture.service.saveAgent({ + name: "Legacy Worker", + role: "engineer", + adapterType: "codex-local", + adapterConfig: {}, + }); + const sessionsPath = path.join(fixture.adeDir, "agents", worker.slug, "sessions.jsonl"); + fs.writeFileSync( + sessionsPath, + `${JSON.stringify({ + sessionId: "legacy-session", + summary: "Legacy worker session", + startedAt: "2026-03-05T10:00:00.000Z", + endedAt: "2026-03-05T10:05:00.000Z", + provider: "codex", + modelId: "openai/gpt-5.3-codex", + capabilityMode: "full_mcp", + createdAt: "2026-03-05T10:06:00.000Z", + })}\n`, + "utf8" + ); + + expect(fixture.service.listSessionLogs(worker.id, 10)[0]?.capabilityMode).toBe("full_tooling"); + + fixture.db.close(); + }); + }); + +}); + +describe("workerBudgetService (file group)", () => { + + function createLogger() { + return { + debug: () => {}, + info: () => {}, + warn: () => {}, + error: () => {}, + } as any; + } + + async function createFixture(config?: { companyBudgetMonthlyCents?: number }) { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "ade-worker-budget-")); + const adeDir = path.join(root, ".ade"); + fs.mkdirSync(adeDir, { recursive: true }); + const dbPath = path.join(adeDir, "ade.db"); + const db = await openKvDb(dbPath, createLogger()); + const projectId = "project-test"; + const workerAgentService = createWorkerAgentService({ + db, + projectId, + adeDir, + }); + const projectConfigService = { + get: () => ({ + effective: { + cto: { + companyBudgetMonthlyCents: config?.companyBudgetMonthlyCents ?? 0, + budgetTelemetry: { + enabled: false, + }, + }, + }, + }), + } as any; + const workerBudgetService = createWorkerBudgetService({ + db, + projectId, + workerAgentService, + projectConfigService, + }); + return { db, workerAgentService, workerBudgetService }; + } + + describe("workerBudgetService", () => { + it("records cost events and combines exact+estimated spend", async () => { + const fixture = await createFixture(); + const worker = fixture.workerAgentService.saveAgent({ + name: "Budget Worker", + role: "engineer", + adapterType: "codex-local", + adapterConfig: { model: "gpt-5.3-codex" }, + budgetMonthlyCents: 10_000, + }); + + fixture.workerBudgetService.recordCostEvent({ + agentId: worker.id, + provider: "openai", + modelId: "gpt-5.3-codex", + costCents: 120, + estimated: false, + source: "api", + }); + fixture.workerBudgetService.recordCostEvent({ + agentId: worker.id, + provider: "codex-local", + modelId: "gpt-5.3-codex", + costCents: 80, + estimated: true, + source: "cli", + }); + + const snapshot = fixture.workerBudgetService.getBudgetSnapshot(); + const row = snapshot.workers.find((entry) => entry.agentId === worker.id); + expect(row?.exactSpentCents).toBe(120); + expect(row?.estimatedSpentCents).toBe(80); + expect(row?.spentMonthlyCents).toBe(200); + expect(snapshot.companySpentMonthlyCents).toBe(200); + expect(snapshot.companyExactSpentCents).toBe(120); + expect(snapshot.companyEstimatedSpentCents).toBe(80); + + fixture.db.close(); + }); + + it("auto-pauses worker when per-worker cap is breached", async () => { + const fixture = await createFixture(); + const worker = fixture.workerAgentService.saveAgent({ + name: "Capped Worker", + role: "qa", + adapterType: "process", + adapterConfig: { command: "echo" }, + budgetMonthlyCents: 150, + }); + + fixture.workerBudgetService.recordCostEvent({ + agentId: worker.id, + provider: "manual", + costCents: 151, + estimated: false, + source: "manual", + }); + const updated = fixture.workerAgentService.getAgent(worker.id); + expect(updated?.status).toBe("paused"); + + fixture.db.close(); + }); + + it("auto-pauses workers when company cap is breached", async () => { + const fixture = await createFixture({ companyBudgetMonthlyCents: 200 }); + const workerA = fixture.workerAgentService.saveAgent({ + name: "A", + role: "engineer", + adapterType: "process", + adapterConfig: { command: "echo" }, + budgetMonthlyCents: 0, + }); + const workerB = fixture.workerAgentService.saveAgent({ + name: "B", + role: "engineer", + adapterType: "process", + adapterConfig: { command: "echo" }, + budgetMonthlyCents: 0, + }); + + fixture.workerBudgetService.recordCostEvent({ + agentId: workerA.id, + provider: "manual", + costCents: 120, + estimated: false, + source: "manual", + }); + fixture.workerBudgetService.recordCostEvent({ + agentId: workerB.id, + provider: "manual", + costCents: 120, + estimated: false, + source: "manual", + }); + + const stateA = fixture.workerAgentService.getAgent(workerA.id); + const stateB = fixture.workerAgentService.getAgent(workerB.id); + expect(stateA?.status).toBe("paused"); + expect(stateB?.status).toBe("paused"); + + fixture.db.close(); + }); + + it("respects monthly boundaries for spend accumulation", async () => { + const fixture = await createFixture(); + const worker = fixture.workerAgentService.saveAgent({ + name: "Month Worker", + role: "general", + adapterType: "process", + adapterConfig: { command: "echo" }, + budgetMonthlyCents: 0, + }); + + fixture.workerBudgetService.recordCostEvent({ + agentId: worker.id, + provider: "manual", + costCents: 50, + estimated: false, + source: "manual", + occurredAt: "2026-01-31T23:59:00.000Z", + }); + fixture.workerBudgetService.recordCostEvent({ + agentId: worker.id, + provider: "manual", + costCents: 75, + estimated: false, + source: "manual", + occurredAt: "2026-02-01T00:00:00.000Z", + }); + + const jan = fixture.workerBudgetService.getBudgetSnapshot({ monthKey: "2026-01" }); + const feb = fixture.workerBudgetService.getBudgetSnapshot({ monthKey: "2026-02" }); + expect(jan.workers.find((entry) => entry.agentId === worker.id)?.spentMonthlyCents).toBe(50); + expect(feb.workers.find((entry) => entry.agentId === worker.id)?.spentMonthlyCents).toBe(75); + + fixture.db.close(); + }); + }); + +}); + +describe("workerRevisionService (file group)", () => { + + function createLogger() { + return { + debug: () => {}, + info: () => {}, + warn: () => {}, + error: () => {}, + } as any; + } + + async function createFixture() { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "ade-worker-revisions-")); + const adeDir = path.join(root, ".ade"); + fs.mkdirSync(adeDir, { recursive: true }); + const dbPath = path.join(adeDir, "ade.db"); + const db = await openKvDb(dbPath, createLogger()); + const projectId = "project-test"; + const workerAgentService = createWorkerAgentService({ + db, + projectId, + adeDir, + }); + const workerRevisionService = createWorkerRevisionService({ + db, + projectId, + workerAgentService, + }); + return { db, projectId, workerAgentService, workerRevisionService }; + } + + describe("workerRevisionService", () => { + it("records revisions on create and update with changed-key detection", async () => { + const fixture = await createFixture(); + const created = fixture.workerRevisionService.saveAgent( + { + name: "Worker A", + role: "engineer", + adapterType: "claude-local", + adapterConfig: { model: "sonnet" }, + }, + "tester" + ); + fixture.workerRevisionService.saveAgent( + { + id: created.id, + name: "Worker Alpha", + role: "engineer", + adapterType: "claude-local", + adapterConfig: { model: "opus" }, + capabilities: ["api"], + }, + "tester" + ); + + const revisions = fixture.workerRevisionService.listAgentRevisions(created.id, 10); + expect(revisions.length).toBeGreaterThanOrEqual(2); + expect(revisions.some((revision) => revision.changedKeys.some((key) => key.includes("name")))).toBe(true); + expect(revisions.some((revision) => revision.changedKeys.some((key) => key.includes("adapterConfig.model")))).toBe(true); + + fixture.db.close(); + }); + + it("rolls back to selected revision snapshot", async () => { + const fixture = await createFixture(); + const created = fixture.workerRevisionService.saveAgent( + { + name: "Rollback Worker", + role: "engineer", + adapterType: "claude-local", + adapterConfig: { model: "sonnet" }, + }, + "tester" + ); + + fixture.workerRevisionService.saveAgent( + { + id: created.id, + name: "Rollback Worker v2", + role: "engineer", + adapterType: "codex-local", + adapterConfig: { model: "gpt-5.3-codex" }, + }, + "tester" + ); + const revisions = fixture.workerRevisionService.listAgentRevisions(created.id, 10); + const revisionToRollback = revisions.find((entry) => entry.before.name === "Rollback Worker"); + expect(revisionToRollback).toBeTruthy(); + + const restored = fixture.workerRevisionService.rollbackAgentRevision( + created.id, + revisionToRollback!.id, + "tester" + ); + expect(restored.name).toBe("Rollback Worker"); + expect(restored.adapterType).toBe("claude-local"); + + fixture.db.close(); + }); + + it("blocks rollback when revision has redactions", async () => { + const fixture = await createFixture(); + const created = fixture.workerRevisionService.saveAgent( + { + name: "Redacted Worker", + role: "researcher", + adapterType: "openclaw-webhook", + adapterConfig: { + url: "https://example.com", + headers: { Authorization: "${env:OPENCLAW_WEBHOOK_TOKEN}" }, + }, + }, + "tester" + ); + + const redactedRevisionId = "rev-redacted"; + fixture.db.run( + ` + insert into worker_agent_revisions( + id, project_id, agent_id, before_json, after_json, changed_keys_json, had_redactions, actor, created_at + ) values(?, ?, ?, ?, ?, ?, ?, ?, ?) + `, + [ + redactedRevisionId, + fixture.projectId, + created.id, + JSON.stringify({ ...created, name: "__REDACTED__" }), + JSON.stringify(created), + JSON.stringify(["adapterConfig.headers.Authorization"]), + 1, + "tester", + new Date().toISOString(), + ] + ); + + expect(() => + fixture.workerRevisionService.rollbackAgentRevision(created.id, redactedRevisionId, "tester") + ).toThrow(/redacted/i); + + fixture.db.close(); + }); + }); + +}); + +describe("workerTaskSessionService (file group)", () => { + + function createLogger() { + return { + debug: () => {}, + info: () => {}, + warn: () => {}, + error: () => {}, + } as any; + } + + async function createFixture() { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "ade-worker-task-sessions-")); + const adeDir = path.join(root, ".ade"); + fs.mkdirSync(adeDir, { recursive: true }); + const dbPath = path.join(adeDir, "ade.db"); + const db = await openKvDb(dbPath, createLogger()); + const projectId = "project-test"; + const service = createWorkerTaskSessionService({ + db, + projectId, + }); + return { db, projectId, service }; + } + + describe("workerTaskSessionService", () => { + it("derives deterministic task keys", async () => { + const fixture = await createFixture(); + const keyA = fixture.service.deriveTaskKey({ + agentId: "a1", + laneId: "lane-x", + missionId: "mission-1", + summary: "fix checkout bug", + }); + const keyB = fixture.service.deriveTaskKey({ + agentId: "a1", + laneId: "lane-x", + missionId: "mission-1", + summary: "fix checkout bug", + }); + expect(keyA).toBe(keyB); + expect(keyA.startsWith("task:")).toBe(true); + + const scopedKey = fixture.service.deriveTaskKey({ + agentId: "a1", + laneId: "lane-x", + workflowRunId: "linear-run-1", + linearIssueId: "issue-1", + summary: "fix checkout bug", + }); + expect(scopedKey).not.toBe(keyA); + fixture.db.close(); + }); + + it("persists and merges task session continuity by (agentId, adapterType, taskKey)", async () => { + const fixture = await createFixture(); + const taskKey = fixture.service.deriveTaskKey({ + agentId: "worker-1", + chatSessionId: "chat-123", + summary: "investigate flaky test", + }); + const created = fixture.service.ensureTaskSession({ + agentId: "worker-1", + adapterType: "codex-local", + taskKey, + payload: { + continuity: { + handle: { + surface: "codex_app_server", + sessionId: "chat-123", + threadId: "thread-1", + }, + }, + }, + }); + expect(created.taskKey).toBe(taskKey); + + const resumed = fixture.service.getTaskSession("worker-1", "codex-local", taskKey); + expect(resumed?.payload).toEqual({ + continuity: { + handle: { + surface: "codex_app_server", + sessionId: "chat-123", + threadId: "thread-1", + }, + }, + }); + + fixture.service.ensureTaskSession({ + agentId: "worker-1", + adapterType: "codex-local", + taskKey, + payload: { + continuity: { + handle: { + surface: "codex_app_server", + threadId: "thread-2", + }, + scope: { + runId: "linear-run-2", + }, + }, + wake: { + lastRunId: "wake-2", + }, + }, + }); + const updated = fixture.service.getTaskSession("worker-1", "codex-local", taskKey); + expect(updated?.payload).toEqual({ + continuity: { + handle: { + surface: "codex_app_server", + sessionId: "chat-123", + threadId: "thread-2", + }, + scope: { + runId: "linear-run-2", + }, + }, + wake: { + lastRunId: "wake-2", + }, + }); + + fixture.db.close(); + }); + + it("clears task sessions with targeted and bulk behavior", async () => { + const fixture = await createFixture(); + const keyOne = fixture.service.deriveTaskKey({ agentId: "worker-1", summary: "one" }); + const keyTwo = fixture.service.deriveTaskKey({ agentId: "worker-1", summary: "two" }); + fixture.service.ensureTaskSession({ + agentId: "worker-1", + adapterType: "process", + taskKey: keyOne, + payload: { run: 1 }, + }); + fixture.service.ensureTaskSession({ + agentId: "worker-1", + adapterType: "process", + taskKey: keyTwo, + payload: { run: 2 }, + }); + + const clearedOne = fixture.service.clearAgentTaskSession({ + agentId: "worker-1", + adapterType: "process", + taskKey: keyOne, + }); + expect(clearedOne).toBe(1); + expect(fixture.service.getTaskSession("worker-1", "process", keyOne)?.payload).toEqual({}); + + const clearedAll = fixture.service.clearAgentTaskSession({ + agentId: "worker-1", + adapterType: "process", + }); + expect(clearedAll).toBeGreaterThanOrEqual(1); + expect(fixture.service.getTaskSession("worker-1", "process", keyTwo)?.payload).toEqual({}); + + fixture.db.close(); + }); + }); + +}); + +describe("openclawBridgeService (file group)", () => { + + function writeOpenclawConfig(adeDir: string, patch: Record): void { + fs.mkdirSync(adeDir, { recursive: true }); + fs.writeFileSync( + path.join(adeDir, "local.secret.yaml"), + YAML.stringify({ + openclaw: { + bridgePort: 0, + hooksToken: "test-hook-token", + ...patch, + }, + }), + "utf8", + ); + } + + describe("openclawBridgeService", () => { + const services: Array> = []; + + afterEach(async () => { + while (services.length) { + const service = services.pop(); + await service?.stop(); + } + }); + + it("handles synchronous query replies end to end", async () => { + const adeDir = fs.mkdtempSync(path.join(os.tmpdir(), "ade-openclaw-query-")); + writeOpenclawConfig(adeDir, { enabled: false }); + + let service!: ReturnType; + const sentMessages: Array<{ sessionId: string; text: string; displayText?: string }> = []; + const agentChatService = { + listSessions: vi.fn(async () => []), + ensureIdentitySession: vi.fn(async () => ({ id: "session-cto", laneId: "lane-1" })), + sendMessage: vi.fn(async ({ sessionId, text, displayText }: { sessionId: string; text: string; displayText?: string }) => { + sentMessages.push({ sessionId, text, displayText }); + const turnId = "turn-1"; + queueMicrotask(() => { + service.onAgentChatEvent({ + sessionId, + timestamp: new Date().toISOString(), + event: { type: "user_message", text: displayText ?? text, turnId }, + }); + service.onAgentChatEvent({ + sessionId, + timestamp: new Date().toISOString(), + event: { type: "text", text: "CTO reply from ADE", turnId }, + }); + service.onAgentChatEvent({ + sessionId, + timestamp: new Date().toISOString(), + event: { type: "done", turnId, status: "completed" }, + }); + }); + }), + } as any; + + service = createOpenclawBridgeService({ + projectRoot: "/tmp/project", + adeDir, + laneService: { + ensurePrimaryLane: vi.fn(async () => {}), + list: vi.fn(async () => [ + { id: "lane-2", laneType: "feature" }, + { id: "lane-1", laneType: "primary" }, + ]), + } as any, + agentChatService, + ctoStateService: { + getIdentity: vi.fn(() => ({ + openclawContextPolicy: { shareMode: "filtered", blockedCategories: ["secret"] }, + })), + } as any, + }); + services.push(service); + await service.start(); + + const state = service.getState(); + const res = await fetch(state.endpoints.queryUrl!, { + method: "POST", + headers: { + "content-type": "application/json", + authorization: "Bearer test-hook-token", + }, + body: JSON.stringify({ + requestId: "req-query-1", + agentId: "discord-cto", + sessionKey: "discord:thread:123", + message: "What changed?", + context: { channel: "discord", secret: "redact-me" }, + }), + }); + + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.reply).toBe("CTO reply from ADE"); + expect(agentChatService.ensureIdentitySession).toHaveBeenCalledWith( + expect.objectContaining({ identityKey: "cto", laneId: "lane-1" }), + ); + expect(sentMessages[0]?.text).toContain("Treat this routing context as turn-scoped bridge metadata only."); + expect(sentMessages[0]?.text).toContain("What changed?"); + }); + + it("routes worker targets by slug and falls back unknown targets to CTO", async () => { + const adeDir = fs.mkdtempSync(path.join(os.tmpdir(), "ade-openclaw-target-")); + writeOpenclawConfig(adeDir, { enabled: false, allowEmployeeTargets: true }); + + let service!: ReturnType; + const ensureIdentitySession = vi.fn(async ({ identityKey }: { identityKey: string }) => ({ + id: identityKey === "cto" ? "session-cto" : "session-worker", + laneId: "lane-1", + })); + const sendMessage = vi.fn(async ({ sessionId, text, displayText }: { sessionId: string; text: string; displayText?: string }) => { + const turnId = sessionId === "session-worker" ? "turn-worker" : "turn-cto"; + queueMicrotask(() => { + service.onAgentChatEvent({ + sessionId, + timestamp: new Date().toISOString(), + event: { type: "user_message", text: displayText ?? text, turnId }, + }); + service.onAgentChatEvent({ + sessionId, + timestamp: new Date().toISOString(), + event: { type: "text", text: sessionId === "session-worker" ? "worker reply" : "cto fallback reply", turnId }, + }); + service.onAgentChatEvent({ + sessionId, + timestamp: new Date().toISOString(), + event: { type: "done", turnId, status: "completed" }, + }); + }); + }); + + service = createOpenclawBridgeService({ + projectRoot: "/tmp/project", + adeDir, + laneService: { + ensurePrimaryLane: vi.fn(async () => {}), + list: vi.fn(async () => [{ id: "lane-1", laneType: "primary" }]), + } as any, + agentChatService: { + listSessions: vi.fn(async () => []), + ensureIdentitySession, + sendMessage, + } as any, + workerAgentService: { + listAgents: vi.fn(() => [ + { id: "worker-1", slug: "frontend", status: "active", deletedAt: null }, + ]), + } as any, + ctoStateService: { + getIdentity: vi.fn(() => ({ + openclawContextPolicy: { shareMode: "filtered", blockedCategories: [] }, + })), + } as any, + }); + services.push(service); + await service.start(); + + const state = service.getState(); + const good = await fetch(state.endpoints.queryUrl!, { + method: "POST", + headers: { + "content-type": "application/json", + authorization: "Bearer test-hook-token", + }, + body: JSON.stringify({ + requestId: "req-good-target", + message: "Ping frontend worker", + targetHint: "agent:frontend", + }), + }); + expect(good.status).toBe(200); + await expect(good.json()).resolves.toEqual(expect.objectContaining({ + accepted: true, + async: true, + status: "working", + routeTarget: "agent:frontend", + })); + expect(ensureIdentitySession).toHaveBeenCalledWith(expect.objectContaining({ identityKey: "agent:worker-1" })); + + const fallback = await fetch(state.endpoints.queryUrl!, { + method: "POST", + headers: { + "content-type": "application/json", + authorization: "Bearer test-hook-token", + }, + body: JSON.stringify({ + requestId: "req-bad-target", + message: "Ping unknown worker", + targetHint: "agent:ghost", + }), + }); + expect(fallback.status).toBe(200); + const latestInbound = service.listMessages(4).find((entry) => entry.requestId === "req-bad-target" && entry.direction === "inbound"); + expect(latestInbound?.resolvedTarget).toBe("cto"); + expect(latestInbound?.metadata).toEqual(expect.objectContaining({ + fallbackReason: expect.stringContaining("ghost"), + })); + }); + + it("deduplicates async hook requests by idempotency key", async () => { + const adeDir = fs.mkdtempSync(path.join(os.tmpdir(), "ade-openclaw-hook-")); + writeOpenclawConfig(adeDir, { enabled: false }); + + let service!: ReturnType; + const sendMessage = vi.fn(async ({ sessionId, text, displayText }: { sessionId: string; text: string; displayText?: string }) => { + queueMicrotask(() => { + service.onAgentChatEvent({ + sessionId, + timestamp: new Date().toISOString(), + event: { type: "user_message", text: displayText ?? text, turnId: "turn-hook" }, + }); + }); + }); + + service = createOpenclawBridgeService({ + projectRoot: "/tmp/project", + adeDir, + laneService: { + ensurePrimaryLane: vi.fn(async () => {}), + list: vi.fn(async () => [{ id: "lane-1", laneType: "primary" }]), + } as any, + agentChatService: { + listSessions: vi.fn(async () => []), + ensureIdentitySession: vi.fn(async () => ({ id: "session-cto", laneId: "lane-1" })), + sendMessage, + } as any, + ctoStateService: { + getIdentity: vi.fn(() => ({ + openclawContextPolicy: { shareMode: "filtered", blockedCategories: [] }, + })), + } as any, + }); + services.push(service); + await service.start(); + + const state = service.getState(); + const request = { + requestId: "dup-key-1", + message: "Fire and forget", + }; + const first = await fetch(state.endpoints.hookUrl!, { + method: "POST", + headers: { + "content-type": "application/json", + authorization: "Bearer test-hook-token", + }, + body: JSON.stringify(request), + }); + const second = await fetch(state.endpoints.hookUrl!, { + method: "POST", + headers: { + "content-type": "application/json", + authorization: "Bearer test-hook-token", + }, + body: JSON.stringify(request), + }); + + expect(first.status).toBe(202); + expect(second.status).toBe(202); + expect(sendMessage).toHaveBeenCalledTimes(1); + expect(await second.json()).toEqual(expect.objectContaining({ duplicate: true })); + }); + + it("queues outbound messages when the operator socket is unavailable", async () => { + const adeDir = fs.mkdtempSync(path.join(os.tmpdir(), "ade-openclaw-outbox-")); + writeOpenclawConfig(adeDir, { enabled: false }); + + const service = createOpenclawBridgeService({ + projectRoot: "/tmp/project", + adeDir, + laneService: { + ensurePrimaryLane: vi.fn(async () => {}), + list: vi.fn(async () => [{ id: "lane-1", laneType: "primary" }]), + } as any, + agentChatService: { + listSessions: vi.fn(async () => []), + ensureIdentitySession: vi.fn(async () => ({ id: "session-cto", laneId: "lane-1" })), + sendMessage: vi.fn(async () => {}), + } as any, + ctoStateService: { + getIdentity: vi.fn(() => ({ + openclawContextPolicy: { shareMode: "filtered", blockedCategories: ["secret"] }, + })), + } as any, + }); + services.push(service); + await service.start(); + + const record = await service.sendMessage({ + requestId: "queued-message-1", + agentId: "discord-cto", + message: "Mission finished", + context: { secret: "hide-me", lane: "lane-1" }, + }); + + expect(record.status).toBe("queued"); + expect(service.getState().status.queuedMessages).toBe(1); + expect(record.context).toEqual({ lane: "lane-1" }); + }); + + it("recursively redacts inbound bridge context before prompting and persistence", async () => { + const adeDir = fs.mkdtempSync(path.join(os.tmpdir(), "ade-openclaw-redact-")); + writeOpenclawConfig(adeDir, { enabled: false }); + + let service!: ReturnType; + const sentMessages: Array<{ text: string }> = []; + service = createOpenclawBridgeService({ + projectRoot: "/tmp/project", + adeDir, + laneService: { + ensurePrimaryLane: vi.fn(async () => {}), + list: vi.fn(async () => [{ id: "lane-1", laneType: "primary" }]), + } as any, + agentChatService: { + listSessions: vi.fn(async () => []), + ensureIdentitySession: vi.fn(async () => ({ id: "session-cto", laneId: "lane-1" })), + sendMessage: vi.fn(async ({ sessionId, text, displayText }: { sessionId: string; text: string; displayText?: string }) => { + sentMessages.push({ text }); + queueMicrotask(() => { + service.onAgentChatEvent({ + sessionId, + timestamp: new Date().toISOString(), + event: { type: "user_message", text: displayText ?? text, turnId: "turn-1" }, + }); + service.onAgentChatEvent({ + sessionId, + timestamp: new Date().toISOString(), + event: { type: "text", text: "redacted", turnId: "turn-1" }, + }); + service.onAgentChatEvent({ + sessionId, + timestamp: new Date().toISOString(), + event: { type: "done", turnId: "turn-1", status: "completed" }, + }); + }); + }), + } as any, + ctoStateService: { + getIdentity: vi.fn(() => ({ + openclawContextPolicy: { shareMode: "filtered", blockedCategories: ["secret"] }, + })), + } as any, + }); + services.push(service); + await service.start(); + + const res = await fetch(service.getState().endpoints.queryUrl!, { + method: "POST", + headers: { + "content-type": "application/json", + authorization: "Bearer test-hook-token", + }, + body: JSON.stringify({ + requestId: "req-redact-1", + message: "Review this", + context: { + nested: { + apiKey: "test-api-key-placeholder", + note: "safe", + }, + secret: "remove-me", + }, + }), + }); + + expect(res.status).toBe(200); + expect(sentMessages[0]?.text).toContain("\"apiKey\": \"[REDACTED]\""); + expect(sentMessages[0]?.text).toContain("\"note\": \"safe\""); + expect(sentMessages[0]?.text).not.toContain("remove-me"); + const inbound = service.listMessages(10).find((entry) => entry.requestId === "req-redact-1" && entry.direction === "inbound"); + expect(inbound?.context).toEqual({ + nested: { + apiKey: "[REDACTED]", + note: "safe", + }, + }); + }); + + it("keeps shareMode full while still redacting sensitive values", async () => { + const adeDir = fs.mkdtempSync(path.join(os.tmpdir(), "ade-openclaw-full-share-")); + writeOpenclawConfig(adeDir, { enabled: false }); + + const service = createOpenclawBridgeService({ + projectRoot: "/tmp/project", + adeDir, + laneService: { + ensurePrimaryLane: vi.fn(async () => {}), + list: vi.fn(async () => [{ id: "lane-1", laneType: "primary" }]), + } as any, + agentChatService: { + listSessions: vi.fn(async () => []), + ensureIdentitySession: vi.fn(async () => ({ id: "session-cto", laneId: "lane-1" })), + sendMessage: vi.fn(async () => {}), + } as any, + ctoStateService: { + getIdentity: vi.fn(() => ({ + openclawContextPolicy: { shareMode: "full", blockedCategories: ["secret"] }, + })), + } as any, + }); + services.push(service); + await service.start(); + + const record = await service.sendMessage({ + requestId: "queued-message-2", + agentId: "discord-cto", + message: "Mission finished", + context: { + secret: "Bearer very-secret-token-value", + lane: "lane-1", + }, + }); + + expect(record.context).toEqual({ + secret: "[REDACTED]", + lane: "lane-1", + }); + }); + + it("migrates legacy runtime files into cache and removes repo-visible copies", async () => { + const adeDir = fs.mkdtempSync(path.join(os.tmpdir(), "ade-openclaw-migrate-")); + writeOpenclawConfig(adeDir, { enabled: false }); + fs.mkdirSync(path.join(adeDir, "cto"), { recursive: true }); + fs.writeFileSync( + path.join(adeDir, "cto", "openclaw-history.json"), + JSON.stringify([{ + id: "legacy-1", + requestId: "legacy-request", + direction: "inbound", + mode: "hook", + status: "received", + body: "Legacy body", + summary: "Legacy summary", + context: { + apiKey: "test-api-key-placeholder", + }, + createdAt: new Date().toISOString(), + }], null, 2), + "utf8", + ); + + const service = createOpenclawBridgeService({ + projectRoot: "/tmp/project", + adeDir, + laneService: { + ensurePrimaryLane: vi.fn(async () => {}), + list: vi.fn(async () => [{ id: "lane-1", laneType: "primary" }]), + } as any, + agentChatService: { + listSessions: vi.fn(async () => []), + ensureIdentitySession: vi.fn(async () => ({ id: "session-cto", laneId: "lane-1" })), + sendMessage: vi.fn(async () => {}), + } as any, + ctoStateService: { + getIdentity: vi.fn(() => ({ + openclawContextPolicy: { shareMode: "filtered", blockedCategories: [] }, + })), + } as any, + }); + services.push(service); + await service.start(); + + expect(fs.existsSync(path.join(adeDir, "cto", "openclaw-history.json"))).toBe(false); + expect(fs.existsSync(path.join(adeDir, "cache", "openclaw", "openclaw-history.json"))).toBe(true); + expect(service.listMessages(10)[0]?.context).toEqual({ apiKey: "[REDACTED]" }); + }); + }); + +}); diff --git a/apps/desktop/src/main/services/cto/flowPolicyService.test.ts b/apps/desktop/src/main/services/cto/flowPolicyService.test.ts deleted file mode 100644 index 9c7beb1de..000000000 --- a/apps/desktop/src/main/services/cto/flowPolicyService.test.ts +++ /dev/null @@ -1,134 +0,0 @@ -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; -import { describe, expect, it } from "vitest"; -import type { LinearSyncConfig, LinearWorkflowConfig } from "../../../shared/types"; -import { openKvDb } from "../state/kvDb"; -import { createLinearWorkflowFileService } from "./linearWorkflowFileService"; -import { createFlowPolicyService } from "./flowPolicyService"; - -function createLogger() { - return { - debug: () => {}, - info: () => {}, - warn: () => {}, - error: () => {}, - } as any; -} - -async function createFixture() { - const root = fs.mkdtempSync(path.join(os.tmpdir(), "ade-flow-policy-")); - const adeDir = path.join(root, ".ade"); - fs.mkdirSync(adeDir, { recursive: true }); - const dbPath = path.join(adeDir, "ade.db"); - const db = await openKvDb(dbPath, createLogger()); - const projectId = "project-flow-policy"; - const legacyConfig: LinearSyncConfig = { - enabled: true, - projects: [{ slug: "acme-platform", defaultWorker: "backend-dev" }], - autoDispatch: { default: "auto", rules: [{ id: "rule-1", action: "auto", match: { labels: ["bug"] } }] }, - }; - const projectConfigService = { - getEffective: () => ({ linearSync: legacyConfig }), - }; - const workflowFileService = createLinearWorkflowFileService({ projectRoot: root }); - return { db, root, projectId, projectConfigService, workflowFileService }; -} - -describe("flowPolicyService", () => { - it("bootstraps from generated migration, saves repo workflows, and rolls back revisions", async () => { - const fixture = await createFixture(); - const service = createFlowPolicyService({ - db: fixture.db, - projectId: fixture.projectId, - projectConfigService: fixture.projectConfigService, - workflowFileService: fixture.workflowFileService, - }); - - const bootstrapped = service.getPolicy(); - expect(bootstrapped.workflows.length).toBeGreaterThan(0); - expect(bootstrapped.migration?.needsSave).toBe(true); - expect(bootstrapped.intake.activeStateTypes).toEqual(["backlog", "unstarted", "started"]); - expect(bootstrapped.intake.terminalStateTypes).toEqual(["completed", "canceled"]); - - const toSave: LinearWorkflowConfig = { - ...bootstrapped, - workflows: bootstrapped.workflows.map((workflow, index) => ({ - ...workflow, - priority: 200 - index, - })), - intake: { - projectSlugs: ["acme-platform"], - activeStateTypes: ["backlog", "unstarted"], - terminalStateTypes: ["completed", "canceled"], - }, - }; - - const saved = service.savePolicy(toSave, "user-a"); - expect(saved.source).toBe("repo"); - expect(saved.intake.projectSlugs).toEqual(["acme-platform"]); - expect(saved.intake.activeStateTypes).toEqual(["backlog", "unstarted"]); - expect(fs.readdirSync(path.join(fixture.root, ".ade", "workflows", "linear")).some((entry) => entry.endsWith(".yaml"))).toBe(true); - - const revisions = service.listRevisions(10); - expect(revisions.length).toBe(2); - expect(revisions[0]?.actor).toBe("user-a"); - - const bootstrapRevision = revisions.find((revision) => revision.actor === "bootstrap"); - expect(bootstrapRevision).toBeTruthy(); - const rolledBack = service.rollbackRevision(bootstrapRevision!.id, "user-b"); - expect(rolledBack.workflows[0]?.name).toBeTruthy(); - expect(service.listRevisions(10)[0]?.actor).toBe("user-b"); - - fixture.db.close(); - }); - - it("validates duplicate workflow ids", async () => { - const fixture = await createFixture(); - const service = createFlowPolicyService({ - db: fixture.db, - projectId: fixture.projectId, - projectConfigService: fixture.projectConfigService, - workflowFileService: fixture.workflowFileService, - }); - - const validation = service.validatePolicy({ - version: 1, - source: "generated", - intake: { - projectSlugs: ["acme-platform"], - activeStateTypes: ["backlog", "unstarted", "started"], - terminalStateTypes: ["completed", "canceled"], - }, - settings: { ctoLinearAssigneeName: "CTO", ctoLinearAssigneeAliases: ["cto"] }, - workflows: [ - { - id: "dup", - name: "One", - enabled: true, - priority: 100, - triggers: { assignees: ["CTO"] }, - target: { type: "mission" }, - steps: [{ id: "launch", type: "launch_target" }], - }, - { - id: "DUP", - name: "Two", - enabled: true, - priority: 90, - triggers: { assignees: ["CTO"] }, - target: { type: "review_gate" }, - steps: [{ id: "launch", type: "launch_target" }], - }, - ], - files: [], - migration: { hasLegacyConfig: false, needsSave: true }, - legacyConfig: null, - }); - - expect(validation.ok).toBe(false); - expect(validation.issues.join(" ")).toContain("Duplicate workflow id"); - - fixture.db.close(); - }); -}); diff --git a/apps/desktop/src/main/services/cto/linearAuth.test.ts b/apps/desktop/src/main/services/cto/linearAuth.test.ts new file mode 100644 index 000000000..6d7e3d446 --- /dev/null +++ b/apps/desktop/src/main/services/cto/linearAuth.test.ts @@ -0,0 +1,692 @@ +import fs from "node:fs"; +import http from "node:http"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { createLinearClient } from "./linearClient"; +import { createLinearCredentialService } from "./linearCredentialService"; +import { createLinearOAuthService } from "./linearOAuthService"; + +const safeStorageMock = vi.hoisted(() => ({ + isEncryptionAvailable: vi.fn(() => true), + encryptString: vi.fn((value: string) => Buffer.from(`enc:${value}`, "utf8")), + decryptString: vi.fn((value: Buffer) => { + const raw = value.toString("utf8"); + return raw.startsWith("enc:") ? raw.slice(4) : raw; + }), +})); + +vi.mock("electron", () => ({ + safeStorage: safeStorageMock, +})); + +function createLogger() { + return { + debug: () => {}, + info: () => {}, + warn: vi.fn(), + error: () => {}, + } as any; +} + +// ===================================================================== +// linearCredentialService +// ===================================================================== + +describe("linearCredentialService", () => { + beforeEach(() => { + safeStorageMock.isEncryptionAvailable.mockReset(); + safeStorageMock.encryptString.mockReset(); + safeStorageMock.decryptString.mockReset(); + safeStorageMock.isEncryptionAvailable.mockReturnValue(true); + safeStorageMock.encryptString.mockImplementation((value: string) => Buffer.from(`enc:${value}`, "utf8")); + safeStorageMock.decryptString.mockImplementation((value: Buffer) => { + const raw = value.toString("utf8"); + return raw.startsWith("enc:") ? raw.slice(4) : raw; + }); + }); + + it("stores token encrypted and reads it back", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "ade-linear-cred-")); + const adeDir = path.join(root, ".ade"); + const service = createLinearCredentialService({ + adeDir, + logger: createLogger(), + }); + + service.setToken("lin_api_123"); + expect(service.getToken()).toBe("lin_api_123"); + expect(service.getStatus().tokenStored).toBe(true); + + const tokenPath = path.join(adeDir, "secrets", "linear-token.v1.bin"); + expect(fs.existsSync(tokenPath)).toBe(true); + const onDisk = fs.readFileSync(tokenPath); + expect(onDisk.toString("utf8")).toMatch(/^enc:/); + }); + + it("reads ADE_LINEAR_API from env as a manual token", () => { + const previousAdeLinearApi = process.env.ADE_LINEAR_API; + const previousLinearApiKey = process.env.LINEAR_API_KEY; + const previousAdeLinearToken = process.env.ADE_LINEAR_TOKEN; + const previousLinearToken = process.env.LINEAR_TOKEN; + try { + process.env.ADE_LINEAR_API = "lin_env_123"; + delete process.env.LINEAR_API_KEY; + delete process.env.ADE_LINEAR_TOKEN; + delete process.env.LINEAR_TOKEN; + + const root = fs.mkdtempSync(path.join(os.tmpdir(), "ade-linear-env-")); + const adeDir = path.join(root, ".ade"); + const service = createLinearCredentialService({ + adeDir, + logger: createLogger(), + }); + + expect(service.getToken()).toBe("lin_env_123"); + expect(service.getStatus().authMode).toBe("manual"); + } finally { + if (previousAdeLinearApi === undefined) delete process.env.ADE_LINEAR_API; + else process.env.ADE_LINEAR_API = previousAdeLinearApi; + if (previousLinearApiKey === undefined) delete process.env.LINEAR_API_KEY; + else process.env.LINEAR_API_KEY = previousLinearApiKey; + if (previousAdeLinearToken === undefined) delete process.env.ADE_LINEAR_TOKEN; + else process.env.ADE_LINEAR_TOKEN = previousAdeLinearToken; + if (previousLinearToken === undefined) delete process.env.LINEAR_TOKEN; + else process.env.LINEAR_TOKEN = previousLinearToken; + } + }); + + it("imports token once from legacy local.secret.yaml when encrypted store is empty", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "ade-linear-legacy-")); + const adeDir = path.join(root, ".ade"); + fs.mkdirSync(adeDir, { recursive: true }); + fs.writeFileSync( + path.join(adeDir, "local.secret.yaml"), + "linear:\n token: lin_legacy_abc\n", + "utf8" + ); + + const service = createLinearCredentialService({ + adeDir, + logger: createLogger(), + }); + + expect(service.getToken()).toBe("lin_legacy_abc"); + + const sentinelPath = path.join(adeDir, "secrets", "linear-token.imported.v1"); + expect(fs.existsSync(sentinelPath)).toBe(true); + expect(fs.readFileSync(sentinelPath, "utf8")).toContain("imported"); + }); + + it("reads Linear OAuth client credentials from .ade/secrets", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "ade-linear-oauth-")); + const adeDir = path.join(root, ".ade"); + fs.mkdirSync(path.join(adeDir, "secrets"), { recursive: true }); + fs.writeFileSync( + path.join(adeDir, "secrets", "linear-oauth.v1.json"), + JSON.stringify({ clientId: "client-123", clientSecret: "secret-456" }), + "utf8" + ); + + const service = createLinearCredentialService({ + adeDir, + logger: createLogger(), + }); + + expect(service.getOAuthClientCredentials()).toEqual({ + clientId: "client-123", + clientSecret: "secret-456", + }); + expect(service.getStatus().oauthConfigured).toBe(true); + }); + + it("stores Linear OAuth client credentials without requiring a secret", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "ade-linear-oauth-store-")); + const adeDir = path.join(root, ".ade"); + const service = createLinearCredentialService({ + adeDir, + logger: createLogger(), + }); + + service.setOAuthClientCredentials({ clientId: "client-public" }); + + expect(service.getOAuthClientCredentials()).toEqual({ + clientId: "client-public", + clientSecret: null, + }); + expect(service.getStatus().oauthConfigured).toBe(true); + + const clientPath = path.join(adeDir, "secrets", "linear-oauth-client.v1.bin"); + expect(fs.existsSync(clientPath)).toBe(true); + expect(fs.readFileSync(clientPath).toString("utf8")).toMatch(/^enc:/); + }); +}); + +// ===================================================================== +// linearOAuthService +// ===================================================================== + +function createCredentialsMock(overrides?: { + clientSecret?: string | null; +}) { + return { + getOAuthClientCredentials: vi.fn(() => ({ + clientId: "test-client-id", + clientSecret: overrides?.clientSecret ?? "test-client-secret", + })), + setOAuthToken: vi.fn(), + }; +} + +/** + * HTTP GET that tolerates early server close. + * + * The OAuth service calls `server.close()` immediately after writing + * its response in error paths. Node's http client may see a socket + * hang-up before the response is fully consumed. We capture whatever + * status code was received; if none, resolve with statusCode 0 so + * tests can still assert on session state via `getSession`. + */ +function httpGet(url: string): Promise<{ statusCode: number; body: string }> { + return new Promise((resolve) => { + const parsed = new URL(url); + let resolved = false; + let statusCode = 0; + let body = ""; + + const req = http.request( + { + hostname: parsed.hostname, + port: parsed.port, + path: `${parsed.pathname}${parsed.search}`, + method: "GET", + }, + (res) => { + statusCode = res.statusCode ?? 0; + res.on("data", (chunk) => { body += chunk; }); + res.on("end", () => { + if (!resolved) { resolved = true; resolve({ statusCode, body }); } + }); + res.on("error", () => { + if (!resolved) { resolved = true; resolve({ statusCode, body }); } + }); + } + ); + req.on("error", () => { + if (!resolved) { resolved = true; resolve({ statusCode, body }); } + }); + req.setTimeout(5000, () => { + req.destroy(); + if (!resolved) { resolved = true; resolve({ statusCode: 0, body: "" }); } + }); + req.end(); + }); +} + +const activeServices: Array> = []; + +function waitMs(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +async function waitForSessionStatus( + service: ReturnType, + sessionId: string, + expectedStatus: string, + timeoutMs = 3000, +): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + const session = service.getSession(sessionId); + if (session.status === expectedStatus) return; + await waitMs(10); + } + const session = service.getSession(sessionId); + expect(session.status, `Timed out waiting for session ${sessionId} to reach status '${expectedStatus}'`).toBe(expectedStatus); +} + +afterEach(async () => { + for (const svc of activeServices) { + svc.dispose(); + } + activeServices.length = 0; + await waitMs(50); +}); + +describe("linearOAuthService", () => { + it("throws when OAuth client credentials are not configured", async () => { + const service = createLinearOAuthService({ + credentials: { + getOAuthClientCredentials: vi.fn(() => null), + setOAuthToken: vi.fn(), + } as any, + logger: createLogger(), + }); + activeServices.push(service); + + await expect(service.startSession()).rejects.toThrow("not configured"); + }); + + it("starts a session and returns a valid authUrl with required OAuth params", async () => { + const credentials = createCredentialsMock(); + const service = createLinearOAuthService({ + credentials: credentials as any, + logger: createLogger(), + }); + activeServices.push(service); + + const result = await service.startSession(); + + expect(result.sessionId).toBeTruthy(); + expect(result.sessionId.startsWith("linear-oauth-")).toBe(true); + expect(result.authUrl).toContain("linear.app/oauth/authorize"); + expect(result.authUrl).toContain("client_id=test-client-id"); + expect(result.authUrl).toContain("response_type=code"); + expect(result.authUrl).toContain("scope=read"); + expect(result.authUrl).toContain("prompt=consent"); + expect(result.redirectUri).toContain("/oauth/callback"); + + const session = service.getSession(result.sessionId); + expect(session.status).toBe("pending"); + expect(session.error).toBeNull(); + }); + + it("getSession returns expired for unknown session id", () => { + const credentials = createCredentialsMock(); + const service = createLinearOAuthService({ + credentials: credentials as any, + logger: createLogger(), + }); + activeServices.push(service); + + const session = service.getSession("nonexistent-session"); + expect(session.status).toBe("expired"); + expect(session.error).toContain("not found"); + }); + + it("exchanges authorization code for access token via the callback", async () => { + const credentials = createCredentialsMock(); + const mockFetch = vi.fn(async () => ({ + ok: true, + status: 200, + json: async () => ({ + access_token: "linear-access-token-123", + refresh_token: "linear-refresh-token-456", + expires_in: 3600, + }), + })) as any; + + const service = createLinearOAuthService({ + credentials: credentials as any, + logger: createLogger(), + fetchImpl: mockFetch, + }); + activeServices.push(service); + + const { sessionId, authUrl, redirectUri } = await service.startSession(); + + const stateParam = new URL(authUrl).searchParams.get("state")!; + expect(stateParam).toBeTruthy(); + + const callbackUrl = `${redirectUri}?code=test-code-123&state=${stateParam}`; + const response = await httpGet(callbackUrl); + + expect(response.statusCode).toBe(200); + expect(response.body).toContain("Linear connected"); + + expect(mockFetch).toHaveBeenCalledTimes(1); + const fetchCall = mockFetch.mock.calls[0]![1] as { body: string }; + expect(fetchCall.body).toContain("code=test-code-123"); + expect(fetchCall.body).toContain("client_id=test-client-id"); + expect(fetchCall.body).toContain("client_secret=test-client-secret"); + + expect(credentials.setOAuthToken).toHaveBeenCalledWith( + expect.objectContaining({ + accessToken: "linear-access-token-123", + refreshToken: "linear-refresh-token-456", + }) + ); + + const session = service.getSession(sessionId); + expect(session.status).toBe("completed"); + expect(session.error).toBeNull(); + }); + + it("handles OAuth callback with error parameter from Linear", async () => { + const credentials = createCredentialsMock(); + const service = createLinearOAuthService({ + credentials: credentials as any, + logger: createLogger(), + }); + activeServices.push(service); + + const { sessionId, authUrl, redirectUri } = await service.startSession(); + const stateParam = new URL(authUrl).searchParams.get("state")!; + + const callbackUrl = `${redirectUri}?error=access_denied&error_description=User+declined&state=${stateParam}`; + await httpGet(callbackUrl); + + await waitForSessionStatus(service, sessionId, "failed"); + const session = service.getSession(sessionId); + expect(session.error).toContain("User declined"); + }); + + it("handles OAuth callback with state mismatch", async () => { + const credentials = createCredentialsMock(); + const service = createLinearOAuthService({ + credentials: credentials as any, + logger: createLogger(), + }); + activeServices.push(service); + + const { sessionId, redirectUri } = await service.startSession(); + + const callbackUrl = `${redirectUri}?code=test-code&state=wrong-state`; + await httpGet(callbackUrl); + + await waitForSessionStatus(service, sessionId, "failed"); + const session = service.getSession(sessionId); + expect(session.error).toContain("state did not match"); + }); + + it("handles OAuth callback without authorization code", async () => { + const credentials = createCredentialsMock(); + const service = createLinearOAuthService({ + credentials: credentials as any, + logger: createLogger(), + }); + activeServices.push(service); + + const { sessionId, authUrl, redirectUri } = await service.startSession(); + const stateParam = new URL(authUrl).searchParams.get("state")!; + + const callbackUrl = `${redirectUri}?state=${stateParam}`; + await httpGet(callbackUrl); + + await waitForSessionStatus(service, sessionId, "failed"); + const session = service.getSession(sessionId); + expect(session.error).toContain("did not include an authorization code"); + }); + + it("handles token exchange failure gracefully", async () => { + const credentials = createCredentialsMock(); + const mockFetch = vi.fn(async () => ({ + ok: false, + status: 400, + json: async () => ({ + error: "invalid_grant", + error_description: "The authorization code has expired.", + }), + })) as any; + + const service = createLinearOAuthService({ + credentials: credentials as any, + logger: createLogger(), + fetchImpl: mockFetch, + }); + activeServices.push(service); + + const { sessionId, authUrl, redirectUri } = await service.startSession(); + const stateParam = new URL(authUrl).searchParams.get("state")!; + + const callbackUrl = `${redirectUri}?code=expired-code&state=${stateParam}`; + await httpGet(callbackUrl); + + await waitForSessionStatus(service, sessionId, "failed"); + const session = service.getSession(sessionId); + expect(session.error).toContain("expired"); + }); + + it("supersedes previous pending sessions when starting a new one", async () => { + const credentials = createCredentialsMock(); + const service = createLinearOAuthService({ + credentials: credentials as any, + logger: createLogger(), + }); + activeServices.push(service); + + const first = await service.startSession(); + expect(service.getSession(first.sessionId).status).toBe("pending"); + + const second = await service.startSession(); + expect(service.getSession(second.sessionId).status).toBe("pending"); + + const firstStatus = service.getSession(first.sessionId); + expect(firstStatus.status).toBe("expired"); + expect(firstStatus.error).toContain("Superseded"); + }); + + it("dispose clears all sessions and closes servers", async () => { + const credentials = createCredentialsMock(); + const service = createLinearOAuthService({ + credentials: credentials as any, + logger: createLogger(), + }); + const { sessionId } = await service.startSession(); + + service.dispose(); + + const session = service.getSession(sessionId); + expect(session.status).toBe("expired"); + }); + + it("uses PKCE flow when no client secret is provided", async () => { + const credentials = createCredentialsMock({ clientSecret: null }); + credentials.getOAuthClientCredentials.mockReturnValue({ + clientId: "public-client-id", + clientSecret: null as any, + }); + + const service = createLinearOAuthService({ + credentials: credentials as any, + logger: createLogger(), + }); + activeServices.push(service); + + const result = await service.startSession(); + const authUrl = new URL(result.authUrl); + + expect(authUrl.searchParams.get("code_challenge_method")).toBe("S256"); + expect(authUrl.searchParams.get("code_challenge")).toBeTruthy(); + expect(authUrl.searchParams.get("client_id")).toBe("public-client-id"); + }); + + it("does not use PKCE when client secret is provided", async () => { + const credentials = createCredentialsMock(); + const service = createLinearOAuthService({ + credentials: credentials as any, + logger: createLogger(), + }); + activeServices.push(service); + + const result = await service.startSession(); + const authUrl = new URL(result.authUrl); + + expect(authUrl.searchParams.get("code_challenge_method")).toBeNull(); + expect(authUrl.searchParams.get("code_challenge")).toBeNull(); + }); +}); + +// ===================================================================== +// linearClient +// ===================================================================== + +function makeIssueNode(id: string, updatedAt: string) { + return { + id, + identifier: `ABC-${id}`, + title: `Issue ${id}`, + description: "Test issue", + url: null, + priority: 2, + createdAt: "2026-03-05T00:00:00.000Z", + updatedAt, + project: { id: "proj-1", slug: "acme-platform" }, + team: { id: "team-1", key: "ACME" }, + state: { id: "state-1", name: "Todo", type: "unstarted" }, + assignee: null, + creator: { id: "user-1" }, + labels: { nodes: [{ id: "label-1", name: "bug" }] }, + children: { nodes: [] }, + }; +} + +describe("linearClient", () => { + it("paginates issues using cursors", async () => { + const fetchImpl = vi.fn(async (_url: string, init?: RequestInit) => { + const body = JSON.parse(String(init?.body ?? "{}")) as { query?: string; variables?: Record }; + if (!body.query?.includes("IssuesByProject")) { + return new Response(JSON.stringify({ data: {} }), { status: 200, headers: { "content-type": "application/json" } }); + } + + const after = body.variables?.after ?? null; + if (!after) { + return new Response( + JSON.stringify({ + data: { + issues: { + pageInfo: { hasNextPage: true, endCursor: "cursor-1" }, + nodes: [makeIssueNode("1", "2026-03-05T00:00:00.000Z")], + }, + }, + }), + { status: 200, headers: { "content-type": "application/json" } } + ); + } + return new Response( + JSON.stringify({ + data: { + issues: { + pageInfo: { hasNextPage: false, endCursor: null }, + nodes: [makeIssueNode("2", "2026-03-05T00:01:00.000Z")], + }, + }, + }), + { status: 200, headers: { "content-type": "application/json" } } + ); + }); + + const client = createLinearClient({ + credentials: { + getTokenOrThrow: () => "Bearer test-token", + getStatus: () => ({ authMode: "oauth" }), + } as any, + fetchImpl: fetchImpl as any, + logger: null, + }); + + const issues = await client.fetchCandidateIssues({ + projectSlugs: ["acme-platform"], + stateTypes: ["unstarted", "started"], + }); + + expect(issues.length).toBe(2); + expect(issues[0]?.identifier).toBe("ABC-1"); + expect(issues[1]?.identifier).toBe("ABC-2"); + expect(fetchImpl).toHaveBeenCalledTimes(2); + }); + + it("retries once on rate-limit response for viewer query", async () => { + const fetchImpl = vi + .fn() + .mockResolvedValueOnce( + new Response( + JSON.stringify({ + errors: [{ message: "Rate limit exceeded" }], + }), + { status: 429, headers: { "content-type": "application/json" } } + ) + ) + .mockResolvedValueOnce( + new Response( + JSON.stringify({ + data: { + viewer: { id: "viewer-1", name: "Alex" }, + }, + }), + { status: 200, headers: { "content-type": "application/json" } } + ) + ); + + const client = createLinearClient({ + credentials: { + getTokenOrThrow: () => "Bearer test-token", + getStatus: () => ({ authMode: "oauth" }), + } as any, + fetchImpl: fetchImpl as any, + logger: null, + }); + + const viewer = await client.getViewer(); + expect(viewer.id).toBe("viewer-1"); + expect(viewer.name).toBe("Alex"); + expect(fetchImpl).toHaveBeenCalledTimes(2); + }); + + it("lists projects with their owning team names", async () => { + const fetchImpl = vi.fn(async (_url: string, init?: RequestInit) => { + const body = JSON.parse(String(init?.body ?? "{}")) as { query?: string }; + expect(init?.headers).toMatchObject({ authorization: "lin_api_test" }); + if (!body.query?.includes("query Projects")) { + return new Response(JSON.stringify({ data: {} }), { status: 200, headers: { "content-type": "application/json" } }); + } + return new Response( + JSON.stringify({ + data: { + projects: { + nodes: [ + { + id: "project-1", + name: "App Platform", + slug: "app-platform", + teams: { + nodes: [{ name: "Platform" }], + }, + }, + ], + }, + }, + }), + { status: 200, headers: { "content-type": "application/json" } } + ); + }); + + const client = createLinearClient({ + credentials: { + getTokenOrThrow: () => "lin_api_test", + getStatus: () => ({ authMode: "manual" }), + } as any, + fetchImpl: fetchImpl as any, + logger: null, + }); + + await expect(client.listProjects()).resolves.toEqual([ + { id: "project-1", name: "App Platform", slug: "app-platform", teamName: "Platform" }, + ]); + }); + + it("strips a pasted bearer prefix from manual API keys", async () => { + const fetchImpl = vi.fn(async (_url: string, init?: RequestInit) => { + expect(init?.headers).toMatchObject({ authorization: "lin_api_test" }); + return new Response( + JSON.stringify({ + data: { + viewer: { id: "viewer-1", name: "Alex" }, + }, + }), + { status: 200, headers: { "content-type": "application/json" } } + ); + }); + + const client = createLinearClient({ + credentials: { + getTokenOrThrow: () => "Bearer lin_api_test", + getStatus: () => ({ authMode: "manual" }), + } as any, + fetchImpl: fetchImpl as any, + logger: null, + }); + + await expect(client.getViewer()).resolves.toEqual({ id: "viewer-1", name: "Alex" }); + }); +}); diff --git a/apps/desktop/src/main/services/cto/linearClient.test.ts b/apps/desktop/src/main/services/cto/linearClient.test.ts deleted file mode 100644 index 463a97805..000000000 --- a/apps/desktop/src/main/services/cto/linearClient.test.ts +++ /dev/null @@ -1,182 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; -import { createLinearClient } from "./linearClient"; - -function makeIssueNode(id: string, updatedAt: string) { - return { - id, - identifier: `ABC-${id}`, - title: `Issue ${id}`, - description: "Test issue", - url: null, - priority: 2, - createdAt: "2026-03-05T00:00:00.000Z", - updatedAt, - project: { id: "proj-1", slug: "acme-platform" }, - team: { id: "team-1", key: "ACME" }, - state: { id: "state-1", name: "Todo", type: "unstarted" }, - assignee: null, - creator: { id: "user-1" }, - labels: { nodes: [{ id: "label-1", name: "bug" }] }, - children: { nodes: [] }, - }; -} - -describe("linearClient", () => { - it("paginates issues using cursors", async () => { - const fetchImpl = vi.fn(async (_url: string, init?: RequestInit) => { - const body = JSON.parse(String(init?.body ?? "{}")) as { query?: string; variables?: Record }; - if (!body.query?.includes("IssuesByProject")) { - return new Response(JSON.stringify({ data: {} }), { status: 200, headers: { "content-type": "application/json" } }); - } - - const after = body.variables?.after ?? null; - if (!after) { - return new Response( - JSON.stringify({ - data: { - issues: { - pageInfo: { hasNextPage: true, endCursor: "cursor-1" }, - nodes: [makeIssueNode("1", "2026-03-05T00:00:00.000Z")], - }, - }, - }), - { status: 200, headers: { "content-type": "application/json" } } - ); - } - return new Response( - JSON.stringify({ - data: { - issues: { - pageInfo: { hasNextPage: false, endCursor: null }, - nodes: [makeIssueNode("2", "2026-03-05T00:01:00.000Z")], - }, - }, - }), - { status: 200, headers: { "content-type": "application/json" } } - ); - }); - - const client = createLinearClient({ - credentials: { - getTokenOrThrow: () => "Bearer test-token", - getStatus: () => ({ authMode: "oauth" }), - } as any, - fetchImpl: fetchImpl as any, - logger: null, - }); - - const issues = await client.fetchCandidateIssues({ - projectSlugs: ["acme-platform"], - stateTypes: ["unstarted", "started"], - }); - - expect(issues.length).toBe(2); - expect(issues[0]?.identifier).toBe("ABC-1"); - expect(issues[1]?.identifier).toBe("ABC-2"); - expect(fetchImpl).toHaveBeenCalledTimes(2); - }); - - it("retries once on rate-limit response for viewer query", async () => { - const fetchImpl = vi - .fn() - .mockResolvedValueOnce( - new Response( - JSON.stringify({ - errors: [{ message: "Rate limit exceeded" }], - }), - { status: 429, headers: { "content-type": "application/json" } } - ) - ) - .mockResolvedValueOnce( - new Response( - JSON.stringify({ - data: { - viewer: { id: "viewer-1", name: "Alex" }, - }, - }), - { status: 200, headers: { "content-type": "application/json" } } - ) - ); - - const client = createLinearClient({ - credentials: { - getTokenOrThrow: () => "Bearer test-token", - getStatus: () => ({ authMode: "oauth" }), - } as any, - fetchImpl: fetchImpl as any, - logger: null, - }); - - const viewer = await client.getViewer(); - expect(viewer.id).toBe("viewer-1"); - expect(viewer.name).toBe("Alex"); - expect(fetchImpl).toHaveBeenCalledTimes(2); - }); - - it("lists projects with their owning team names", async () => { - const fetchImpl = vi.fn(async (_url: string, init?: RequestInit) => { - const body = JSON.parse(String(init?.body ?? "{}")) as { query?: string }; - expect(init?.headers).toMatchObject({ authorization: "lin_api_test" }); - if (!body.query?.includes("query Projects")) { - return new Response(JSON.stringify({ data: {} }), { status: 200, headers: { "content-type": "application/json" } }); - } - return new Response( - JSON.stringify({ - data: { - projects: { - nodes: [ - { - id: "project-1", - name: "App Platform", - slug: "app-platform", - teams: { - nodes: [{ name: "Platform" }], - }, - }, - ], - }, - }, - }), - { status: 200, headers: { "content-type": "application/json" } } - ); - }); - - const client = createLinearClient({ - credentials: { - getTokenOrThrow: () => "lin_api_test", - getStatus: () => ({ authMode: "manual" }), - } as any, - fetchImpl: fetchImpl as any, - logger: null, - }); - - await expect(client.listProjects()).resolves.toEqual([ - { id: "project-1", name: "App Platform", slug: "app-platform", teamName: "Platform" }, - ]); - }); - - it("strips a pasted bearer prefix from manual API keys", async () => { - const fetchImpl = vi.fn(async (_url: string, init?: RequestInit) => { - expect(init?.headers).toMatchObject({ authorization: "lin_api_test" }); - return new Response( - JSON.stringify({ - data: { - viewer: { id: "viewer-1", name: "Alex" }, - }, - }), - { status: 200, headers: { "content-type": "application/json" } } - ); - }); - - const client = createLinearClient({ - credentials: { - getTokenOrThrow: () => "Bearer lin_api_test", - getStatus: () => ({ authMode: "manual" }), - } as any, - fetchImpl: fetchImpl as any, - logger: null, - }); - - await expect(client.getViewer()).resolves.toEqual({ id: "viewer-1", name: "Alex" }); - }); -}); diff --git a/apps/desktop/src/main/services/cto/linearCloseoutService.test.ts b/apps/desktop/src/main/services/cto/linearCloseoutService.test.ts deleted file mode 100644 index 59d1693dc..000000000 --- a/apps/desktop/src/main/services/cto/linearCloseoutService.test.ts +++ /dev/null @@ -1,260 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; -import type { - LinearWorkflowDefinition, - LinearWorkflowRun, - NormalizedLinearIssue, -} from "../../../shared/types"; -import { createLinearCloseoutService } from "./linearCloseoutService"; - -const issueFixture: NormalizedLinearIssue = { - id: "issue-1", - identifier: "ADE-12", - title: "Harden automation closeout", - description: "Proof artifacts should publish cleanly into Linear closeout.", - url: "https://linear.app/acme/issue/ADE-12", - projectId: "proj-1", - projectSlug: "acme-platform", - teamId: "team-1", - teamKey: "ACME", - stateId: "state-todo", - stateName: "Todo", - stateType: "unstarted", - priority: 2, - priorityLabel: "high", - labels: ["automation"], - assigneeId: null, - assigneeName: null, - ownerId: "owner-1", - blockerIssueIds: [], - hasOpenBlockers: false, - createdAt: "2026-03-05T00:00:00.000Z", - updatedAt: "2026-03-05T00:00:00.000Z", - raw: {}, -}; - -const workflowFixture: LinearWorkflowDefinition = { - id: "flow-1", - name: "Automation hardening", - enabled: true, - priority: 100, - triggers: { projectSlugs: ["acme-platform"] }, - target: { type: "mission" }, - steps: [], - closeout: { - successState: "done", - failureState: "blocked", - successComment: "Closeout applied.", - applyLabels: ["ade"], - artifactMode: "links", - }, -}; - -const sessionWorkflowFixture: LinearWorkflowDefinition = { - ...workflowFixture, - id: "flow-session", - name: "Session closeout", - target: { type: "employee_session" }, -}; - -const runFixture: LinearWorkflowRun = { - id: "run-1", - issueId: issueFixture.id, - identifier: issueFixture.identifier, - title: issueFixture.title, - workflowId: workflowFixture.id, - workflowName: workflowFixture.name, - workflowVersion: "2026-03-12T00:00:00.000Z", - source: "repo", - targetType: "mission", - status: "in_progress", - currentStepIndex: 0, - currentStepId: null, - executionLaneId: null, - linkedMissionId: "mission-1", - linkedSessionId: null, - linkedWorkerRunId: null, - linkedPrId: null, - reviewState: null, - supervisorIdentityKey: null, - reviewReadyReason: null, - prState: null, - prChecksStatus: null, - prReviewStatus: null, - latestReviewNote: null, - retryCount: 0, - retryAfter: null, - closeoutState: "pending", - terminalOutcome: null, - sourceIssueSnapshot: issueFixture, - lastError: null, - createdAt: "2026-03-05T00:00:00.000Z", - updatedAt: "2026-03-05T00:00:00.000Z", -}; - -describe("linearCloseoutService", () => { - it("merges mission and orchestrator proof artifacts into Linear closeout payload", async () => { - const publishMissionCloseout = vi.fn(async () => {}); - const issueTracker = { - fetchWorkflowStates: vi.fn(async () => [ - { id: "state-done", name: "Done", type: "completed" }, - { id: "state-blocked", name: "Blocked", type: "started" }, - ]), - updateIssueState: vi.fn(async () => {}), - addLabel: vi.fn(async () => {}), - createComment: vi.fn(async () => ({ commentId: "comment-1" })), - }; - - const service = createLinearCloseoutService({ - issueTracker: issueTracker as any, - outboundService: { - publishMissionCloseout, - } as any, - missionService: { - get: vi.fn(() => ({ - id: "mission-1", - artifacts: [ - { id: "art-1", artifactType: "pr", uri: "https://github.com/acme/repo/pull/42" }, - { id: "art-2", artifactType: "note", uri: "https://example.com/mission-note" }, - ], - })), - } as any, - orchestratorService: { - getArtifactsForMission: vi.fn(() => [ - { - id: "orch-1", - kind: "screenshot", - value: ".ade/artifacts/computer-use/shot.png", - metadata: {}, - }, - { - id: "orch-2", - kind: "pr", - value: "https://github.com/acme/repo/pull/43", - metadata: {}, - }, - { - id: "orch-3", - kind: "file", - value: "", - metadata: { uri: "https://example.com/browser-trace.zip" }, - }, - ]), - } as any, - prService: { - listAll: vi.fn(() => []), - getForLane: vi.fn(() => null), - } as any, - computerUseArtifactBrokerService: { - listArtifacts: vi.fn(() => []), - } as any, - }); - - await service.applyOutcome({ - run: runFixture, - workflow: workflowFixture, - issue: issueFixture, - outcome: "completed", - summary: "Validation evidence captured and closeout completed.", - }); - - expect(issueTracker.updateIssueState).toHaveBeenCalledWith(issueFixture.id, "state-done"); - expect(issueTracker.addLabel).toHaveBeenCalledWith(issueFixture.id, "ade"); - expect(issueTracker.createComment).toHaveBeenCalledWith(issueFixture.id, "Closeout applied."); - expect(publishMissionCloseout).toHaveBeenCalledWith(expect.objectContaining({ - issue: issueFixture, - missionId: "mission-1", - status: "completed", - summary: "Validation evidence captured and closeout completed.", - prLinks: [ - "https://github.com/acme/repo/pull/42", - "https://github.com/acme/repo/pull/43", - ], - artifactPaths: [ - "https://github.com/acme/repo/pull/42", - "https://example.com/mission-note", - ".ade/artifacts/computer-use/shot.png", - "https://github.com/acme/repo/pull/43", - "https://example.com/browser-trace.zip", - ], - artifactMode: "links", - commentTemplate: null, - })); - }); - - it("publishes non-mission PR links and broker artifacts to the generic Linear closeout", async () => { - const publishWorkflowCloseout = vi.fn(async () => {}); - const service = createLinearCloseoutService({ - issueTracker: { - fetchWorkflowStates: vi.fn(async () => []), - updateIssueState: vi.fn(async () => {}), - addLabel: vi.fn(async () => {}), - createComment: vi.fn(async () => ({ commentId: "comment-1" })), - } as any, - outboundService: { - publishMissionCloseout: vi.fn(async () => {}), - publishWorkflowCloseout, - } as any, - missionService: { - get: vi.fn(() => null), - } as any, - orchestratorService: { - getArtifactsForMission: vi.fn(() => []), - } as any, - prService: { - listAll: vi.fn(() => [{ id: "pr-99", githubUrl: "https://github.com/acme/repo/pull/99" }]), - getForLane: vi.fn(() => null), - } as any, - computerUseArtifactBrokerService: { - listArtifacts: vi.fn(({ owner }: { owner: { kind: string; id: string } }) => { - if (owner.kind === "chat_session") { - return [{ id: "artifact-1", kind: "browser_trace", uri: ".ade/artifacts/chat-trace.zip" }]; - } - if (owner.kind === "lane") { - return [{ id: "artifact-2", kind: "screenshot", uri: "https://example.com/lane-proof.png" }]; - } - if (owner.kind === "github_pr") { - return [{ id: "artifact-3", kind: "browser_verification", uri: "https://example.com/pr-proof.json" }]; - } - return []; - }), - } as any, - }); - - await service.applyOutcome({ - run: { - ...runFixture, - targetType: "employee_session", - linkedMissionId: null, - linkedSessionId: "session-1", - linkedPrId: "pr-99", - executionLaneId: "lane-1", - }, - workflow: sessionWorkflowFixture, - issue: issueFixture, - outcome: "completed", - summary: "Worker handoff wrapped with linked proof.", - }); - - expect(publishWorkflowCloseout).toHaveBeenCalledWith(expect.objectContaining({ - issue: issueFixture, - status: "completed", - summary: "Worker handoff wrapped with linked proof.", - targetLabel: "employee session", - targetId: "session-1", - contextLines: [ - "Workflow target: employee_session", - "Lane: lane-1", - "Session: session-1", - "Linked PR record: pr-99", - ], - prLinks: ["https://github.com/acme/repo/pull/99"], - artifactPaths: [ - ".ade/artifacts/chat-trace.zip", - "https://example.com/lane-proof.png", - "https://example.com/pr-proof.json", - ], - artifactMode: "links", - commentTemplate: null, - })); - }); -}); diff --git a/apps/desktop/src/main/services/cto/linearCredentialService.test.ts b/apps/desktop/src/main/services/cto/linearCredentialService.test.ts deleted file mode 100644 index 04d10a5a9..000000000 --- a/apps/desktop/src/main/services/cto/linearCredentialService.test.ts +++ /dev/null @@ -1,156 +0,0 @@ -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; -import { beforeEach, describe, expect, it, vi } from "vitest"; -import { createLinearCredentialService } from "./linearCredentialService"; - -const safeStorageMock = vi.hoisted(() => ({ - isEncryptionAvailable: vi.fn(() => true), - encryptString: vi.fn((value: string) => Buffer.from(`enc:${value}`, "utf8")), - decryptString: vi.fn((value: Buffer) => { - const raw = value.toString("utf8"); - return raw.startsWith("enc:") ? raw.slice(4) : raw; - }), -})); - -vi.mock("electron", () => ({ - safeStorage: safeStorageMock, -})); - -function createLogger() { - return { - debug: () => {}, - info: () => {}, - warn: () => {}, - error: () => {}, - } as any; -} - -describe("linearCredentialService", () => { - beforeEach(() => { - safeStorageMock.isEncryptionAvailable.mockReset(); - safeStorageMock.encryptString.mockReset(); - safeStorageMock.decryptString.mockReset(); - safeStorageMock.isEncryptionAvailable.mockReturnValue(true); - safeStorageMock.encryptString.mockImplementation((value: string) => Buffer.from(`enc:${value}`, "utf8")); - safeStorageMock.decryptString.mockImplementation((value: Buffer) => { - const raw = value.toString("utf8"); - return raw.startsWith("enc:") ? raw.slice(4) : raw; - }); - }); - - it("stores token encrypted and reads it back", () => { - const root = fs.mkdtempSync(path.join(os.tmpdir(), "ade-linear-cred-")); - const adeDir = path.join(root, ".ade"); - const service = createLinearCredentialService({ - adeDir, - logger: createLogger(), - }); - - service.setToken("lin_api_123"); - expect(service.getToken()).toBe("lin_api_123"); - expect(service.getStatus().tokenStored).toBe(true); - - const tokenPath = path.join(adeDir, "secrets", "linear-token.v1.bin"); - expect(fs.existsSync(tokenPath)).toBe(true); - const onDisk = fs.readFileSync(tokenPath); - expect(onDisk.toString("utf8")).toMatch(/^enc:/); - }); - - it("reads ADE_LINEAR_API from env as a manual token", () => { - const previousAdeLinearApi = process.env.ADE_LINEAR_API; - const previousLinearApiKey = process.env.LINEAR_API_KEY; - const previousAdeLinearToken = process.env.ADE_LINEAR_TOKEN; - const previousLinearToken = process.env.LINEAR_TOKEN; - try { - process.env.ADE_LINEAR_API = "lin_env_123"; - delete process.env.LINEAR_API_KEY; - delete process.env.ADE_LINEAR_TOKEN; - delete process.env.LINEAR_TOKEN; - - const root = fs.mkdtempSync(path.join(os.tmpdir(), "ade-linear-env-")); - const adeDir = path.join(root, ".ade"); - const service = createLinearCredentialService({ - adeDir, - logger: createLogger(), - }); - - expect(service.getToken()).toBe("lin_env_123"); - expect(service.getStatus().authMode).toBe("manual"); - } finally { - if (previousAdeLinearApi === undefined) delete process.env.ADE_LINEAR_API; - else process.env.ADE_LINEAR_API = previousAdeLinearApi; - if (previousLinearApiKey === undefined) delete process.env.LINEAR_API_KEY; - else process.env.LINEAR_API_KEY = previousLinearApiKey; - if (previousAdeLinearToken === undefined) delete process.env.ADE_LINEAR_TOKEN; - else process.env.ADE_LINEAR_TOKEN = previousAdeLinearToken; - if (previousLinearToken === undefined) delete process.env.LINEAR_TOKEN; - else process.env.LINEAR_TOKEN = previousLinearToken; - } - }); - - it("imports token once from legacy local.secret.yaml when encrypted store is empty", () => { - const root = fs.mkdtempSync(path.join(os.tmpdir(), "ade-linear-legacy-")); - const adeDir = path.join(root, ".ade"); - fs.mkdirSync(adeDir, { recursive: true }); - fs.writeFileSync( - path.join(adeDir, "local.secret.yaml"), - "linear:\n token: lin_legacy_abc\n", - "utf8" - ); - - const service = createLinearCredentialService({ - adeDir, - logger: createLogger(), - }); - - expect(service.getToken()).toBe("lin_legacy_abc"); - - const sentinelPath = path.join(adeDir, "secrets", "linear-token.imported.v1"); - expect(fs.existsSync(sentinelPath)).toBe(true); - expect(fs.readFileSync(sentinelPath, "utf8")).toContain("imported"); - }); - - it("reads Linear OAuth client credentials from .ade/secrets", () => { - const root = fs.mkdtempSync(path.join(os.tmpdir(), "ade-linear-oauth-")); - const adeDir = path.join(root, ".ade"); - fs.mkdirSync(path.join(adeDir, "secrets"), { recursive: true }); - fs.writeFileSync( - path.join(adeDir, "secrets", "linear-oauth.v1.json"), - JSON.stringify({ clientId: "client-123", clientSecret: "secret-456" }), - "utf8" - ); - - const service = createLinearCredentialService({ - adeDir, - logger: createLogger(), - }); - - expect(service.getOAuthClientCredentials()).toEqual({ - clientId: "client-123", - clientSecret: "secret-456", - }); - expect(service.getStatus().oauthConfigured).toBe(true); - }); - - it("stores Linear OAuth client credentials without requiring a secret", () => { - const root = fs.mkdtempSync(path.join(os.tmpdir(), "ade-linear-oauth-store-")); - const adeDir = path.join(root, ".ade"); - const service = createLinearCredentialService({ - adeDir, - logger: createLogger(), - }); - - service.setOAuthClientCredentials({ clientId: "client-public" }); - - expect(service.getOAuthClientCredentials()).toEqual({ - clientId: "client-public", - clientSecret: null, - }); - expect(service.getStatus().oauthConfigured).toBe(true); - - const clientPath = path.join(adeDir, "secrets", "linear-oauth-client.v1.bin"); - expect(fs.existsSync(clientPath)).toBe(true); - expect(fs.readFileSync(clientPath).toString("utf8")).toMatch(/^enc:/); - }); -}); diff --git a/apps/desktop/src/main/services/cto/linearDispatcherService.test.ts b/apps/desktop/src/main/services/cto/linearDispatcherService.test.ts deleted file mode 100644 index 1db378a06..000000000 --- a/apps/desktop/src/main/services/cto/linearDispatcherService.test.ts +++ /dev/null @@ -1,1778 +0,0 @@ -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; -import { pathToFileURL } from "node:url"; -import { describe, expect, it, vi } from "vitest"; -import type { LinearWorkflowConfig, LinearWorkflowMatchResult, NormalizedLinearIssue } from "../../../shared/types"; -import { openKvDb } from "../state/kvDb"; -import { createLinearDispatcherService } from "./linearDispatcherService"; -import { createLinearOutboundService } from "./linearOutboundService"; -import { createLinearCloseoutService } from "./linearCloseoutService"; - -const issueFixture: NormalizedLinearIssue = { - id: "issue-1", - identifier: "ABC-42", - title: "Fix flaky sync run", - description: "Occasional sync failure under load.", - url: "https://linear.app/acme/issue/ABC-42", - projectId: "proj-1", - projectSlug: "acme-platform", - teamId: "team-1", - teamKey: "ACME", - stateId: "state-todo", - stateName: "Todo", - stateType: "unstarted", - priority: 2, - priorityLabel: "high", - labels: ["bug"], - assigneeId: null, - assigneeName: "CTO", - ownerId: "owner-1", - creatorId: "creator-1", - creatorName: "Taylor", - blockerIssueIds: [], - hasOpenBlockers: false, - createdAt: "2026-03-05T00:00:00.000Z", - updatedAt: "2026-03-05T00:00:00.000Z", - raw: {}, -}; - -const intake = { - projectSlugs: ["acme-platform"], - activeStateTypes: ["backlog", "unstarted", "started"], - terminalStateTypes: ["completed", "canceled"], -}; - -function buildPolicy(targetType: "mission" | "review_gate" | "worker_run"): LinearWorkflowConfig { - return { - version: 1, - source: "repo", - intake, - settings: { ctoLinearAssigneeName: "CTO", ctoLinearAssigneeAliases: ["cto"] }, - workflows: [ - { - id: "flow-1", - name: "Flow 1", - enabled: true, - priority: 100, - triggers: { assignees: ["CTO"], projectSlugs: ["acme-platform"] }, - target: { type: targetType, runMode: targetType === "review_gate" ? "manual" : "autopilot", workerSelector: { mode: "slug", value: "backend-dev" } }, - steps: [ - { id: "launch", type: "launch_target", name: "Launch target" }, - ...(targetType === "review_gate" - ? [{ id: "review", type: "request_human_review", name: "Review gate" } as const] - : [{ id: "wait", type: "wait_for_target_status", name: "Wait", targetStatus: targetType === "mission" ? "completed" : "runtime_completed" } as const]), - { id: "complete", type: "complete_issue", name: "Complete issue" }, - ], - closeout: { successState: "done", failureState: "blocked", applyLabels: ["ade"], resolveOnSuccess: true, reopenOnFailure: true, artifactMode: "links" }, - }, - ], - files: [], - migration: { hasLegacyConfig: false, needsSave: false }, - legacyConfig: null, - }; -} - -function buildEmployeeSessionPolicy(): LinearWorkflowConfig { - return { - version: 1, - source: "repo", - intake, - settings: { ctoLinearAssigneeName: "CTO", ctoLinearAssigneeAliases: ["cto"] }, - workflows: [ - { - id: "employee-flow", - name: "Employee flow", - enabled: true, - priority: 100, - triggers: { assignees: ["agent-1"], labels: ["workflow:backend"] }, - target: { type: "employee_session", runMode: "assisted" }, - steps: [ - { id: "set", type: "set_linear_state", name: "Move to progress", state: "in_progress" }, - { id: "launch", type: "launch_target", name: "Launch chat" }, - { id: "wait", type: "wait_for_target_status", name: "Wait", targetStatus: "completed" }, - { id: "complete", type: "complete_issue", name: "Complete issue" }, - ], - closeout: { successState: "in_review", failureState: "blocked", applyLabels: ["ade"], resolveOnSuccess: true, reopenOnFailure: true, artifactMode: "links" }, - }, - ], - files: [], - migration: { hasLegacyConfig: false, needsSave: false }, - legacyConfig: null, - }; -} - -function buildDirectCtoSessionPolicy(overrides?: Partial): LinearWorkflowConfig { - return { - version: 1, - source: "repo", - intake, - settings: { ctoLinearAssigneeName: "CTO", ctoLinearAssigneeAliases: ["cto"] }, - workflows: [ - { - id: "cto-session-flow", - name: "CTO direct session", - enabled: true, - priority: 100, - triggers: { assignees: ["CTO"], labels: ["workflow:backend"] }, - target: { - type: "employee_session", - runMode: "assisted", - employeeIdentityKey: "cto", - sessionTemplate: "default", - laneSelection: "primary", - sessionReuse: "reuse_existing", - ...overrides, - }, - steps: [ - { id: "launch", type: "launch_target", name: "Launch chat" }, - { id: "wait", type: "wait_for_target_status", name: "Wait", targetStatus: "completed" }, - { id: "complete", type: "complete_issue", name: "Complete issue" }, - ], - closeout: { successState: "in_review", failureState: "blocked", applyLabels: ["ade"], resolveOnSuccess: true, reopenOnFailure: true, artifactMode: "links" }, - }, - ], - files: [], - migration: { hasLegacyConfig: false, needsSave: false }, - legacyConfig: null, - }; -} - -function buildSupervisedWorkerPolicy(): LinearWorkflowConfig { - return { - version: 1, - source: "repo", - intake, - settings: { ctoLinearAssigneeName: "CTO", ctoLinearAssigneeAliases: ["cto"] }, - workflows: [ - { - id: "supervised-worker", - name: "Supervised worker", - enabled: true, - priority: 140, - triggers: { assignees: ["CTO"], labels: ["workflow:backend-supervised"] }, - target: { - type: "worker_run", - runMode: "autopilot", - workerSelector: { mode: "slug", value: "backend-dev" }, - laneSelection: "fresh_issue_lane", - }, - steps: [ - { id: "launch", type: "launch_target", name: "Launch worker run" }, - { id: "wait", type: "wait_for_target_status", name: "Wait", targetStatus: "runtime_completed" }, - { - id: "review", - type: "request_human_review", - name: "Supervisor review", - reviewerIdentityKey: "cto", - rejectAction: "loop_back", - loopToStepId: "launch", - }, - { id: "complete", type: "complete_issue", name: "Complete issue" }, - ], - closeout: { successState: "in_review", failureState: "blocked", applyLabels: ["ade"], resolveOnSuccess: true, reopenOnFailure: true, artifactMode: "links" }, - }, - ], - files: [], - migration: { hasLegacyConfig: false, needsSave: false }, - legacyConfig: null, - }; -} - -function buildWorkerExplicitCompletionPolicy(): LinearWorkflowConfig { - return { - version: 1, - source: "repo", - intake, - settings: { ctoLinearAssigneeName: "CTO", ctoLinearAssigneeAliases: ["cto"] }, - workflows: [ - { - id: "worker-explicit", - name: "Worker explicit completion", - enabled: true, - priority: 120, - triggers: { assignees: ["CTO"], labels: ["workflow:explicit-complete"] }, - target: { - type: "worker_run", - runMode: "autopilot", - workerSelector: { mode: "slug", value: "backend-dev" }, - laneSelection: "primary", - }, - steps: [ - { id: "launch", type: "launch_target", name: "Launch worker run" }, - { id: "wait", type: "wait_for_target_status", name: "Wait", targetStatus: "explicit_completion" }, - { id: "complete", type: "complete_issue", name: "Complete issue" }, - ], - closeout: { successState: "in_review", failureState: "blocked", applyLabels: ["ade"], resolveOnSuccess: true, reopenOnFailure: true, artifactMode: "links" }, - }, - ], - files: [], - migration: { hasLegacyConfig: false, needsSave: false }, - legacyConfig: null, - }; -} - -function buildPrReadyPolicy(): LinearWorkflowConfig { - return { - version: 1, - source: "repo", - intake, - settings: { ctoLinearAssigneeName: "CTO", ctoLinearAssigneeAliases: ["cto"] }, - workflows: [ - { - id: "pr-ready-flow", - name: "PR ready flow", - enabled: true, - priority: 120, - triggers: { assignees: ["CTO"], labels: ["workflow:backend"] }, - target: { - type: "pr_resolution", - runMode: "autopilot", - workerSelector: { mode: "slug", value: "backend-dev" }, - prStrategy: { kind: "per-lane", draft: true }, - prTiming: "after_target_complete", - laneSelection: "primary", - }, - steps: [ - { id: "launch", type: "launch_target", name: "Launch PR flow" }, - { id: "wait-pr", type: "wait_for_pr", name: "Wait for PR" }, - { id: "complete", type: "complete_issue", name: "Complete issue" }, - ], - closeout: { successState: "in_review", failureState: "blocked", applyLabels: ["ade"], resolveOnSuccess: true, reopenOnFailure: true, artifactMode: "links", reviewReadyWhen: "pr_ready" }, - }, - ], - files: [], - migration: { hasLegacyConfig: false, needsSave: false }, - legacyConfig: null, - }; -} - -function buildPrPolicy(): LinearWorkflowConfig { - return { - version: 1, - source: "repo", - intake, - settings: { ctoLinearAssigneeName: "CTO", ctoLinearAssigneeAliases: ["cto"] }, - workflows: [ - { - id: "pr-flow", - name: "PR flow", - enabled: true, - priority: 120, - triggers: { assignees: ["CTO"], labels: ["workflow:backend"] }, - target: { type: "pr_resolution", runMode: "autopilot", workerSelector: { mode: "slug", value: "backend-dev" }, prStrategy: { kind: "per-lane", draft: true } }, - steps: [ - { id: "launch", type: "launch_target", name: "Launch PR flow" }, - { id: "wait-pr", type: "wait_for_pr", name: "Wait for PR" }, - { id: "notify", type: "emit_app_notification", name: "Notify", notifyOn: "review_ready" }, - { id: "complete", type: "complete_issue", name: "Complete issue" }, - ], - closeout: { successState: "in_review", failureState: "blocked", applyLabels: ["ade"], resolveOnSuccess: true, reopenOnFailure: true, artifactMode: "links", reviewReadyWhen: "pr_created" }, - }, - ], - files: [], - migration: { hasLegacyConfig: false, needsSave: false }, - legacyConfig: null, - }; -} - -function buildDownstreamEmployeeSessionPolicy(): LinearWorkflowConfig { - return { - version: 1, - source: "repo", - intake, - settings: { ctoLinearAssigneeName: "CTO", ctoLinearAssigneeAliases: ["cto"] }, - workflows: [ - { - id: "downstream-session-flow", - name: "Downstream session flow", - enabled: true, - priority: 125, - triggers: { assignees: ["CTO"], labels: ["workflow:downstream-session"] }, - target: { - type: "worker_run", - runMode: "autopilot", - workerSelector: { mode: "slug", value: "backend-dev" }, - laneSelection: "primary", - downstreamTarget: { - type: "employee_session", - employeeIdentityKey: "cto", - runMode: "assisted", - laneSelection: "primary", - sessionReuse: "reuse_existing", - }, - }, - steps: [ - { id: "launch", type: "launch_target", name: "Launch worker run" }, - { id: "wait", type: "wait_for_target_status", name: "Wait", targetStatus: "runtime_completed" }, - { id: "complete", type: "complete_issue", name: "Complete issue" }, - ], - closeout: { successState: "in_review", failureState: "blocked", applyLabels: ["ade"], resolveOnSuccess: true, reopenOnFailure: true, artifactMode: "links" }, - }, - ], - files: [], - migration: { hasLegacyConfig: false, needsSave: false }, - legacyConfig: null, - }; -} - -function buildDownstreamManualCompletionPolicy(): LinearWorkflowConfig { - return { - version: 1, - source: "repo", - intake, - settings: { ctoLinearAssigneeName: "CTO", ctoLinearAssigneeAliases: ["cto"] }, - workflows: [ - { - id: "downstream-manual-flow", - name: "Downstream manual flow", - enabled: true, - priority: 126, - triggers: { assignees: ["CTO"], labels: ["workflow:downstream-manual"] }, - target: { - type: "employee_session", - runMode: "assisted", - employeeIdentityKey: "cto", - laneSelection: "primary", - sessionReuse: "fresh_session", - downstreamTarget: { - type: "employee_session", - runMode: "assisted", - employeeIdentityKey: "cto", - laneSelection: "primary", - sessionReuse: "fresh_session", - }, - }, - steps: [ - { id: "launch", type: "launch_target", name: "Launch chat" }, - { id: "wait", type: "wait_for_target_status", name: "Wait" }, - { id: "complete", type: "complete_issue", name: "Complete issue" }, - ], - closeout: { successState: "in_review", failureState: "blocked", applyLabels: ["ade"], resolveOnSuccess: true, reopenOnFailure: true, artifactMode: "links" }, - }, - ], - files: [], - migration: { hasLegacyConfig: false, needsSave: false }, - legacyConfig: null, - }; -} - -function buildEmployeeToWorkerHandoffPolicy(): LinearWorkflowConfig { - return { - version: 1, - source: "repo", - intake, - settings: { ctoLinearAssigneeName: "CTO", ctoLinearAssigneeAliases: ["cto"] }, - workflows: [ - { - id: "employee-to-worker-flow", - name: "Employee to worker flow", - enabled: true, - priority: 127, - triggers: { assignees: ["CTO"], labels: ["workflow:employee-to-worker"] }, - target: { - type: "employee_session", - runMode: "assisted", - employeeIdentityKey: "cto", - laneSelection: "primary", - sessionReuse: "reuse_existing", - downstreamTarget: { - type: "worker_run", - runMode: "autopilot", - workerSelector: { mode: "slug", value: "backend-dev" }, - laneSelection: "primary", - }, - }, - steps: [ - { id: "launch", type: "launch_target", name: "Launch chat" }, - { id: "wait", type: "wait_for_target_status", name: "Wait" }, - { id: "complete", type: "complete_issue", name: "Complete issue" }, - ], - closeout: { successState: "in_review", failureState: "blocked", applyLabels: ["ade"], resolveOnSuccess: true, reopenOnFailure: true, artifactMode: "links" }, - }, - ], - files: [], - migration: { hasLegacyConfig: false, needsSave: false }, - legacyConfig: null, - }; -} -function buildInvalidDownstreamPrPolicy(): LinearWorkflowConfig { - return { - version: 1, - source: "repo", - intake, - settings: { ctoLinearAssigneeName: "CTO", ctoLinearAssigneeAliases: ["cto"] }, - workflows: [ - { - id: "invalid-downstream-pr", - name: "Invalid downstream PR", - enabled: true, - priority: 130, - triggers: { assignees: ["CTO"], labels: ["workflow:downstream-pr"] }, - target: { - type: "worker_run", - runMode: "autopilot", - workerSelector: { mode: "slug", value: "backend-dev" }, - laneSelection: "primary", - downstreamTarget: { - type: "pr_resolution", - runMode: "autopilot", - workerSelector: { mode: "slug", value: "backend-dev" }, - laneSelection: "primary", - }, - }, - steps: [ - { id: "launch", type: "launch_target", name: "Launch worker run" }, - { id: "wait", type: "wait_for_target_status", name: "Wait", targetStatus: "runtime_completed" }, - { id: "complete", type: "complete_issue", name: "Complete issue" }, - ], - closeout: { successState: "in_review", failureState: "blocked", applyLabels: ["ade"], resolveOnSuccess: true, reopenOnFailure: true, artifactMode: "links" }, - }, - ], - files: [], - migration: { hasLegacyConfig: false, needsSave: false }, - legacyConfig: null, - }; -} - -function buildMatch(policy: LinearWorkflowConfig): LinearWorkflowMatchResult { - return { - workflowId: policy.workflows[0]!.id, - workflowName: policy.workflows[0]!.name, - workflow: policy.workflows[0]!, - target: policy.workflows[0]!.target, - reason: "Matched the configured workflow.", - candidates: [{ workflowId: "flow-1", workflowName: "Flow 1", priority: 100, matched: true, reasons: ["Assignee matched CTO"], matchedSignals: ["Assignee matched CTO"] }], - nextStepsPreview: ["Launch target", "Wait", "Complete issue"], - }; -} - -function createOutboundServiceMocks() { - return { - ensureWorkpad: vi.fn(async () => ({ commentId: "comment-1" })), - updateWorkpad: vi.fn(async () => ({ commentId: "comment-1" })), - publishMissionStart: vi.fn(async () => {}), - publishMissionProgress: vi.fn(async () => {}), - publishWorkflowStatus: vi.fn(async () => {}), - publishWorkflowCloseout: vi.fn(async () => {}), - publishMissionCloseout: vi.fn(async () => {}), - } as any; -} - -describe("linearDispatcherService", () => { - it("launches a mission target and records the mission id", async () => { - const root = fs.mkdtempSync(path.join(os.tmpdir(), "ade-linear-dispatcher-")); - const db = await openKvDb(path.join(root, "ade.db"), { debug() {}, info() {}, warn() {}, error() {} } as any); - const policy = buildPolicy("mission"); - const missionCreate = vi.fn(() => ({ id: "mission-1", title: "Mission" })); - - const dispatcher = createLinearDispatcherService({ - db, - projectId: "project-1", - issueTracker: { - fetchIssueById: vi.fn(async () => issueFixture), - fetchWorkflowStates: vi.fn(async () => [{ id: "done", name: "Done", type: "completed", teamId: "team-1", teamKey: "ACME" }]), - updateIssueState: vi.fn(async () => {}), - addLabel: vi.fn(async () => {}), - createComment: vi.fn(async () => ({ commentId: "comment-1" })), - } as any, - workerAgentService: { listAgents: vi.fn(() => [{ id: "agent-1", slug: "backend-dev", capabilities: [] }]) } as any, - workerHeartbeatService: { triggerWakeup: vi.fn(), listRuns: vi.fn(() => []) } as any, - missionService: { create: missionCreate, get: vi.fn(() => ({ id: "mission-1", status: "completed", artifacts: [] })) } as any, - aiOrchestratorService: { startMissionRun: vi.fn(async () => ({ runId: "run-1" })) } as any, - agentChatService: { - ensureIdentitySession: vi.fn(async () => ({ id: "session-1" })), - sendMessage: vi.fn(async () => {}), - listSessions: vi.fn(async () => []), - } as any, - laneService: { ensurePrimaryLane: vi.fn(async () => {}), list: vi.fn(async () => [{ id: "lane-1", laneType: "primary" }]) } as any, - templateService: { renderTemplate: vi.fn(() => ({ prompt: "Fix it." })) } as any, - closeoutService: { applyOutcome: vi.fn(async () => {}) } as any, - outboundService: createOutboundServiceMocks(), - workerTaskSessionService: { - deriveTaskKey: vi.fn(() => "task-1"), - ensureTaskSession: vi.fn(() => ({ id: "task-session-1" })), - } as any, - prService: { - getForLane: vi.fn(() => null), - createFromLane: vi.fn(async () => ({ id: "pr-1", githubPrNumber: 101 })), - } as any, - }); - - const run = dispatcher.createRun(issueFixture, buildMatch(policy)); - await dispatcher.advanceRun(run.id, policy); - expect(missionCreate).toHaveBeenCalledTimes(1); - expect(dispatcher.listQueue()[0]?.missionId).toBe("mission-1"); - db.close(); - }); - - it("holds review_gate targets in escalated status", async () => { - const root = fs.mkdtempSync(path.join(os.tmpdir(), "ade-linear-dispatcher-review-")); - const db = await openKvDb(path.join(root, "ade.db"), { debug() {}, info() {}, warn() {}, error() {} } as any); - const policy = buildPolicy("review_gate"); - - const dispatcher = createLinearDispatcherService({ - db, - projectId: "project-1", - issueTracker: { - fetchIssueById: vi.fn(async () => issueFixture), - fetchWorkflowStates: vi.fn(async () => []), - updateIssueState: vi.fn(async () => {}), - addLabel: vi.fn(async () => {}), - createComment: vi.fn(async () => ({ commentId: "comment-1" })), - } as any, - workerAgentService: { listAgents: vi.fn(() => []) } as any, - workerHeartbeatService: { triggerWakeup: vi.fn(), listRuns: vi.fn(() => []) } as any, - missionService: { create: vi.fn(), get: vi.fn() } as any, - aiOrchestratorService: { startMissionRun: vi.fn() } as any, - agentChatService: { - ensureIdentitySession: vi.fn(async () => ({ id: "session-1" })), - sendMessage: vi.fn(async () => {}), - listSessions: vi.fn(async () => []), - } as any, - laneService: { ensurePrimaryLane: vi.fn(async () => {}), list: vi.fn(async () => [{ id: "lane-1", laneType: "primary" }]) } as any, - templateService: { renderTemplate: vi.fn(() => ({ prompt: "noop" })) } as any, - closeoutService: { applyOutcome: vi.fn(async () => {}) } as any, - outboundService: createOutboundServiceMocks(), - workerTaskSessionService: { - deriveTaskKey: vi.fn(() => "task-1"), - ensureTaskSession: vi.fn(() => ({ id: "task-session-1" })), - } as any, - prService: { - getForLane: vi.fn(() => null), - createFromLane: vi.fn(async () => ({ id: "pr-1", githubPrNumber: 101 })), - } as any, - }); - - const run = dispatcher.createRun(issueFixture, buildMatch(policy)); - await dispatcher.advanceRun(run.id, policy); - expect(dispatcher.listQueue()[0]?.status).toBe("escalated"); - db.close(); - }); - - it("delegates employee_session runs into the assigned employee chat", async () => { - const root = fs.mkdtempSync(path.join(os.tmpdir(), "ade-linear-dispatcher-session-")); - const db = await openKvDb(path.join(root, "ade.db"), { debug() {}, info() {}, warn() {}, error() {} } as any); - const policy = buildEmployeeSessionPolicy(); - const sendMessage = vi.fn(async () => ({ id: "message-1" })); - const ensureTaskSession = vi.fn(() => ({ id: "task-session-1" })); - const employeeIssue = { ...issueFixture, assigneeId: "user-1", assigneeName: "Alex", labels: ["workflow:backend"] }; - - const dispatcher = createLinearDispatcherService({ - db, - projectId: "project-1", - issueTracker: { - fetchIssueById: vi.fn(async () => employeeIssue), - fetchWorkflowStates: vi.fn(async () => [{ id: "state-progress", name: "In Progress", type: "started", teamId: "team-1", teamKey: "ACME" }]), - updateIssueState: vi.fn(async () => {}), - addLabel: vi.fn(async () => {}), - createComment: vi.fn(async () => ({ commentId: "comment-1" })), - } as any, - workerAgentService: { - listAgents: vi.fn(() => [{ id: "agent-1", slug: "backend-dev", name: "Backend Dev", adapterType: "claude-local", capabilities: [], linearIdentity: { userIds: ["user-1"], displayNames: ["Alex"] } }]), - getAgent: vi.fn(() => null), - } as any, - workerHeartbeatService: { triggerWakeup: vi.fn(), listRuns: vi.fn(() => []) } as any, - missionService: { create: vi.fn(), get: vi.fn() } as any, - aiOrchestratorService: { startMissionRun: vi.fn() } as any, - agentChatService: { - ensureIdentitySession: vi.fn(async () => ({ id: "session-1" })), - sendMessage, - listSessions: vi.fn(async () => [{ sessionId: "session-1", laneId: "lane-1", status: "idle" }]), - } as any, - laneService: { ensurePrimaryLane: vi.fn(async () => {}), list: vi.fn(async () => [{ id: "lane-1", laneType: "primary" }]) } as any, - templateService: { renderTemplate: vi.fn(() => ({ prompt: "Please implement the issue." })) } as any, - closeoutService: { applyOutcome: vi.fn(async () => {}) } as any, - outboundService: createOutboundServiceMocks(), - workerTaskSessionService: { - deriveTaskKey: vi.fn(() => "task-key-1"), - ensureTaskSession, - } as any, - prService: { - getForLane: vi.fn(() => null), - createFromLane: vi.fn(async () => ({ id: "pr-1", githubPrNumber: 101 })), - } as any, - }); - - const run = dispatcher.createRun({ ...issueFixture, assigneeId: "user-1", assigneeName: "Alex", labels: ["workflow:backend"] }, buildMatch(policy)); - await dispatcher.advanceRun(run.id, policy); - - expect(ensureTaskSession).toHaveBeenCalledTimes(1); - expect(sendMessage).toHaveBeenCalledWith({ - sessionId: "session-1", - text: expect.stringContaining("Start work immediately for Linear issue ABC-42."), - }); - expect(dispatcher.listQueue()[0]?.sessionId).toBe("session-1"); - db.close(); - }); - - it("keeps employee_session runs visible as queued when manual delegation is required", async () => { - const root = fs.mkdtempSync(path.join(os.tmpdir(), "ade-linear-dispatcher-awaiting-delegation-")); - const db = await openKvDb(path.join(root, "ade.db"), { debug() {}, info() {}, warn() {}, error() {} } as any); - const policy = buildEmployeeSessionPolicy(); - const employeeIssue = { ...issueFixture, assigneeId: "user-missing", assigneeName: "Missing Person", labels: ["workflow:backend"] }; - - const dispatcher = createLinearDispatcherService({ - db, - projectId: "project-1", - issueTracker: { - fetchIssueById: vi.fn(async () => employeeIssue), - fetchWorkflowStates: vi.fn(async () => []), - updateIssueState: vi.fn(async () => {}), - addLabel: vi.fn(async () => {}), - createComment: vi.fn(async () => ({ commentId: "comment-1" })), - } as any, - workerAgentService: { listAgents: vi.fn(() => []) } as any, - workerHeartbeatService: { triggerWakeup: vi.fn(), listRuns: vi.fn(() => []) } as any, - missionService: { create: vi.fn(), get: vi.fn() } as any, - aiOrchestratorService: { startMissionRun: vi.fn() } as any, - agentChatService: { - ensureIdentitySession: vi.fn(async () => ({ id: "session-1" })), - sendMessage: vi.fn(async () => {}), - listSessions: vi.fn(async () => []), - } as any, - laneService: { ensurePrimaryLane: vi.fn(async () => {}), list: vi.fn(async () => [{ id: "lane-1", laneType: "primary" }]) } as any, - templateService: { renderTemplate: vi.fn(() => ({ prompt: "Fix it." })) } as any, - closeoutService: { applyOutcome: vi.fn(async () => {}) } as any, - outboundService: createOutboundServiceMocks(), - workerTaskSessionService: { - deriveTaskKey: vi.fn(() => "task-1"), - ensureTaskSession: vi.fn(() => ({ id: "task-session-1" })), - } as any, - prService: { - getForLane: vi.fn(() => null), - createFromLane: vi.fn(async () => ({ id: "pr-1", githubPrNumber: 101 })), - } as any, - }); - - const run = dispatcher.createRun(employeeIssue, buildMatch(policy)); - await dispatcher.advanceRun(run.id, policy); - - expect(dispatcher.listQueue()[0]?.status).toBe("queued"); - const detail = await dispatcher.getRunDetail(run.id, policy); - expect(detail?.run.status).toBe("awaiting_delegation"); - db.close(); - }); - - it("rewinds launch_target when retrying a run that is awaiting delegation", async () => { - const root = fs.mkdtempSync(path.join(os.tmpdir(), "ade-linear-dispatcher-retry-awaiting-delegation-")); - const db = await openKvDb(path.join(root, "ade.db"), { debug() {}, info() {}, warn() {}, error() {} } as any); - const policy = buildEmployeeSessionPolicy(); - const employeeIssue = { - ...issueFixture, - assigneeId: "unknown-agent", - assigneeName: "Unknown Agent", - labels: ["workflow:backend"], - }; - - const dispatcher = createLinearDispatcherService({ - db, - projectId: "project-1", - issueTracker: { - fetchIssueById: vi.fn(async () => employeeIssue), - fetchWorkflowStates: vi.fn(async () => []), - updateIssueState: vi.fn(async () => {}), - addLabel: vi.fn(async () => {}), - createComment: vi.fn(async () => ({ commentId: "comment-1" })), - } as any, - workerAgentService: { listAgents: vi.fn(() => []) } as any, - workerHeartbeatService: { triggerWakeup: vi.fn(), listRuns: vi.fn(() => []) } as any, - missionService: { create: vi.fn(), get: vi.fn() } as any, - aiOrchestratorService: { startMissionRun: vi.fn() } as any, - agentChatService: { ensureIdentitySession: vi.fn(), sendMessage: vi.fn(async () => {}), listSessions: vi.fn(async () => []) } as any, - laneService: { ensurePrimaryLane: vi.fn(async () => {}), list: vi.fn(async () => [{ id: "lane-1", laneType: "primary" }]) } as any, - templateService: { renderTemplate: vi.fn(() => ({ prompt: "Please own this issue." })) } as any, - closeoutService: { applyOutcome: vi.fn(async () => {}) } as any, - outboundService: createOutboundServiceMocks(), - workerTaskSessionService: { - deriveTaskKey: vi.fn(() => "task-key-1"), - ensureTaskSession: vi.fn(() => ({ id: "task-session-1" })), - } as any, - prService: { - getForLane: vi.fn(() => null), - createFromLane: vi.fn(async () => ({ id: "pr-1", githubPrNumber: 101 })), - } as any, - }); - - const run = dispatcher.createRun(employeeIssue, buildMatch(policy)); - await dispatcher.advanceRun(run.id, policy); - - const detailBeforeRetry = await dispatcher.getRunDetail(run.id, policy); - expect(detailBeforeRetry?.steps.find((step) => step.workflowStepId === "launch")?.status).toBe("completed"); - - await dispatcher.resolveRunAction(run.id, "retry", "Try again.", policy); - - const detailAfterRetry = await dispatcher.getRunDetail(run.id, policy); - expect(detailAfterRetry?.steps.find((step) => step.workflowStepId === "launch")?.status).toBe("pending"); - db.close(); - }); - - it("resumes awaiting-delegation runs after an operator picks an override", async () => { - const root = fs.mkdtempSync(path.join(os.tmpdir(), "ade-linear-dispatcher-resume-awaiting-delegation-")); - const db = await openKvDb(path.join(root, "ade.db"), { debug() {}, info() {}, warn() {}, error() {} } as any); - const policy = buildEmployeeSessionPolicy(); - const employeeIssue = { - ...issueFixture, - assigneeId: "unknown-agent", - assigneeName: "Unknown Agent", - labels: ["workflow:backend"], - }; - const ensureIdentitySession = vi.fn(async () => ({ id: "session-override-1" })); - - const dispatcher = createLinearDispatcherService({ - db, - projectId: "project-1", - issueTracker: { - fetchIssueById: vi.fn(async () => employeeIssue), - fetchWorkflowStates: vi.fn(async () => []), - updateIssueState: vi.fn(async () => {}), - addLabel: vi.fn(async () => {}), - createComment: vi.fn(async () => ({ commentId: "comment-1" })), - } as any, - workerAgentService: { listAgents: vi.fn(() => []) } as any, - workerHeartbeatService: { triggerWakeup: vi.fn(), listRuns: vi.fn(() => []) } as any, - missionService: { create: vi.fn(), get: vi.fn() } as any, - aiOrchestratorService: { startMissionRun: vi.fn() } as any, - agentChatService: { - ensureIdentitySession, - sendMessage: vi.fn(async () => ({ id: "message-1" })), - listSessions: vi.fn(async () => []), - } as any, - laneService: { ensurePrimaryLane: vi.fn(async () => {}), list: vi.fn(async () => [{ id: "lane-1", laneType: "primary" }]) } as any, - templateService: { renderTemplate: vi.fn(() => ({ prompt: "Please own this issue." })) } as any, - closeoutService: { applyOutcome: vi.fn(async () => {}) } as any, - outboundService: createOutboundServiceMocks(), - workerTaskSessionService: { - deriveTaskKey: vi.fn(() => "task-key-1"), - ensureTaskSession: vi.fn(() => ({ id: "task-session-1" })), - } as any, - prService: { - getForLane: vi.fn(() => null), - createFromLane: vi.fn(async () => ({ id: "pr-1", githubPrNumber: 101 })), - } as any, - }); - - const run = dispatcher.createRun(employeeIssue, buildMatch(policy)); - const awaitingDelegation = await dispatcher.advanceRun(run.id, policy); - expect(awaitingDelegation?.status).toBe("awaiting_delegation"); - - const queued = await dispatcher.resolveRunAction(run.id, "resume", "Use CTO.", policy, "cto"); - expect(queued?.status).toBe("queued"); - - const resumed = await dispatcher.advanceRun(run.id, policy); - expect(resumed?.status).toBe("waiting_for_target"); - expect(ensureIdentitySession).toHaveBeenCalledWith(expect.objectContaining({ - identityKey: "cto", - laneId: "lane-1", - })); - db.close(); - }); - - it("launches a direct CTO employee session when the workflow targets CTO", async () => { - const root = fs.mkdtempSync(path.join(os.tmpdir(), "ade-linear-dispatcher-cto-session-")); - const db = await openKvDb(path.join(root, "ade.db"), { debug() {}, info() {}, warn() {}, error() {} } as any); - const policy = buildDirectCtoSessionPolicy(); - const ensureIdentitySession = vi.fn(async () => ({ id: "session-cto-1" })); - - const dispatcher = createLinearDispatcherService({ - db, - projectId: "project-1", - issueTracker: { - fetchIssueById: vi.fn(async () => issueFixture), - fetchWorkflowStates: vi.fn(async () => []), - updateIssueState: vi.fn(async () => {}), - addLabel: vi.fn(async () => {}), - createComment: vi.fn(async () => ({ commentId: "comment-1" })), - } as any, - workerAgentService: { listAgents: vi.fn(() => []) } as any, - workerHeartbeatService: { triggerWakeup: vi.fn(), listRuns: vi.fn(() => []) } as any, - missionService: { create: vi.fn(), get: vi.fn() } as any, - aiOrchestratorService: { startMissionRun: vi.fn() } as any, - agentChatService: { - ensureIdentitySession, - sendMessage: vi.fn(async () => ({ id: "message-1" })), - listSessions: vi.fn(async () => [{ sessionId: "session-cto-1", laneId: "lane-1", status: "idle" }]), - } as any, - laneService: { ensurePrimaryLane: vi.fn(async () => {}), list: vi.fn(async () => [{ id: "lane-1", laneType: "primary" }]) } as any, - templateService: { renderTemplate: vi.fn(() => ({ prompt: "Please own this issue." })) } as any, - closeoutService: { applyOutcome: vi.fn(async () => {}) } as any, - outboundService: createOutboundServiceMocks(), - workerTaskSessionService: { - deriveTaskKey: vi.fn(() => "task-key-1"), - ensureTaskSession: vi.fn(() => ({ id: "task-session-1" })), - } as any, - prService: { - getForLane: vi.fn(() => null), - createFromLane: vi.fn(async () => ({ id: "pr-1", githubPrNumber: 101 })), - } as any, - }); - - const run = dispatcher.createRun({ ...issueFixture, labels: ["workflow:backend"], assigneeName: "CTO" }, buildMatch(policy)); - await dispatcher.advanceRun(run.id, policy); - - expect(ensureIdentitySession).toHaveBeenCalledWith(expect.objectContaining({ - identityKey: "cto", - laneId: "lane-1", - })); - expect(dispatcher.listQueue()[0]?.sessionId).toBe("session-cto-1"); - db.close(); - }); - - it("resumes awaiting-lane-choice runs after an operator picks a lane", async () => { - const root = fs.mkdtempSync(path.join(os.tmpdir(), "ade-linear-dispatcher-lane-choice-")); - const db = await openKvDb(path.join(root, "ade.db"), { debug() {}, info() {}, warn() {}, error() {} } as any); - const policy = buildDirectCtoSessionPolicy({ - laneSelection: "operator_prompt", - sessionReuse: "reuse_existing", - }); - const ensureIdentitySession = vi.fn(async () => ({ id: "session-cto-2" })); - - const dispatcher = createLinearDispatcherService({ - db, - projectId: "project-1", - issueTracker: { - fetchIssueById: vi.fn(async () => issueFixture), - fetchWorkflowStates: vi.fn(async () => []), - updateIssueState: vi.fn(async () => {}), - addLabel: vi.fn(async () => {}), - createComment: vi.fn(async () => ({ commentId: "comment-1" })), - } as any, - workerAgentService: { listAgents: vi.fn(() => []) } as any, - workerHeartbeatService: { triggerWakeup: vi.fn(), listRuns: vi.fn(() => []) } as any, - missionService: { create: vi.fn(), get: vi.fn() } as any, - aiOrchestratorService: { startMissionRun: vi.fn() } as any, - agentChatService: { - ensureIdentitySession, - sendMessage: vi.fn(async () => ({ id: "message-1" })), - listSessions: vi.fn(async () => []), - } as any, - laneService: { - ensurePrimaryLane: vi.fn(async () => {}), - list: vi.fn(async () => [ - { id: "lane-1", laneType: "primary", name: "Primary" }, - { id: "lane-2", laneType: "worktree", name: "Existing lane" }, - ]), - } as any, - templateService: { renderTemplate: vi.fn(() => ({ prompt: "Please own this issue." })) } as any, - closeoutService: { applyOutcome: vi.fn(async () => {}) } as any, - outboundService: createOutboundServiceMocks(), - workerTaskSessionService: { - deriveTaskKey: vi.fn(() => "task-key-1"), - ensureTaskSession: vi.fn(() => ({ id: "task-session-1" })), - } as any, - prService: { - getForLane: vi.fn(() => null), - createFromLane: vi.fn(async () => ({ id: "pr-1", githubPrNumber: 101 })), - } as any, - }); - - const run = dispatcher.createRun({ ...issueFixture, labels: ["workflow:backend"], assigneeName: "CTO" }, buildMatch(policy)); - const awaitingLaneChoice = await dispatcher.advanceRun(run.id, policy); - - expect(awaitingLaneChoice?.status).toBe("awaiting_lane_choice"); - - const queued = await dispatcher.resolveRunAction(run.id, "resume", "Use the existing lane.", policy, undefined, "lane-2"); - expect(queued?.executionLaneId).toBe("lane-2"); - - await dispatcher.advanceRun(run.id, policy); - - expect(ensureIdentitySession).toHaveBeenCalledWith(expect.objectContaining({ - identityKey: "cto", - laneId: "lane-2", - })); - expect(dispatcher.listQueue()[0]).toEqual(expect.objectContaining({ - laneId: "lane-2", - sessionId: "session-cto-2", - })); - db.close(); - }); - - it("keeps employee-session workflows waiting when a chat runtime ends and relinks to the active identity session", async () => { - const root = fs.mkdtempSync(path.join(os.tmpdir(), "ade-linear-dispatcher-session-relink-")); - const db = await openKvDb(path.join(root, "ade.db"), { debug() {}, info() {}, warn() {}, error() {} } as any); - const policy = buildDirectCtoSessionPolicy(); - let sessions = [ - { - sessionId: "session-cto-1", - laneId: "lane-1", - identityKey: "cto", - status: "idle", - lastActivityAt: "2026-03-05T00:00:00.000Z", - }, - ]; - - const dispatcher = createLinearDispatcherService({ - db, - projectId: "project-1", - issueTracker: { - fetchIssueById: vi.fn(async () => issueFixture), - fetchWorkflowStates: vi.fn(async () => []), - updateIssueState: vi.fn(async () => {}), - addLabel: vi.fn(async () => {}), - createComment: vi.fn(async () => ({ commentId: "comment-1" })), - } as any, - workerAgentService: { listAgents: vi.fn(() => []) } as any, - workerHeartbeatService: { triggerWakeup: vi.fn(), listRuns: vi.fn(() => []) } as any, - missionService: { create: vi.fn(), get: vi.fn() } as any, - aiOrchestratorService: { startMissionRun: vi.fn() } as any, - agentChatService: { - ensureIdentitySession: vi.fn(async () => ({ id: "session-cto-1" })), - sendMessage: vi.fn(async () => ({ id: "message-1" })), - listSessions: vi.fn(async () => sessions), - } as any, - laneService: { ensurePrimaryLane: vi.fn(async () => {}), list: vi.fn(async () => [{ id: "lane-1", laneType: "primary" }]) } as any, - templateService: { renderTemplate: vi.fn(() => ({ prompt: "Please own this issue." })) } as any, - closeoutService: { applyOutcome: vi.fn(async () => {}) } as any, - outboundService: createOutboundServiceMocks(), - workerTaskSessionService: { - deriveTaskKey: vi.fn(() => "task-key-1"), - ensureTaskSession: vi.fn(() => ({ id: "task-session-1" })), - } as any, - prService: { - getForLane: vi.fn(() => null), - createFromLane: vi.fn(async () => ({ id: "pr-1", githubPrNumber: 101 })), - } as any, - }); - - const run = dispatcher.createRun({ ...issueFixture, labels: ["workflow:backend"], assigneeName: "CTO" }, buildMatch(policy)); - await dispatcher.advanceRun(run.id, policy); - - sessions = [ - { - sessionId: "session-cto-1", - laneId: "lane-1", - identityKey: "cto", - status: "ended", - lastActivityAt: "2026-03-05T00:05:00.000Z", - }, - ]; - const waiting = await dispatcher.advanceRun(run.id, policy); - expect(waiting?.status).toBe("waiting_for_target"); - - sessions = [ - { - sessionId: "session-cto-2", - laneId: "lane-1", - identityKey: "cto", - status: "idle", - lastActivityAt: "2026-03-05T00:06:00.000Z", - }, - ]; - const relinked = await dispatcher.advanceRun(run.id, policy); - expect(relinked?.status).toBe("waiting_for_target"); - expect(dispatcher.listQueue()[0]?.sessionId).toBe("session-cto-2"); - const detail = await dispatcher.getRunDetail(run.id, policy); - expect(detail?.steps.find((step) => step.workflowStepId === "wait")?.targetStatus).toBe("explicit_completion"); - db.close(); - }); - - it("creates a fresh issue lane and fresh session when configured", async () => { - const root = fs.mkdtempSync(path.join(os.tmpdir(), "ade-linear-dispatcher-fresh-lane-")); - const db = await openKvDb(path.join(root, "ade.db"), { debug() {}, info() {}, warn() {}, error() {} } as any); - const policy = buildDirectCtoSessionPolicy({ - laneSelection: "fresh_issue_lane", - sessionReuse: "fresh_session", - freshLaneName: "Backend supervised lane", - }); - const ensureIdentitySession = vi.fn(async () => ({ id: "session-fresh-1" })); - const createLane = vi.fn(async () => ({ id: "lane-2", name: "Backend supervised lane" })); - - const dispatcher = createLinearDispatcherService({ - db, - projectId: "project-1", - issueTracker: { - fetchIssueById: vi.fn(async () => issueFixture), - fetchWorkflowStates: vi.fn(async () => []), - updateIssueState: vi.fn(async () => {}), - addLabel: vi.fn(async () => {}), - createComment: vi.fn(async () => ({ commentId: "comment-1" })), - } as any, - workerAgentService: { listAgents: vi.fn(() => []) } as any, - workerHeartbeatService: { triggerWakeup: vi.fn(), listRuns: vi.fn(() => []) } as any, - missionService: { create: vi.fn(), get: vi.fn() } as any, - aiOrchestratorService: { startMissionRun: vi.fn() } as any, - agentChatService: { - ensureIdentitySession, - sendMessage: vi.fn(async () => ({ id: "message-1" })), - listSessions: vi.fn(async () => [{ sessionId: "session-fresh-1", laneId: "lane-2", status: "idle" }]), - } as any, - laneService: { - ensurePrimaryLane: vi.fn(async () => {}), - list: vi.fn(async () => [{ id: "lane-1", laneType: "primary" }]), - create: createLane, - } as any, - templateService: { renderTemplate: vi.fn(() => ({ prompt: "Please own this issue." })) } as any, - closeoutService: { applyOutcome: vi.fn(async () => {}) } as any, - outboundService: createOutboundServiceMocks(), - workerTaskSessionService: { - deriveTaskKey: vi.fn(() => "task-key-1"), - ensureTaskSession: vi.fn(() => ({ id: "task-session-1" })), - } as any, - prService: { - getForLane: vi.fn(() => null), - createFromLane: vi.fn(async () => ({ id: "pr-1", githubPrNumber: 101 })), - } as any, - }); - - const run = dispatcher.createRun({ ...issueFixture, labels: ["workflow:backend"], assigneeName: "CTO" }, buildMatch(policy)); - await dispatcher.advanceRun(run.id, policy); - - expect(createLane).toHaveBeenCalledTimes(1); - expect(ensureIdentitySession).toHaveBeenCalledWith(expect.objectContaining({ - identityKey: "cto", - laneId: "lane-2", - reuseExisting: false, - })); - expect(dispatcher.listQueue()[0]?.laneId).toBe("lane-2"); - db.close(); - }); - - it("requires an explicit ADE completion signal for worker runs when configured", async () => { - const root = fs.mkdtempSync(path.join(os.tmpdir(), "ade-linear-dispatcher-worker-explicit-")); - const db = await openKvDb(path.join(root, "ade.db"), { debug() {}, info() {}, warn() {}, error() {} } as any); - const policy = buildWorkerExplicitCompletionPolicy(); - const closeout = vi.fn(async () => {}); - - const dispatcher = createLinearDispatcherService({ - db, - projectId: "project-1", - issueTracker: { - fetchIssueById: vi.fn(async () => issueFixture), - fetchWorkflowStates: vi.fn(async () => []), - updateIssueState: vi.fn(async () => {}), - addLabel: vi.fn(async () => {}), - createComment: vi.fn(async () => ({ commentId: "comment-1" })), - } as any, - workerAgentService: { - listAgents: vi.fn(() => [{ id: "agent-1", slug: "backend-dev", adapterType: "claude-local", capabilities: [] }]), - } as any, - workerHeartbeatService: { - triggerWakeup: vi.fn(async () => ({ runId: "worker-run-1" })), - listRuns: vi.fn(() => [{ id: "worker-run-1", status: "completed" }]), - } as any, - missionService: { create: vi.fn(), get: vi.fn() } as any, - aiOrchestratorService: { startMissionRun: vi.fn() } as any, - agentChatService: { ensureIdentitySession: vi.fn(), sendMessage: vi.fn(async () => {}), listSessions: vi.fn(async () => []) } as any, - laneService: { ensurePrimaryLane: vi.fn(async () => {}), list: vi.fn(async () => [{ id: "lane-1", laneType: "primary" }]) } as any, - templateService: { renderTemplate: vi.fn(() => ({ prompt: "Implement the issue." })) } as any, - closeoutService: { applyOutcome: closeout } as any, - outboundService: createOutboundServiceMocks(), - workerTaskSessionService: { - deriveTaskKey: vi.fn(() => "task-key-1"), - ensureTaskSession: vi.fn(() => ({ id: "task-session-1" })), - } as any, - prService: { - getForLane: vi.fn(() => null), - createFromLane: vi.fn(async () => ({ id: "pr-1", githubPrNumber: 101 })), - } as any, - }); - - const run = dispatcher.createRun({ ...issueFixture, labels: ["workflow:explicit-complete"] }, buildMatch(policy)); - await dispatcher.advanceRun(run.id, policy); - const waiting = await dispatcher.advanceRun(run.id, policy); - expect(waiting?.status).toBe("waiting_for_target"); - - const detailBefore = await dispatcher.getRunDetail(run.id, policy); - expect(detailBefore?.steps.find((step) => step.workflowStepId === "wait")?.targetStatus).toBe("explicit_completion"); - - await dispatcher.resolveRunAction(run.id, "complete", "Validated via ADE closeout.", policy); - const completed = await dispatcher.advanceRun(run.id, policy); - expect(completed?.status).toBe("completed"); - expect(closeout).toHaveBeenCalledTimes(1); - db.close(); - }); - - it("scopes manual completion markers to the active downstream stage", async () => { - const root = fs.mkdtempSync(path.join(os.tmpdir(), "ade-linear-dispatcher-downstream-manual-")); - const db = await openKvDb(path.join(root, "ade.db"), { debug() {}, info() {}, warn() {}, error() {} } as any); - const policy = buildDownstreamManualCompletionPolicy(); - const ensureIdentitySession = vi - .fn() - .mockResolvedValueOnce({ id: "session-1" }) - .mockResolvedValueOnce({ id: "session-2" }); - - const dispatcher = createLinearDispatcherService({ - db, - projectId: "project-1", - issueTracker: { - fetchIssueById: vi.fn(async () => issueFixture), - fetchWorkflowStates: vi.fn(async () => []), - updateIssueState: vi.fn(async () => {}), - addLabel: vi.fn(async () => {}), - createComment: vi.fn(async () => ({ commentId: "comment-1" })), - } as any, - workerAgentService: { listAgents: vi.fn(() => []) } as any, - workerHeartbeatService: { triggerWakeup: vi.fn(), listRuns: vi.fn(() => []) } as any, - missionService: { create: vi.fn(), get: vi.fn() } as any, - aiOrchestratorService: { startMissionRun: vi.fn() } as any, - agentChatService: { - ensureIdentitySession, - sendMessage: vi.fn(async () => ({ id: "message-1" })), - listSessions: vi.fn(async () => [ - { sessionId: "session-1", laneId: "lane-1", identityKey: "cto", status: "idle", lastActivityAt: "2026-03-05T00:00:00.000Z" }, - { sessionId: "session-2", laneId: "lane-1", identityKey: "cto", status: "idle", lastActivityAt: "2026-03-05T00:01:00.000Z" }, - ]), - } as any, - laneService: { ensurePrimaryLane: vi.fn(async () => {}), list: vi.fn(async () => [{ id: "lane-1", laneType: "primary" }]) } as any, - templateService: { renderTemplate: vi.fn(() => ({ prompt: "Please own this issue." })) } as any, - closeoutService: { applyOutcome: vi.fn(async () => {}) } as any, - outboundService: createOutboundServiceMocks(), - workerTaskSessionService: { - deriveTaskKey: vi.fn(() => "task-key-1"), - ensureTaskSession: vi.fn(() => ({ id: "task-session-1" })), - } as any, - prService: { - getForLane: vi.fn(() => null), - createFromLane: vi.fn(async () => ({ id: "pr-1", githubPrNumber: 101 })), - } as any, - }); - - const run = dispatcher.createRun({ ...issueFixture, labels: ["workflow:downstream-manual"], assigneeName: "CTO" }, buildMatch(policy)); - await dispatcher.advanceRun(run.id, policy); - await dispatcher.resolveRunAction(run.id, "complete", "Stage 1 finished.", policy); - - const afterHandoff = await dispatcher.advanceRun(run.id, policy); - expect(afterHandoff?.status).toBe("waiting_for_target"); - expect(dispatcher.listQueue()[0]?.sessionId).toBe("session-2"); - - const stillWaiting = await dispatcher.advanceRun(run.id, policy); - expect(stillWaiting?.status).toBe("waiting_for_target"); - expect(dispatcher.listQueue()[0]?.sessionId).toBe("session-2"); - expect(ensureIdentitySession).toHaveBeenCalledTimes(2); - db.close(); - }); - - it("clears incompatible CTO overrides before handing off to a worker-backed downstream target", async () => { - const root = fs.mkdtempSync(path.join(os.tmpdir(), "ade-linear-dispatcher-override-handoff-")); - const db = await openKvDb(path.join(root, "ade.db"), { debug() {}, info() {}, warn() {}, error() {} } as any); - const policy = buildEmployeeToWorkerHandoffPolicy(); - const triggerWakeup = vi.fn(async () => ({ runId: "worker-run-1" })); - - const dispatcher = createLinearDispatcherService({ - db, - projectId: "project-1", - issueTracker: { - fetchIssueById: vi.fn(async () => issueFixture), - fetchWorkflowStates: vi.fn(async () => []), - updateIssueState: vi.fn(async () => {}), - addLabel: vi.fn(async () => {}), - createComment: vi.fn(async () => ({ commentId: "comment-1" })), - } as any, - workerAgentService: { - listAgents: vi.fn(() => [{ id: "agent-1", slug: "backend-dev", adapterType: "claude-local", capabilities: [] }]), - } as any, - workerHeartbeatService: { - triggerWakeup, - listRuns: vi.fn(() => []), - } as any, - missionService: { create: vi.fn(), get: vi.fn() } as any, - aiOrchestratorService: { startMissionRun: vi.fn() } as any, - agentChatService: { - ensureIdentitySession: vi.fn(async () => ({ id: "session-1" })), - sendMessage: vi.fn(async () => ({ id: "message-1" })), - listSessions: vi.fn(async () => [{ sessionId: "session-1", laneId: "lane-1", identityKey: "cto", status: "idle", lastActivityAt: "2026-03-05T00:00:00.000Z" }]), - } as any, - laneService: { ensurePrimaryLane: vi.fn(async () => {}), list: vi.fn(async () => [{ id: "lane-1", laneType: "primary" }]) } as any, - templateService: { renderTemplate: vi.fn(() => ({ prompt: "Please own this issue." })) } as any, - closeoutService: { applyOutcome: vi.fn(async () => {}) } as any, - outboundService: createOutboundServiceMocks(), - workerTaskSessionService: { - deriveTaskKey: vi.fn(() => "task-key-1"), - ensureTaskSession: vi.fn(() => ({ id: "task-session-1" })), - } as any, - prService: { - getForLane: vi.fn(() => null), - createFromLane: vi.fn(async () => ({ id: "pr-1", githubPrNumber: 101 })), - } as any, - }); - - const run = dispatcher.createRun({ ...issueFixture, labels: ["workflow:employee-to-worker"], assigneeName: "CTO" }, buildMatch(policy)); - await dispatcher.advanceRun(run.id, policy); - await dispatcher.resolveRunAction(run.id, "complete", "Hand off to a worker.", policy, "cto"); - - const handedOff = await dispatcher.advanceRun(run.id, policy); - expect(handedOff?.status).toBe("waiting_for_target"); - expect(handedOff?.linkedWorkerRunId).toBe("worker-run-1"); - expect(dispatcher.listQueue()[0]?.employeeOverride).toBeNull(); - expect(triggerWakeup).toHaveBeenCalledTimes(1); - db.close(); - }); - - it("preserves a launched session instead of scheduling a retry after a partial launch failure", async () => { - const root = fs.mkdtempSync(path.join(os.tmpdir(), "ade-linear-dispatcher-partial-launch-")); - const db = await openKvDb(path.join(root, "ade.db"), { debug() {}, info() {}, warn() {}, error() {} } as any); - const policy = buildDirectCtoSessionPolicy(); - const ensureIdentitySession = vi.fn(async () => ({ id: "session-cto-1" })); - - const dispatcher = createLinearDispatcherService({ - db, - projectId: "project-1", - issueTracker: { - fetchIssueById: vi.fn(async () => issueFixture), - fetchWorkflowStates: vi.fn(async () => []), - updateIssueState: vi.fn(async () => {}), - addLabel: vi.fn(async () => {}), - createComment: vi.fn(async () => ({ commentId: "comment-1" })), - } as any, - workerAgentService: { listAgents: vi.fn(() => []) } as any, - workerHeartbeatService: { triggerWakeup: vi.fn(), listRuns: vi.fn(() => []) } as any, - missionService: { create: vi.fn(), get: vi.fn() } as any, - aiOrchestratorService: { startMissionRun: vi.fn() } as any, - agentChatService: { - ensureIdentitySession, - sendMessage: vi.fn(async () => { - throw new Error("Chat delivery failed after session creation."); - }), - listSessions: vi.fn(async () => [{ sessionId: "session-cto-1", laneId: "lane-1", identityKey: "cto", status: "idle", lastActivityAt: "2026-03-05T00:00:00.000Z" }]), - } as any, - laneService: { ensurePrimaryLane: vi.fn(async () => {}), list: vi.fn(async () => [{ id: "lane-1", laneType: "primary" }]) } as any, - templateService: { renderTemplate: vi.fn(() => ({ prompt: "Please own this issue." })) } as any, - closeoutService: { applyOutcome: vi.fn(async () => {}) } as any, - outboundService: createOutboundServiceMocks(), - workerTaskSessionService: { - deriveTaskKey: vi.fn(() => "task-key-1"), - ensureTaskSession: vi.fn(() => ({ id: "task-session-1" })), - } as any, - prService: { - getForLane: vi.fn(() => null), - createFromLane: vi.fn(async () => ({ id: "pr-1", githubPrNumber: 101 })), - } as any, - }); - - const run = dispatcher.createRun({ ...issueFixture, labels: ["workflow:backend"], assigneeName: "CTO" }, buildMatch(policy)); - const preserved = await dispatcher.advanceRun(run.id, policy); - - expect(preserved?.status).toBe("waiting_for_target"); - expect(preserved?.linkedSessionId).toBe("session-cto-1"); - expect(dispatcher.listQueue()[0]?.status).not.toBe("retry_wait"); - - const detail = await dispatcher.getRunDetail(run.id, policy); - expect(detail?.steps.find((step) => step.workflowStepId === "launch")?.status).toBe("completed"); - - const resumed = await dispatcher.advanceRun(run.id, policy); - expect(resumed?.status).toBe("waiting_for_target"); - expect(ensureIdentitySession).toHaveBeenCalledTimes(1); - db.close(); - }); - - it("still completes delegated workflows that are configured to complete on launch", async () => { - const root = fs.mkdtempSync(path.join(os.tmpdir(), "ade-linear-dispatcher-complete-on-launch-")); - const db = await openKvDb(path.join(root, "ade.db"), { debug() {}, info() {}, warn() {}, error() {} } as any); - const policy = buildDirectCtoSessionPolicy(); - policy.workflows[0]!.steps = [ - { id: "launch", type: "launch_target", name: "Launch chat" }, - { id: "complete", type: "complete_issue", name: "Complete issue" }, - ]; - const closeout = vi.fn(async () => {}); - - const dispatcher = createLinearDispatcherService({ - db, - projectId: "project-1", - issueTracker: { - fetchIssueById: vi.fn(async () => issueFixture), - fetchWorkflowStates: vi.fn(async () => []), - updateIssueState: vi.fn(async () => {}), - addLabel: vi.fn(async () => {}), - createComment: vi.fn(async () => ({ commentId: "comment-1" })), - } as any, - workerAgentService: { listAgents: vi.fn(() => []) } as any, - workerHeartbeatService: { triggerWakeup: vi.fn(), listRuns: vi.fn(() => []) } as any, - missionService: { create: vi.fn(), get: vi.fn() } as any, - aiOrchestratorService: { startMissionRun: vi.fn() } as any, - agentChatService: { - ensureIdentitySession: vi.fn(async () => ({ id: "session-cto-1" })), - sendMessage: vi.fn(async () => ({ id: "message-1" })), - listSessions: vi.fn(async () => [{ sessionId: "session-cto-1", laneId: "lane-1", status: "idle" }]), - } as any, - laneService: { ensurePrimaryLane: vi.fn(async () => {}), list: vi.fn(async () => [{ id: "lane-1", laneType: "primary" }]) } as any, - templateService: { renderTemplate: vi.fn(() => ({ prompt: "Please own this issue." })) } as any, - closeoutService: { applyOutcome: closeout } as any, - outboundService: createOutboundServiceMocks(), - workerTaskSessionService: { - deriveTaskKey: vi.fn(() => "task-key-1"), - ensureTaskSession: vi.fn(() => ({ id: "task-session-1" })), - } as any, - prService: { - getForLane: vi.fn(() => null), - createFromLane: vi.fn(async () => ({ id: "pr-1", githubPrNumber: 101 })), - } as any, - }); - - const run = dispatcher.createRun({ ...issueFixture, labels: ["workflow:backend"], assigneeName: "CTO" }, buildMatch(policy)); - const completed = await dispatcher.advanceRun(run.id, policy); - - expect(completed?.status).toBe("completed"); - expect(closeout).toHaveBeenCalledTimes(1); - db.close(); - }); - - it("keeps a single Linear workpad comment live through delegated worker execution, PR linking, and closeout", async () => { - const root = fs.mkdtempSync(path.join(os.tmpdir(), "ade-linear-dispatcher-workpad-integration-")); - const db = await openKvDb(path.join(root, "ade.db"), { debug() {}, info() {}, warn() {}, error() {} } as any); - const artifactPath = path.join(root, "proof.txt"); - fs.writeFileSync(artifactPath, "proof", "utf8"); - - const policy = buildWorkerExplicitCompletionPolicy(); - const workflow = policy.workflows[0]!; - workflow.target.laneSelection = "fresh_issue_lane"; - workflow.target.prStrategy = { kind: "per-lane", draft: true }; - workflow.target.prTiming = "after_start"; - - const createComment = vi.fn(async () => ({ commentId: "comment-1" })); - const updateBodies: string[] = []; - const updateComment = vi.fn(async (_commentId: string, body: string) => { - updateBodies.push(body); - }); - const issueTracker = { - fetchIssueById: vi.fn(async () => issueFixture), - fetchWorkflowStates: vi.fn(async () => [{ id: "state-review", name: "In Review", type: "started", teamId: "team-1", teamKey: "ACME" }]), - updateIssueState: vi.fn(async () => {}), - addLabel: vi.fn(async () => {}), - createComment, - updateComment, - uploadAttachment: vi.fn(), - } as any; - const prService = { - getForLane: vi.fn(() => null), - getStatus: vi.fn(async () => ({ - prId: "pr-55", - state: "open", - checksStatus: "passing", - reviewStatus: "requested", - isMergeable: true, - mergeConflicts: false, - behindBaseBy: 0, - })), - createFromLane: vi.fn(async () => ({ id: "pr-55", githubPrNumber: 55 })), - listAll: vi.fn(() => [{ id: "pr-55", githubUrl: "https://github.com/acme/repo/pull/55" }]), - } as any; - const outboundService = createLinearOutboundService({ - db, - projectId: "project-1", - projectRoot: root, - issueTracker, - logger: { debug() {}, info() {}, warn() {}, error() {} } as any, - }); - const closeoutService = createLinearCloseoutService({ - issueTracker, - outboundService, - missionService: { get: vi.fn(() => null) } as any, - orchestratorService: { getArtifactsForMission: vi.fn(() => []) } as any, - prService, - computerUseArtifactBrokerService: { - listArtifacts: vi.fn(() => [{ uri: artifactPath }]), - } as any, - }); - - const dispatcher = createLinearDispatcherService({ - db, - projectId: "project-1", - issueTracker, - workerAgentService: { - listAgents: vi.fn(() => [{ id: "agent-1", slug: "backend-dev", adapterType: "codex-local", capabilities: [] }]), - } as any, - workerHeartbeatService: { - triggerWakeup: vi.fn(async () => ({ runId: "worker-run-1" })), - listRuns: vi.fn(() => [{ id: "worker-run-1", status: "completed" }]), - } as any, - missionService: { create: vi.fn(), get: vi.fn() } as any, - aiOrchestratorService: { startMissionRun: vi.fn() } as any, - agentChatService: { ensureIdentitySession: vi.fn(), sendMessage: vi.fn(async () => {}), listSessions: vi.fn(async () => []) } as any, - laneService: { - ensurePrimaryLane: vi.fn(async () => {}), - list: vi.fn(async () => [{ id: "lane-1", laneType: "primary" }]), - create: vi.fn(async () => ({ id: "lane-2", name: "ABC-42 fresh lane" })), - } as any, - templateService: { renderTemplate: vi.fn(() => ({ prompt: "Implement the issue." })) } as any, - closeoutService, - outboundService, - workerTaskSessionService: { - deriveTaskKey: vi.fn(() => "task-key-1"), - ensureTaskSession: vi.fn(() => ({ id: "task-session-1" })), - } as any, - prService, - }); - - const run = dispatcher.createRun({ ...issueFixture, labels: ["workflow:explicit-complete"] }, buildMatch(policy)); - await dispatcher.advanceRun(run.id, policy); - await dispatcher.resolveRunAction(run.id, "complete", "Validated with proof and PR.", policy); - const completed = await dispatcher.advanceRun(run.id, policy); - - expect(completed?.status).toBe("completed"); - expect(createComment).toHaveBeenCalledTimes(1); - expect(updateBodies.length).toBeGreaterThanOrEqual(2); - expect(updateBodies[0]).toContain("- Lane: lane-2"); - expect(updateBodies[0]).toContain("- Worker run: worker-run-1"); - expect(updateBodies[0]).toContain("- PR: pr-55"); - expect(updateBodies[updateBodies.length - 1]).toContain("### Closeout Summary"); - expect(updateBodies[updateBodies.length - 1]).toContain("https://github.com/acme/repo/pull/55"); - expect(updateBodies[updateBodies.length - 1]).toContain(pathToFileURL(fs.realpathSync(artifactPath)).href); - db.close(); - }); - - it("persists downstream session ownership after handing work from a worker to an employee session", async () => { - const root = fs.mkdtempSync(path.join(os.tmpdir(), "ade-linear-dispatcher-downstream-session-")); - const db = await openKvDb(path.join(root, "ade.db"), { debug() {}, info() {}, warn() {}, error() {} } as any); - const policy = buildDownstreamEmployeeSessionPolicy(); - const outboundService = createOutboundServiceMocks(); - - const dispatcher = createLinearDispatcherService({ - db, - projectId: "project-1", - issueTracker: { - fetchIssueById: vi.fn(async () => issueFixture), - fetchWorkflowStates: vi.fn(async () => []), - updateIssueState: vi.fn(async () => {}), - addLabel: vi.fn(async () => {}), - createComment: vi.fn(async () => ({ commentId: "comment-1" })), - } as any, - workerAgentService: { - listAgents: vi.fn(() => [{ id: "agent-1", slug: "backend-dev", adapterType: "claude-local", capabilities: [] }]), - } as any, - workerHeartbeatService: { - triggerWakeup: vi.fn(async () => ({ runId: "worker-run-1" })), - listRuns: vi.fn(() => [{ id: "worker-run-1", status: "completed" }]), - } as any, - missionService: { create: vi.fn(), get: vi.fn() } as any, - aiOrchestratorService: { startMissionRun: vi.fn() } as any, - agentChatService: { - ensureIdentitySession: vi.fn(async () => ({ id: "session-cto-2" })), - sendMessage: vi.fn(async () => ({ id: "message-1" })), - listSessions: vi.fn(async () => [{ sessionId: "session-cto-2", laneId: "lane-1", status: "idle", identityKey: "cto" }]), - } as any, - laneService: { ensurePrimaryLane: vi.fn(async () => {}), list: vi.fn(async () => [{ id: "lane-1", laneType: "primary" }]) } as any, - templateService: { renderTemplate: vi.fn(() => ({ prompt: "Please own this issue." })) } as any, - closeoutService: { applyOutcome: vi.fn(async () => {}) } as any, - outboundService, - workerTaskSessionService: { - deriveTaskKey: vi.fn(() => "task-key-1"), - ensureTaskSession: vi.fn(() => ({ id: "task-session-1" })), - } as any, - prService: { - getForLane: vi.fn(() => null), - createFromLane: vi.fn(async () => ({ id: "pr-1", githubPrNumber: 101 })), - } as any, - }); - - const run = dispatcher.createRun({ ...issueFixture, labels: ["workflow:downstream-session"] }, buildMatch(policy)); - await dispatcher.advanceRun(run.id, policy); - await dispatcher.advanceRun(run.id, policy); - - const queueItem = dispatcher.listQueue()[0]!; - expect(queueItem.sessionId).toBe("session-cto-2"); - expect(queueItem.sessionLabel).toBe("CTO"); - expect(queueItem.workerId).toBeNull(); - expect(queueItem.workerSlug).toBeNull(); - expect(outboundService.publishWorkflowStatus).toHaveBeenLastCalledWith(expect.objectContaining({ - delegatedOwner: "CTO", - sessionId: "session-cto-2", - })); - db.close(); - }); - - it("pauses for supervisor approval and can resume after approval", async () => { - const root = fs.mkdtempSync(path.join(os.tmpdir(), "ade-linear-dispatcher-supervisor-")); - const db = await openKvDb(path.join(root, "ade.db"), { debug() {}, info() {}, warn() {}, error() {} } as any); - const policy = buildSupervisedWorkerPolicy(); - const createLane = vi.fn(async () => ({ id: "lane-2", name: "Fresh lane" })); - const listRuns = vi.fn(() => [{ id: "worker-run-1", status: "completed" }]); - - const dispatcher = createLinearDispatcherService({ - db, - projectId: "project-1", - issueTracker: { - fetchIssueById: vi.fn(async () => issueFixture), - fetchWorkflowStates: vi.fn(async () => []), - updateIssueState: vi.fn(async () => {}), - addLabel: vi.fn(async () => {}), - createComment: vi.fn(async () => ({ commentId: "comment-1" })), - } as any, - workerAgentService: { - listAgents: vi.fn(() => [{ id: "agent-1", slug: "backend-dev", adapterType: "claude-local", capabilities: [] }]), - } as any, - workerHeartbeatService: { - triggerWakeup: vi.fn(async () => ({ runId: "worker-run-1" })), - listRuns, - } as any, - missionService: { create: vi.fn(), get: vi.fn() } as any, - aiOrchestratorService: { startMissionRun: vi.fn() } as any, - agentChatService: { ensureIdentitySession: vi.fn(), sendMessage: vi.fn(async () => {}), listSessions: vi.fn(async () => []) } as any, - laneService: { - ensurePrimaryLane: vi.fn(async () => {}), - list: vi.fn(async () => [{ id: "lane-1", laneType: "primary" }]), - create: createLane, - } as any, - templateService: { renderTemplate: vi.fn(() => ({ prompt: "Implement the issue." })) } as any, - closeoutService: { applyOutcome: vi.fn(async () => {}) } as any, - outboundService: createOutboundServiceMocks(), - workerTaskSessionService: { - deriveTaskKey: vi.fn(() => "task-key-1"), - ensureTaskSession: vi.fn(() => ({ id: "task-session-1" })), - } as any, - prService: { - getForLane: vi.fn(() => null), - createFromLane: vi.fn(async () => ({ id: "pr-1", githubPrNumber: 101 })), - } as any, - }); - - const run = dispatcher.createRun({ ...issueFixture, labels: ["workflow:backend-supervised"] }, buildMatch(policy)); - await dispatcher.advanceRun(run.id, policy); - const awaitingReview = await dispatcher.advanceRun(run.id, policy); - - expect(awaitingReview?.status).toBe("awaiting_human_review"); - expect(dispatcher.listQueue()[0]?.status).toBe("escalated"); - - await dispatcher.resolveRunAction(run.id, "approve", "Looks good.", policy); - const completed = await dispatcher.advanceRun(run.id, policy); - expect(completed?.status).toBe("completed"); - db.close(); - }); - - it("loops back to delegated work when supervisor requests changes", async () => { - const root = fs.mkdtempSync(path.join(os.tmpdir(), "ade-linear-dispatcher-loopback-")); - const db = await openKvDb(path.join(root, "ade.db"), { debug() {}, info() {}, warn() {}, error() {} } as any); - const policy = buildSupervisedWorkerPolicy(); - - const dispatcher = createLinearDispatcherService({ - db, - projectId: "project-1", - issueTracker: { - fetchIssueById: vi.fn(async () => issueFixture), - fetchWorkflowStates: vi.fn(async () => []), - updateIssueState: vi.fn(async () => {}), - addLabel: vi.fn(async () => {}), - createComment: vi.fn(async () => ({ commentId: "comment-1" })), - } as any, - workerAgentService: { - listAgents: vi.fn(() => [{ id: "agent-1", slug: "backend-dev", adapterType: "claude-local", capabilities: [] }]), - } as any, - workerHeartbeatService: { - triggerWakeup: vi.fn(async () => ({ runId: "worker-run-1" })), - listRuns: vi.fn(() => [{ id: "worker-run-1", status: "completed" }]), - } as any, - missionService: { create: vi.fn(), get: vi.fn() } as any, - aiOrchestratorService: { startMissionRun: vi.fn() } as any, - agentChatService: { ensureIdentitySession: vi.fn(), sendMessage: vi.fn(async () => {}), listSessions: vi.fn(async () => []) } as any, - laneService: { - ensurePrimaryLane: vi.fn(async () => {}), - list: vi.fn(async () => [{ id: "lane-1", laneType: "primary" }]), - create: vi.fn(async () => ({ id: "lane-2", name: "Fresh lane" })), - } as any, - templateService: { renderTemplate: vi.fn(() => ({ prompt: "Implement the issue." })) } as any, - closeoutService: { applyOutcome: vi.fn(async () => {}) } as any, - outboundService: createOutboundServiceMocks(), - workerTaskSessionService: { - deriveTaskKey: vi.fn(() => "task-key-1"), - ensureTaskSession: vi.fn(() => ({ id: "task-session-1" })), - } as any, - prService: { - getForLane: vi.fn(() => null), - createFromLane: vi.fn(async () => ({ id: "pr-1", githubPrNumber: 101 })), - } as any, - }); - - const run = dispatcher.createRun({ ...issueFixture, labels: ["workflow:backend-supervised"] }, buildMatch(policy)); - await dispatcher.advanceRun(run.id, policy); - await dispatcher.advanceRun(run.id, policy); - await dispatcher.resolveRunAction(run.id, "reject", "Please tighten the implementation.", policy); - const looped = await dispatcher.advanceRun(run.id, policy); - - expect(looped?.status).toBe("queued"); - expect(looped?.currentStepId).toBe("launch"); - expect(dispatcher.listQueue()[0]?.reviewState).toBe("changes_requested"); - db.close(); - }); - - it("links or creates a PR before closing out review-ready runs", async () => { - const root = fs.mkdtempSync(path.join(os.tmpdir(), "ade-linear-dispatcher-pr-")); - const db = await openKvDb(path.join(root, "ade.db"), { debug() {}, info() {}, warn() {}, error() {} } as any); - const policy = buildPrPolicy(); - const events: Array<{ type: string; milestone?: string; level?: string }> = []; - const closeout = vi.fn(async () => {}); - const createFromLane = vi.fn(async () => ({ id: "pr-42", githubPrNumber: 42 })); - - const dispatcher = createLinearDispatcherService({ - db, - projectId: "project-1", - issueTracker: { - fetchIssueById: vi.fn(async () => issueFixture), - fetchWorkflowStates: vi.fn(async () => [{ id: "state-review", name: "In Review", type: "started", teamId: "team-1", teamKey: "ACME" }]), - updateIssueState: vi.fn(async () => {}), - addLabel: vi.fn(async () => {}), - createComment: vi.fn(async () => ({ commentId: "comment-1" })), - } as any, - workerAgentService: { listAgents: vi.fn(() => [{ id: "agent-1", slug: "backend-dev", adapterType: "claude-local", capabilities: [] }]) } as any, - workerHeartbeatService: { - triggerWakeup: vi.fn(async () => ({ runId: "worker-run-1" })), - listRuns: vi.fn(() => []), - } as any, - missionService: { create: vi.fn(), get: vi.fn() } as any, - aiOrchestratorService: { startMissionRun: vi.fn() } as any, - agentChatService: { ensureIdentitySession: vi.fn(), sendMessage: vi.fn(async () => {}), listSessions: vi.fn(async () => []) } as any, - laneService: { ensurePrimaryLane: vi.fn(async () => {}), list: vi.fn(async () => [{ id: "lane-1", laneType: "primary" }]) } as any, - templateService: { renderTemplate: vi.fn(() => ({ prompt: "Open a PR." })) } as any, - closeoutService: { applyOutcome: closeout } as any, - outboundService: createOutboundServiceMocks(), - workerTaskSessionService: { - deriveTaskKey: vi.fn(() => "task-key-1"), - ensureTaskSession: vi.fn(() => ({ id: "task-session-1" })), - } as any, - prService: { - getForLane: vi.fn(() => null), - createFromLane, - } as any, - onEvent: (event) => events.push({ type: event.type, milestone: (event as any).milestone, level: (event as any).level }), - }); - - const run = dispatcher.createRun({ ...issueFixture, labels: ["workflow:backend"] }, buildMatch(policy)); - await dispatcher.advanceRun(run.id, policy); - const finalRun = await dispatcher.advanceRun(run.id, policy); - - expect(createFromLane).toHaveBeenCalledTimes(1); - expect(finalRun?.linkedPrId).toBe("pr-42"); - expect(finalRun?.status).toBe("completed"); - expect(closeout).toHaveBeenCalledTimes(1); - expect(events.some((entry) => entry.milestone === "pr_linked")).toBe(true); - expect(events.some((entry) => entry.milestone === "review_ready")).toBe(true); - db.close(); - }); - - it("waits for a PR to become review-ready before closing out", async () => { - const root = fs.mkdtempSync(path.join(os.tmpdir(), "ade-linear-dispatcher-pr-ready-")); - const db = await openKvDb(path.join(root, "ade.db"), { debug() {}, info() {}, warn() {}, error() {} } as any); - const policy = buildPrReadyPolicy(); - const getStatus = vi - .fn() - .mockResolvedValueOnce({ - prId: "pr-42", - state: "open", - checksStatus: "failing", - reviewStatus: "requested", - isMergeable: false, - mergeConflicts: false, - behindBaseBy: 0, - }) - .mockResolvedValueOnce({ - prId: "pr-42", - state: "open", - checksStatus: "failing", - reviewStatus: "requested", - isMergeable: false, - mergeConflicts: false, - behindBaseBy: 0, - }) - .mockResolvedValueOnce({ - prId: "pr-42", - state: "open", - checksStatus: "passing", - reviewStatus: "approved", - isMergeable: true, - mergeConflicts: false, - behindBaseBy: 0, - }) - .mockResolvedValueOnce({ - prId: "pr-42", - state: "open", - checksStatus: "passing", - reviewStatus: "approved", - isMergeable: true, - mergeConflicts: false, - behindBaseBy: 0, - }); - - const dispatcher = createLinearDispatcherService({ - db, - projectId: "project-1", - issueTracker: { - fetchIssueById: vi.fn(async () => issueFixture), - fetchWorkflowStates: vi.fn(async () => []), - updateIssueState: vi.fn(async () => {}), - addLabel: vi.fn(async () => {}), - createComment: vi.fn(async () => ({ commentId: "comment-1" })), - } as any, - workerAgentService: { listAgents: vi.fn(() => [{ id: "agent-1", slug: "backend-dev", adapterType: "claude-local", capabilities: [] }]) } as any, - workerHeartbeatService: { - triggerWakeup: vi.fn(async () => ({ runId: "worker-run-1" })), - listRuns: vi.fn(() => []), - } as any, - missionService: { create: vi.fn(), get: vi.fn() } as any, - aiOrchestratorService: { startMissionRun: vi.fn() } as any, - agentChatService: { ensureIdentitySession: vi.fn(), sendMessage: vi.fn(async () => {}), listSessions: vi.fn(async () => []) } as any, - laneService: { ensurePrimaryLane: vi.fn(async () => {}), list: vi.fn(async () => [{ id: "lane-1", laneType: "primary" }]) } as any, - templateService: { renderTemplate: vi.fn(() => ({ prompt: "Open a review-ready PR." })) } as any, - closeoutService: { applyOutcome: vi.fn(async () => {}) } as any, - outboundService: createOutboundServiceMocks(), - workerTaskSessionService: { - deriveTaskKey: vi.fn(() => "task-key-1"), - ensureTaskSession: vi.fn(() => ({ id: "task-session-1" })), - } as any, - prService: { - getForLane: vi.fn(() => null), - getStatus, - createFromLane: vi.fn(async () => ({ id: "pr-42", githubPrNumber: 42 })), - } as any, - }); - - const run = dispatcher.createRun({ ...issueFixture, labels: ["workflow:backend"] }, buildMatch(policy)); - await dispatcher.advanceRun(run.id, policy); - const waiting = await dispatcher.advanceRun(run.id, policy); - expect(waiting?.status).toBe("waiting_for_pr"); - - const resolved = await dispatcher.advanceRun(run.id, policy); - expect(resolved?.status).toBe("completed"); - expect(getStatus).toHaveBeenCalledTimes(4); - expect(dispatcher.listQueue()[0]?.prChecksStatus).toBe("passing"); - expect(dispatcher.listQueue()[0]?.prReviewStatus).toBe("approved"); - db.close(); - }); - - it("fails before launching a downstream PR stage when the workflow is missing wait_for_pr", async () => { - const root = fs.mkdtempSync(path.join(os.tmpdir(), "ade-linear-dispatcher-invalid-downstream-pr-")); - const db = await openKvDb(path.join(root, "ade.db"), { debug() {}, info() {}, warn() {}, error() {} } as any); - const policy = buildInvalidDownstreamPrPolicy(); - const triggerWakeup = vi - .fn() - .mockResolvedValueOnce({ runId: "worker-run-1" }) - .mockResolvedValueOnce({ runId: "worker-run-2" }); - - const dispatcher = createLinearDispatcherService({ - db, - projectId: "project-1", - issueTracker: { - fetchIssueById: vi.fn(async () => issueFixture), - fetchWorkflowStates: vi.fn(async () => []), - updateIssueState: vi.fn(async () => {}), - addLabel: vi.fn(async () => {}), - createComment: vi.fn(async () => ({ commentId: "comment-1" })), - } as any, - workerAgentService: { listAgents: vi.fn(() => [{ id: "agent-1", slug: "backend-dev", adapterType: "claude-local", capabilities: [] }]) } as any, - workerHeartbeatService: { - triggerWakeup, - listRuns: vi.fn(() => [{ id: "worker-run-1", status: "completed" }]), - } as any, - missionService: { create: vi.fn(), get: vi.fn() } as any, - aiOrchestratorService: { startMissionRun: vi.fn() } as any, - agentChatService: { ensureIdentitySession: vi.fn(), sendMessage: vi.fn(async () => {}), listSessions: vi.fn(async () => []) } as any, - laneService: { ensurePrimaryLane: vi.fn(async () => {}), list: vi.fn(async () => [{ id: "lane-1", laneType: "primary" }]) } as any, - templateService: { renderTemplate: vi.fn(() => ({ prompt: "Open a PR." })) } as any, - closeoutService: { applyOutcome: vi.fn(async () => {}) } as any, - outboundService: createOutboundServiceMocks(), - workerTaskSessionService: { - deriveTaskKey: vi.fn(() => "task-key-1"), - ensureTaskSession: vi.fn(() => ({ id: "task-session-1" })), - } as any, - prService: { - getForLane: vi.fn(() => null), - createFromLane: vi.fn(async () => ({ id: "pr-42", githubPrNumber: 42 })), - } as any, - }); - - const run = dispatcher.createRun({ ...issueFixture, labels: ["workflow:downstream-pr"] }, buildMatch(policy)); - await dispatcher.advanceRun(run.id, policy); - - const retried = await dispatcher.advanceRun(run.id, policy); - expect(retried?.status).toBe("failed"); - expect(triggerWakeup).toHaveBeenCalledTimes(1); - db.close(); - }); -}); diff --git a/apps/desktop/src/main/services/cto/linearIngressService.test.ts b/apps/desktop/src/main/services/cto/linearIngressService.test.ts deleted file mode 100644 index ec7f10b93..000000000 --- a/apps/desktop/src/main/services/cto/linearIngressService.test.ts +++ /dev/null @@ -1,102 +0,0 @@ -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; -import { afterEach, describe, expect, it, vi } from "vitest"; -import { openKvDb } from "../state/kvDb"; -import { createLinearIngressService } from "./linearIngressService"; - -describe("linearIngressService", () => { - const fetchMock = vi.fn(); - - afterEach(() => { - vi.restoreAllMocks(); - fetchMock.mockReset(); - }); - - it("ensures the relay webhook and stores ingress status", async () => { - const root = fs.mkdtempSync(path.join(os.tmpdir(), "ade-linear-ingress-")); - const db = await openKvDb(path.join(root, "ade.db"), { debug() {}, info() {}, warn() {}, error() {} } as any); - - fetchMock.mockResolvedValueOnce({ - ok: true, - json: async () => ({ - endpointId: "endpoint-1", - webhookUrl: "https://relay.example.com/linear/webhooks/endpoint-1", - signingSecret: "relay-secret", - lastDeliveredAt: null, - }), - } as Response); - fetchMock.mockImplementationOnce(async (_url: string, init?: RequestInit) => { - const signal = init?.signal as AbortSignal | undefined; - await new Promise((resolve) => { - if (signal?.aborted) { - resolve(); - return; - } - signal?.addEventListener("abort", () => resolve(), { once: true }); - }); - throw new Error("aborted"); - }); - - vi.stubGlobal("fetch", fetchMock); - - const service = createLinearIngressService({ - db, - projectId: "project-1", - linearClient: { - listWebhooks: vi.fn(async () => []), - createWebhook: vi.fn(async () => ({ id: "webhook-1" })), - } as any, - secretService: { - getSecret: (key: string) => - key === "linearRelay.apiBaseUrl" - ? "https://relay.example.com" - : key === "linearRelay.remoteProjectId" - ? "remote-project-1" - : key === "linearRelay.accessToken" - ? "token-1" - : null, - } as any, - }); - - await service.ensureRelayWebhook(true); - const status = service.getStatus(); - - expect(status.localWebhook.status).toBe("listening"); - expect(status.localWebhook.url).toContain("/linear-webhooks"); - expect(status.relay.status).toBe("ready"); - expect(status.relay.webhookUrl).toContain("/linear/webhooks/endpoint-1"); - expect(fetchMock).toHaveBeenCalledTimes(2); - - service.dispose(); - db.close(); - }); - - it("does not auto-start ingress when relay credentials are missing", async () => { - const root = fs.mkdtempSync(path.join(os.tmpdir(), "ade-linear-ingress-")); - const db = await openKvDb(path.join(root, "ade.db"), { debug() {}, info() {}, warn() {}, error() {} } as any); - - vi.stubGlobal("fetch", fetchMock); - - const service = createLinearIngressService({ - db, - projectId: "project-1", - linearClient: { - listWebhooks: vi.fn(async () => []), - createWebhook: vi.fn(async () => ({ id: "webhook-1" })), - } as any, - secretService: { - getSecret: () => null, - } as any, - }); - - await service.start(); - - expect(fetchMock).not.toHaveBeenCalled(); - expect(service.getStatus().localWebhook.status).toBe("disabled"); - expect(service.canAutoStart()).toBe(false); - - service.dispose(); - db.close(); - }); -}); diff --git a/apps/desktop/src/main/services/cto/linearIntake.test.ts b/apps/desktop/src/main/services/cto/linearIntake.test.ts new file mode 100644 index 000000000..8bfe27ce7 --- /dev/null +++ b/apps/desktop/src/main/services/cto/linearIntake.test.ts @@ -0,0 +1,799 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import type { + LinearWorkflowDefinition, + LinearWorkflowRun, + NormalizedLinearIssue, +} from "../../../shared/types"; +import type { LinearWorkflowConfig, NormalizedLinearIssue } from "../../../shared/types"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { createLinearCloseoutService } from "./linearCloseoutService"; +import { createLinearIngressService } from "./linearIngressService"; +import { createLinearIntakeService } from "./linearIntakeService"; +import { createLinearRoutingService } from "./linearRoutingService"; +import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; +import { openKvDb } from "../state/kvDb"; + +describe("linearIntakeService (file group)", () => { + + function createLogger() { + return { + debug: () => {}, + info: () => {}, + warn: () => {}, + error: () => {}, + } as any; + } + + const issueFixture: NormalizedLinearIssue = { + id: "issue-1", + identifier: "ABC-42", + title: "Fix flaky sync run", + description: "Occasional sync failure under load.", + url: "https://linear.app/acme/issue/ABC-42", + projectId: "proj-1", + projectSlug: "acme-platform", + teamId: "team-1", + teamKey: "ACME", + stateId: "state-todo", + stateName: "Todo", + stateType: "unstarted", + priority: 2, + priorityLabel: "high", + labels: ["bug"], + assigneeId: null, + assigneeName: "CTO", + ownerId: "owner-1", + blockerIssueIds: [], + hasOpenBlockers: false, + createdAt: "2026-03-05T00:00:00.000Z", + updatedAt: "2026-03-05T00:00:00.000Z", + raw: {}, + }; + + const secondIssue: NormalizedLinearIssue = { + ...issueFixture, + id: "issue-2", + identifier: "ABC-43", + title: "Add rate limiter", + priority: 1, + createdAt: "2026-03-04T00:00:00.000Z", + updatedAt: "2026-03-04T00:00:00.000Z", + }; + + const policy: LinearWorkflowConfig = { + version: 1, + source: "repo", + intake: { + projectSlugs: ["acme-platform"], + activeStateTypes: ["backlog", "unstarted", "started"], + terminalStateTypes: ["completed", "canceled"], + }, + settings: { ctoLinearAssigneeName: "CTO", ctoLinearAssigneeAliases: ["cto"] }, + workflows: [ + { + id: "flow-1", + name: "Flow 1", + enabled: true, + priority: 100, + triggers: { assignees: ["CTO"], projectSlugs: ["acme-platform"] }, + target: { type: "mission" }, + steps: [{ id: "launch", type: "launch_target" }], + }, + ], + files: [], + migration: { hasLegacyConfig: false, needsSave: false }, + legacyConfig: null, + }; + + async function createFixture() { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "ade-linear-intake-")); + const adeDir = path.join(root, ".ade"); + fs.mkdirSync(adeDir, { recursive: true }); + const db = await openKvDb(path.join(adeDir, "ade.db"), createLogger()); + return { root, adeDir, db }; + } + + describe("linearIntakeService", () => { + it("fetches candidates, filters out blockers, and sorts by priority then createdAt", async () => { + const fixture = await createFixture(); + const blockedIssue: NormalizedLinearIssue = { + ...issueFixture, + id: "issue-blocked", + identifier: "ABC-99", + hasOpenBlockers: true, + priority: 0, + }; + const fetchCandidateIssues = vi.fn(async () => [blockedIssue, secondIssue, issueFixture]); + + const service = createLinearIntakeService({ + db: fixture.db, + projectId: "project-intake-test", + issueTracker: { + fetchCandidateIssues, + } as any, + }); + + const candidates = await service.fetchCandidates(policy); + + expect(fetchCandidateIssues).toHaveBeenCalledWith({ + projectSlugs: ["acme-platform"], + stateTypes: ["backlog", "unstarted", "started"], + }); + + // Blocked issue should be filtered out + expect(candidates.find((issue) => issue.id === "issue-blocked")).toBeUndefined(); + // Remaining should be sorted by priority (ascending), then createdAt + expect(candidates).toHaveLength(2); + expect(candidates[0]!.id).toBe("issue-2"); // priority 1 < priority 2 + expect(candidates[1]!.id).toBe("issue-1"); + + fixture.db.close(); + }); + + it("merges project slugs from intake, workflows, and legacy config", async () => { + const fixture = await createFixture(); + const fetchCandidateIssues = vi.fn(async () => []); + + const service = createLinearIntakeService({ + db: fixture.db, + projectId: "project-slug-merge", + issueTracker: { fetchCandidateIssues } as any, + }); + + const policyWithLegacy: LinearWorkflowConfig = { + ...policy, + intake: { + ...policy.intake, + projectSlugs: ["primary-project"], + }, + workflows: [ + { + id: "flow-extra", + name: "Extra flow", + enabled: true, + priority: 100, + triggers: { projectSlugs: ["extra-project"] }, + target: { type: "mission" }, + steps: [{ id: "launch", type: "launch_target" }], + }, + ], + legacyConfig: { + enabled: true, + projects: [{ slug: "legacy-project" }], + }, + }; + + await service.fetchCandidates(policyWithLegacy); + + const calledWith = (fetchCandidateIssues.mock.calls as any)[0][0] as { projectSlugs: string[] }; + expect(calledWith.projectSlugs).toContain("primary-project"); + expect(calledWith.projectSlugs).toContain("extra-project"); + expect(calledWith.projectSlugs).toContain("legacy-project"); + + fixture.db.close(); + }); + + it("attaches previous state info from persisted snapshots", async () => { + const fixture = await createFixture(); + const projectId = "project-previous-state"; + + // Pre-persist a snapshot so the service finds previous state + const now = new Date().toISOString(); + fixture.db.run( + ` + insert into linear_issue_snapshots( + id, project_id, issue_id, identifier, state_type, assignee_id, updated_at_linear, payload_json, hash, created_at, updated_at + ) + values(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `, + [ + `${projectId}:${issueFixture.id}`, + projectId, + issueFixture.id, + issueFixture.identifier, + "backlog", + null, + issueFixture.updatedAt, + JSON.stringify({ ...issueFixture, stateId: "state-backlog", stateName: "Backlog", stateType: "backlog" }), + "old-hash", + now, + now, + ] + ); + + const service = createLinearIntakeService({ + db: fixture.db, + projectId, + issueTracker: { + fetchCandidateIssues: vi.fn(async () => [issueFixture]), + } as any, + }); + + const candidates = await service.fetchCandidates(policy); + expect(candidates).toHaveLength(1); + expect(candidates[0]!.previousStateType).toBe("backlog"); + expect(candidates[0]!.previousStateName).toBe("Backlog"); + + fixture.db.close(); + }); + + it("persistSnapshot inserts a new row and updates an existing one", async () => { + const fixture = await createFixture(); + const projectId = "project-persist-test"; + + const service = createLinearIntakeService({ + db: fixture.db, + projectId, + issueTracker: { + fetchCandidateIssues: vi.fn(async () => []), + } as any, + }); + + // First persist: insert + service.persistSnapshot(issueFixture); + const row1 = fixture.db.get<{ issue_id: string; state_type: string }>( + `select issue_id, state_type from linear_issue_snapshots where project_id = ? and issue_id = ?`, + [projectId, issueFixture.id] + ); + expect(row1, "First persist should create a row").toBeTruthy(); + expect(row1!.issue_id).toBe(issueFixture.id); + expect(row1!.state_type).toBe("unstarted"); + + // Second persist: update + const updatedIssue = { ...issueFixture, stateType: "started" as const, stateName: "In Progress" }; + service.persistSnapshot(updatedIssue); + const row2 = fixture.db.get<{ state_type: string }>( + `select state_type from linear_issue_snapshots where project_id = ? and issue_id = ?`, + [projectId, issueFixture.id] + ); + expect(row2!.state_type).toBe("started"); + + fixture.db.close(); + }); + + it("issueHash produces consistent deterministic output", async () => { + const fixture = await createFixture(); + + const service = createLinearIntakeService({ + db: fixture.db, + projectId: "project-hash-test", + issueTracker: { + fetchCandidateIssues: vi.fn(async () => []), + } as any, + }); + + const hash1 = service.issueHash(issueFixture); + const hash2 = service.issueHash(issueFixture); + expect(hash1).toBe(hash2); + expect(hash1).toHaveLength(64); // sha256 hex + + const hash3 = service.issueHash({ ...issueFixture, title: "Different title" }); + expect(hash3).not.toBe(hash1); + + fixture.db.close(); + }); + + it("returns empty array when no issues match the query", async () => { + const fixture = await createFixture(); + + const service = createLinearIntakeService({ + db: fixture.db, + projectId: "project-empty", + issueTracker: { + fetchCandidateIssues: vi.fn(async () => []), + } as any, + }); + + const candidates = await service.fetchCandidates(policy); + expect(candidates).toEqual([]); + + fixture.db.close(); + }); + }); + +}); + +describe("linearIngressService (file group)", () => { + + describe("linearIngressService", () => { + const fetchMock = vi.fn(); + + afterEach(() => { + vi.restoreAllMocks(); + fetchMock.mockReset(); + }); + + it("ensures the relay webhook and stores ingress status", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "ade-linear-ingress-")); + const db = await openKvDb(path.join(root, "ade.db"), { debug() {}, info() {}, warn() {}, error() {} } as any); + + fetchMock.mockResolvedValueOnce({ + ok: true, + json: async () => ({ + endpointId: "endpoint-1", + webhookUrl: "https://relay.example.com/linear/webhooks/endpoint-1", + signingSecret: "relay-secret", + lastDeliveredAt: null, + }), + } as Response); + fetchMock.mockImplementationOnce(async (_url: string, init?: RequestInit) => { + const signal = init?.signal as AbortSignal | undefined; + await new Promise((resolve) => { + if (signal?.aborted) { + resolve(); + return; + } + signal?.addEventListener("abort", () => resolve(), { once: true }); + }); + throw new Error("aborted"); + }); + + vi.stubGlobal("fetch", fetchMock); + + const service = createLinearIngressService({ + db, + projectId: "project-1", + linearClient: { + listWebhooks: vi.fn(async () => []), + createWebhook: vi.fn(async () => ({ id: "webhook-1" })), + } as any, + secretService: { + getSecret: (key: string) => + key === "linearRelay.apiBaseUrl" + ? "https://relay.example.com" + : key === "linearRelay.remoteProjectId" + ? "remote-project-1" + : key === "linearRelay.accessToken" + ? "token-1" + : null, + } as any, + }); + + await service.ensureRelayWebhook(true); + const status = service.getStatus(); + + expect(status.localWebhook.status).toBe("listening"); + expect(status.localWebhook.url).toContain("/linear-webhooks"); + expect(status.relay.status).toBe("ready"); + expect(status.relay.webhookUrl).toContain("/linear/webhooks/endpoint-1"); + expect(fetchMock).toHaveBeenCalledTimes(2); + + service.dispose(); + db.close(); + }); + + it("does not auto-start ingress when relay credentials are missing", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "ade-linear-ingress-")); + const db = await openKvDb(path.join(root, "ade.db"), { debug() {}, info() {}, warn() {}, error() {} } as any); + + vi.stubGlobal("fetch", fetchMock); + + const service = createLinearIngressService({ + db, + projectId: "project-1", + linearClient: { + listWebhooks: vi.fn(async () => []), + createWebhook: vi.fn(async () => ({ id: "webhook-1" })), + } as any, + secretService: { + getSecret: () => null, + } as any, + }); + + await service.start(); + + expect(fetchMock).not.toHaveBeenCalled(); + expect(service.getStatus().localWebhook.status).toBe("disabled"); + expect(service.canAutoStart()).toBe(false); + + service.dispose(); + db.close(); + }); + }); + +}); + +describe("linearRoutingService (file group)", () => { + + const baseIssue: NormalizedLinearIssue = { + id: "issue-1", + identifier: "ABC-10", + title: "Fix login bug", + description: "Users cannot login when refresh token is stale.", + url: null, + projectId: "proj-1", + projectSlug: "acme-platform", + teamId: "team-1", + teamKey: "ACME", + stateId: "state-1", + stateName: "Todo", + stateType: "unstarted", + previousStateId: "state-backlog", + previousStateName: "Backlog", + previousStateType: "backlog", + priority: 2, + priorityLabel: "high", + labels: ["bug", "fast-lane"], + metadataTags: ["ui"], + assigneeId: null, + assigneeName: "CTO", + ownerId: "owner-1", + creatorId: "creator-1", + creatorName: "Taylor", + blockerIssueIds: [], + hasOpenBlockers: false, + createdAt: "2026-03-05T00:00:00.000Z", + updatedAt: "2026-03-05T00:00:00.000Z", + raw: {}, + }; + + function buildPolicy(): LinearWorkflowConfig { + return { + version: 1, + source: "repo", + intake: { + projectSlugs: ["acme-platform"], + activeStateTypes: ["backlog", "unstarted", "started"], + terminalStateTypes: ["completed", "canceled"], + }, + settings: { ctoLinearAssigneeName: "CTO", ctoLinearAssigneeAliases: ["cto"] }, + workflows: [ + { + id: "review", + name: "Review gate", + enabled: true, + priority: 50, + triggers: { assignees: ["CTO"], labels: ["needs-triage"] }, + target: { type: "review_gate" }, + steps: [{ id: "launch", type: "launch_target" }], + }, + { + id: "fast-lane", + name: "PR fast lane", + enabled: true, + priority: 120, + triggers: { assignees: ["CTO"], labels: ["fast-lane"], priority: ["high"], projectSlugs: ["acme-platform"] }, + target: { type: "pr_resolution", runMode: "autopilot" }, + steps: [{ id: "launch", type: "launch_target", name: "Launch PR flow" }], + }, + ], + files: [], + migration: { hasLegacyConfig: false, needsSave: false }, + legacyConfig: null, + }; + } + + describe("linearRoutingService", () => { + it("returns all candidate explanations and picks the highest-priority match", async () => { + const policy = buildPolicy(); + const service = createLinearRoutingService({ + flowPolicyService: { + getPolicy: () => policy, + normalizePolicy: (input?: LinearWorkflowConfig) => input ?? policy, + } as any, + }); + + const decision = await service.routeIssue({ issue: baseIssue }); + expect(decision.workflowId).toBe("fast-lane"); + expect(decision.target?.type).toBe("pr_resolution"); + expect(decision.candidates).toHaveLength(2); + expect(decision.candidates.find((candidate) => candidate.workflowId === "review")?.matched).toBe(false); + }); + + it("explains when nothing matched", async () => { + const policy = buildPolicy(); + const service = createLinearRoutingService({ + flowPolicyService: { + getPolicy: () => policy, + normalizePolicy: (input?: LinearWorkflowConfig) => input ?? policy, + } as any, + }); + + const decision = await service.routeIssue({ + issue: { ...baseIssue, labels: ["bug"], assigneeName: "Someone Else" }, + }); + expect(decision.workflowId).toBeNull(); + expect(decision.reason).toContain("No workflow matched"); + }); + + it("requires both assignee and workflow label, while allowing employee identity mappings", async () => { + const policy = buildPolicy(); + policy.workflows[1] = { + ...policy.workflows[1]!, + triggers: { + ...policy.workflows[1]!.triggers, + assignees: ["agent-1"], + }, + }; + const service = createLinearRoutingService({ + flowPolicyService: { + getPolicy: () => policy, + normalizePolicy: (input?: LinearWorkflowConfig) => input ?? policy, + } as any, + workerAgentService: { + listAgents: () => [ + { + id: "agent-1", + slug: "backend-dev", + name: "Backend Dev", + linearIdentity: { userIds: ["user-1"], displayNames: ["Alex Johnson"], aliases: ["alex"] }, + }, + ], + } as any, + }); + + const missingLabel = await service.routeIssue({ + issue: { ...baseIssue, assigneeId: "user-1", assigneeName: "Alex Johnson", labels: ["bug"] }, + }); + expect(missingLabel.workflowId).toBeNull(); + expect(missingLabel.candidates[1]?.missingSignals).toContain("Missing label"); + + const matched = await service.routeIssue({ + issue: { ...baseIssue, assigneeId: "user-1", assigneeName: "Alex Johnson", labels: ["fast-lane"] }, + }); + expect(matched.workflowId).toBe("fast-lane"); + expect(matched.simulation?.explainsAndAcrossFields).toBe(true); + }); + }); + +}); + +describe("linearCloseoutService (file group)", () => { + + const issueFixture: NormalizedLinearIssue = { + id: "issue-1", + identifier: "ADE-12", + title: "Harden automation closeout", + description: "Proof artifacts should publish cleanly into Linear closeout.", + url: "https://linear.app/acme/issue/ADE-12", + projectId: "proj-1", + projectSlug: "acme-platform", + teamId: "team-1", + teamKey: "ACME", + stateId: "state-todo", + stateName: "Todo", + stateType: "unstarted", + priority: 2, + priorityLabel: "high", + labels: ["automation"], + assigneeId: null, + assigneeName: null, + ownerId: "owner-1", + blockerIssueIds: [], + hasOpenBlockers: false, + createdAt: "2026-03-05T00:00:00.000Z", + updatedAt: "2026-03-05T00:00:00.000Z", + raw: {}, + }; + + const workflowFixture: LinearWorkflowDefinition = { + id: "flow-1", + name: "Automation hardening", + enabled: true, + priority: 100, + triggers: { projectSlugs: ["acme-platform"] }, + target: { type: "mission" }, + steps: [], + closeout: { + successState: "done", + failureState: "blocked", + successComment: "Closeout applied.", + applyLabels: ["ade"], + artifactMode: "links", + }, + }; + + const sessionWorkflowFixture: LinearWorkflowDefinition = { + ...workflowFixture, + id: "flow-session", + name: "Session closeout", + target: { type: "employee_session" }, + }; + + const runFixture: LinearWorkflowRun = { + id: "run-1", + issueId: issueFixture.id, + identifier: issueFixture.identifier, + title: issueFixture.title, + workflowId: workflowFixture.id, + workflowName: workflowFixture.name, + workflowVersion: "2026-03-12T00:00:00.000Z", + source: "repo", + targetType: "mission", + status: "in_progress", + currentStepIndex: 0, + currentStepId: null, + executionLaneId: null, + linkedMissionId: "mission-1", + linkedSessionId: null, + linkedWorkerRunId: null, + linkedPrId: null, + reviewState: null, + supervisorIdentityKey: null, + reviewReadyReason: null, + prState: null, + prChecksStatus: null, + prReviewStatus: null, + latestReviewNote: null, + retryCount: 0, + retryAfter: null, + closeoutState: "pending", + terminalOutcome: null, + sourceIssueSnapshot: issueFixture, + lastError: null, + createdAt: "2026-03-05T00:00:00.000Z", + updatedAt: "2026-03-05T00:00:00.000Z", + }; + + describe("linearCloseoutService", () => { + it("merges mission and orchestrator proof artifacts into Linear closeout payload", async () => { + const publishMissionCloseout = vi.fn(async () => {}); + const issueTracker = { + fetchWorkflowStates: vi.fn(async () => [ + { id: "state-done", name: "Done", type: "completed" }, + { id: "state-blocked", name: "Blocked", type: "started" }, + ]), + updateIssueState: vi.fn(async () => {}), + addLabel: vi.fn(async () => {}), + createComment: vi.fn(async () => ({ commentId: "comment-1" })), + }; + + const service = createLinearCloseoutService({ + issueTracker: issueTracker as any, + outboundService: { + publishMissionCloseout, + } as any, + missionService: { + get: vi.fn(() => ({ + id: "mission-1", + artifacts: [ + { id: "art-1", artifactType: "pr", uri: "https://github.com/acme/repo/pull/42" }, + { id: "art-2", artifactType: "note", uri: "https://example.com/mission-note" }, + ], + })), + } as any, + orchestratorService: { + getArtifactsForMission: vi.fn(() => [ + { + id: "orch-1", + kind: "screenshot", + value: ".ade/artifacts/computer-use/shot.png", + metadata: {}, + }, + { + id: "orch-2", + kind: "pr", + value: "https://github.com/acme/repo/pull/43", + metadata: {}, + }, + { + id: "orch-3", + kind: "file", + value: "", + metadata: { uri: "https://example.com/browser-trace.zip" }, + }, + ]), + } as any, + prService: { + listAll: vi.fn(() => []), + getForLane: vi.fn(() => null), + } as any, + computerUseArtifactBrokerService: { + listArtifacts: vi.fn(() => []), + } as any, + }); + + await service.applyOutcome({ + run: runFixture, + workflow: workflowFixture, + issue: issueFixture, + outcome: "completed", + summary: "Validation evidence captured and closeout completed.", + }); + + expect(issueTracker.updateIssueState).toHaveBeenCalledWith(issueFixture.id, "state-done"); + expect(issueTracker.addLabel).toHaveBeenCalledWith(issueFixture.id, "ade"); + expect(issueTracker.createComment).toHaveBeenCalledWith(issueFixture.id, "Closeout applied."); + expect(publishMissionCloseout).toHaveBeenCalledWith(expect.objectContaining({ + issue: issueFixture, + missionId: "mission-1", + status: "completed", + summary: "Validation evidence captured and closeout completed.", + prLinks: [ + "https://github.com/acme/repo/pull/42", + "https://github.com/acme/repo/pull/43", + ], + artifactPaths: [ + "https://github.com/acme/repo/pull/42", + "https://example.com/mission-note", + ".ade/artifacts/computer-use/shot.png", + "https://github.com/acme/repo/pull/43", + "https://example.com/browser-trace.zip", + ], + artifactMode: "links", + commentTemplate: null, + })); + }); + + it("publishes non-mission PR links and broker artifacts to the generic Linear closeout", async () => { + const publishWorkflowCloseout = vi.fn(async () => {}); + const service = createLinearCloseoutService({ + issueTracker: { + fetchWorkflowStates: vi.fn(async () => []), + updateIssueState: vi.fn(async () => {}), + addLabel: vi.fn(async () => {}), + createComment: vi.fn(async () => ({ commentId: "comment-1" })), + } as any, + outboundService: { + publishMissionCloseout: vi.fn(async () => {}), + publishWorkflowCloseout, + } as any, + missionService: { + get: vi.fn(() => null), + } as any, + orchestratorService: { + getArtifactsForMission: vi.fn(() => []), + } as any, + prService: { + listAll: vi.fn(() => [{ id: "pr-99", githubUrl: "https://github.com/acme/repo/pull/99" }]), + getForLane: vi.fn(() => null), + } as any, + computerUseArtifactBrokerService: { + listArtifacts: vi.fn(({ owner }: { owner: { kind: string; id: string } }) => { + if (owner.kind === "chat_session") { + return [{ id: "artifact-1", kind: "browser_trace", uri: ".ade/artifacts/chat-trace.zip" }]; + } + if (owner.kind === "lane") { + return [{ id: "artifact-2", kind: "screenshot", uri: "https://example.com/lane-proof.png" }]; + } + if (owner.kind === "github_pr") { + return [{ id: "artifact-3", kind: "browser_verification", uri: "https://example.com/pr-proof.json" }]; + } + return []; + }), + } as any, + }); + + await service.applyOutcome({ + run: { + ...runFixture, + targetType: "employee_session", + linkedMissionId: null, + linkedSessionId: "session-1", + linkedPrId: "pr-99", + executionLaneId: "lane-1", + }, + workflow: sessionWorkflowFixture, + issue: issueFixture, + outcome: "completed", + summary: "Worker handoff wrapped with linked proof.", + }); + + expect(publishWorkflowCloseout).toHaveBeenCalledWith(expect.objectContaining({ + issue: issueFixture, + status: "completed", + summary: "Worker handoff wrapped with linked proof.", + targetLabel: "employee session", + targetId: "session-1", + contextLines: [ + "Workflow target: employee_session", + "Lane: lane-1", + "Session: session-1", + "Linked PR record: pr-99", + ], + prLinks: ["https://github.com/acme/repo/pull/99"], + artifactPaths: [ + ".ade/artifacts/chat-trace.zip", + "https://example.com/lane-proof.png", + "https://example.com/pr-proof.json", + ], + artifactMode: "links", + commentTemplate: null, + })); + }); + }); + +}); diff --git a/apps/desktop/src/main/services/cto/linearIntakeService.test.ts b/apps/desktop/src/main/services/cto/linearIntakeService.test.ts deleted file mode 100644 index d8da46d03..000000000 --- a/apps/desktop/src/main/services/cto/linearIntakeService.test.ts +++ /dev/null @@ -1,283 +0,0 @@ -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; -import { describe, expect, it, vi } from "vitest"; -import type { LinearWorkflowConfig, NormalizedLinearIssue } from "../../../shared/types"; -import { openKvDb } from "../state/kvDb"; -import { createLinearIntakeService } from "./linearIntakeService"; - -function createLogger() { - return { - debug: () => {}, - info: () => {}, - warn: () => {}, - error: () => {}, - } as any; -} - -const issueFixture: NormalizedLinearIssue = { - id: "issue-1", - identifier: "ABC-42", - title: "Fix flaky sync run", - description: "Occasional sync failure under load.", - url: "https://linear.app/acme/issue/ABC-42", - projectId: "proj-1", - projectSlug: "acme-platform", - teamId: "team-1", - teamKey: "ACME", - stateId: "state-todo", - stateName: "Todo", - stateType: "unstarted", - priority: 2, - priorityLabel: "high", - labels: ["bug"], - assigneeId: null, - assigneeName: "CTO", - ownerId: "owner-1", - blockerIssueIds: [], - hasOpenBlockers: false, - createdAt: "2026-03-05T00:00:00.000Z", - updatedAt: "2026-03-05T00:00:00.000Z", - raw: {}, -}; - -const secondIssue: NormalizedLinearIssue = { - ...issueFixture, - id: "issue-2", - identifier: "ABC-43", - title: "Add rate limiter", - priority: 1, - createdAt: "2026-03-04T00:00:00.000Z", - updatedAt: "2026-03-04T00:00:00.000Z", -}; - -const policy: LinearWorkflowConfig = { - version: 1, - source: "repo", - intake: { - projectSlugs: ["acme-platform"], - activeStateTypes: ["backlog", "unstarted", "started"], - terminalStateTypes: ["completed", "canceled"], - }, - settings: { ctoLinearAssigneeName: "CTO", ctoLinearAssigneeAliases: ["cto"] }, - workflows: [ - { - id: "flow-1", - name: "Flow 1", - enabled: true, - priority: 100, - triggers: { assignees: ["CTO"], projectSlugs: ["acme-platform"] }, - target: { type: "mission" }, - steps: [{ id: "launch", type: "launch_target" }], - }, - ], - files: [], - migration: { hasLegacyConfig: false, needsSave: false }, - legacyConfig: null, -}; - -async function createFixture() { - const root = fs.mkdtempSync(path.join(os.tmpdir(), "ade-linear-intake-")); - const adeDir = path.join(root, ".ade"); - fs.mkdirSync(adeDir, { recursive: true }); - const db = await openKvDb(path.join(adeDir, "ade.db"), createLogger()); - return { root, adeDir, db }; -} - -describe("linearIntakeService", () => { - it("fetches candidates, filters out blockers, and sorts by priority then createdAt", async () => { - const fixture = await createFixture(); - const blockedIssue: NormalizedLinearIssue = { - ...issueFixture, - id: "issue-blocked", - identifier: "ABC-99", - hasOpenBlockers: true, - priority: 0, - }; - const fetchCandidateIssues = vi.fn(async () => [blockedIssue, secondIssue, issueFixture]); - - const service = createLinearIntakeService({ - db: fixture.db, - projectId: "project-intake-test", - issueTracker: { - fetchCandidateIssues, - } as any, - }); - - const candidates = await service.fetchCandidates(policy); - - expect(fetchCandidateIssues).toHaveBeenCalledWith({ - projectSlugs: ["acme-platform"], - stateTypes: ["backlog", "unstarted", "started"], - }); - - // Blocked issue should be filtered out - expect(candidates.find((issue) => issue.id === "issue-blocked")).toBeUndefined(); - // Remaining should be sorted by priority (ascending), then createdAt - expect(candidates).toHaveLength(2); - expect(candidates[0]!.id).toBe("issue-2"); // priority 1 < priority 2 - expect(candidates[1]!.id).toBe("issue-1"); - - fixture.db.close(); - }); - - it("merges project slugs from intake, workflows, and legacy config", async () => { - const fixture = await createFixture(); - const fetchCandidateIssues = vi.fn(async () => []); - - const service = createLinearIntakeService({ - db: fixture.db, - projectId: "project-slug-merge", - issueTracker: { fetchCandidateIssues } as any, - }); - - const policyWithLegacy: LinearWorkflowConfig = { - ...policy, - intake: { - ...policy.intake, - projectSlugs: ["primary-project"], - }, - workflows: [ - { - id: "flow-extra", - name: "Extra flow", - enabled: true, - priority: 100, - triggers: { projectSlugs: ["extra-project"] }, - target: { type: "mission" }, - steps: [{ id: "launch", type: "launch_target" }], - }, - ], - legacyConfig: { - enabled: true, - projects: [{ slug: "legacy-project" }], - }, - }; - - await service.fetchCandidates(policyWithLegacy); - - const calledWith = (fetchCandidateIssues.mock.calls as any)[0][0] as { projectSlugs: string[] }; - expect(calledWith.projectSlugs).toContain("primary-project"); - expect(calledWith.projectSlugs).toContain("extra-project"); - expect(calledWith.projectSlugs).toContain("legacy-project"); - - fixture.db.close(); - }); - - it("attaches previous state info from persisted snapshots", async () => { - const fixture = await createFixture(); - const projectId = "project-previous-state"; - - // Pre-persist a snapshot so the service finds previous state - const now = new Date().toISOString(); - fixture.db.run( - ` - insert into linear_issue_snapshots( - id, project_id, issue_id, identifier, state_type, assignee_id, updated_at_linear, payload_json, hash, created_at, updated_at - ) - values(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - `, - [ - `${projectId}:${issueFixture.id}`, - projectId, - issueFixture.id, - issueFixture.identifier, - "backlog", - null, - issueFixture.updatedAt, - JSON.stringify({ ...issueFixture, stateId: "state-backlog", stateName: "Backlog", stateType: "backlog" }), - "old-hash", - now, - now, - ] - ); - - const service = createLinearIntakeService({ - db: fixture.db, - projectId, - issueTracker: { - fetchCandidateIssues: vi.fn(async () => [issueFixture]), - } as any, - }); - - const candidates = await service.fetchCandidates(policy); - expect(candidates).toHaveLength(1); - expect(candidates[0]!.previousStateType).toBe("backlog"); - expect(candidates[0]!.previousStateName).toBe("Backlog"); - - fixture.db.close(); - }); - - it("persistSnapshot inserts a new row and updates an existing one", async () => { - const fixture = await createFixture(); - const projectId = "project-persist-test"; - - const service = createLinearIntakeService({ - db: fixture.db, - projectId, - issueTracker: { - fetchCandidateIssues: vi.fn(async () => []), - } as any, - }); - - // First persist: insert - service.persistSnapshot(issueFixture); - const row1 = fixture.db.get<{ issue_id: string; state_type: string }>( - `select issue_id, state_type from linear_issue_snapshots where project_id = ? and issue_id = ?`, - [projectId, issueFixture.id] - ); - expect(row1, "First persist should create a row").toBeTruthy(); - expect(row1!.issue_id).toBe(issueFixture.id); - expect(row1!.state_type).toBe("unstarted"); - - // Second persist: update - const updatedIssue = { ...issueFixture, stateType: "started" as const, stateName: "In Progress" }; - service.persistSnapshot(updatedIssue); - const row2 = fixture.db.get<{ state_type: string }>( - `select state_type from linear_issue_snapshots where project_id = ? and issue_id = ?`, - [projectId, issueFixture.id] - ); - expect(row2!.state_type).toBe("started"); - - fixture.db.close(); - }); - - it("issueHash produces consistent deterministic output", async () => { - const fixture = await createFixture(); - - const service = createLinearIntakeService({ - db: fixture.db, - projectId: "project-hash-test", - issueTracker: { - fetchCandidateIssues: vi.fn(async () => []), - } as any, - }); - - const hash1 = service.issueHash(issueFixture); - const hash2 = service.issueHash(issueFixture); - expect(hash1).toBe(hash2); - expect(hash1).toHaveLength(64); // sha256 hex - - const hash3 = service.issueHash({ ...issueFixture, title: "Different title" }); - expect(hash3).not.toBe(hash1); - - fixture.db.close(); - }); - - it("returns empty array when no issues match the query", async () => { - const fixture = await createFixture(); - - const service = createLinearIntakeService({ - db: fixture.db, - projectId: "project-empty", - issueTracker: { - fetchCandidateIssues: vi.fn(async () => []), - } as any, - }); - - const candidates = await service.fetchCandidates(policy); - expect(candidates).toEqual([]); - - fixture.db.close(); - }); -}); diff --git a/apps/desktop/src/main/services/cto/linearOAuthService.test.ts b/apps/desktop/src/main/services/cto/linearOAuthService.test.ts deleted file mode 100644 index 367199245..000000000 --- a/apps/desktop/src/main/services/cto/linearOAuthService.test.ts +++ /dev/null @@ -1,363 +0,0 @@ -import http from "node:http"; -import { describe, expect, it, vi, afterEach } from "vitest"; -import { createLinearOAuthService } from "./linearOAuthService"; - -function createLogger() { - return { - debug: () => {}, - info: () => {}, - warn: vi.fn(), - error: () => {}, - } as any; -} - -function createCredentialsMock(overrides?: { - clientSecret?: string | null; -}) { - return { - getOAuthClientCredentials: vi.fn(() => ({ - clientId: "test-client-id", - clientSecret: overrides?.clientSecret ?? "test-client-secret", - })), - setOAuthToken: vi.fn(), - }; -} - -/** - * HTTP GET that tolerates early server close. - * - * The OAuth service calls `server.close()` immediately after writing - * its response in error paths. Node's http client may see a socket - * hang-up before the response is fully consumed. We capture whatever - * status code was received; if none, resolve with statusCode 0 so - * tests can still assert on session state via `getSession`. - */ -function httpGet(url: string): Promise<{ statusCode: number; body: string }> { - return new Promise((resolve) => { - const parsed = new URL(url); - let resolved = false; - let statusCode = 0; - let body = ""; - - const req = http.request( - { - hostname: parsed.hostname, - port: parsed.port, - path: `${parsed.pathname}${parsed.search}`, - method: "GET", - }, - (res) => { - statusCode = res.statusCode ?? 0; - res.on("data", (chunk) => { body += chunk; }); - res.on("end", () => { - if (!resolved) { resolved = true; resolve({ statusCode, body }); } - }); - res.on("error", () => { - if (!resolved) { resolved = true; resolve({ statusCode, body }); } - }); - } - ); - req.on("error", () => { - // Server closed before we could read the full response. - if (!resolved) { resolved = true; resolve({ statusCode, body }); } - }); - req.setTimeout(5000, () => { - req.destroy(); - if (!resolved) { resolved = true; resolve({ statusCode: 0, body: "" }); } - }); - req.end(); - }); -} - -const activeServices: Array> = []; - -function waitMs(ms: number): Promise { - return new Promise((resolve) => setTimeout(resolve, ms)); -} - -async function waitForSessionStatus( - service: ReturnType, - sessionId: string, - expectedStatus: string, - timeoutMs = 3000, -): Promise { - const deadline = Date.now() + timeoutMs; - while (Date.now() < deadline) { - const session = service.getSession(sessionId); - if (session.status === expectedStatus) return; - await waitMs(10); - } - // Final check that will throw a clear assertion error - const session = service.getSession(sessionId); - expect(session.status, `Timed out waiting for session ${sessionId} to reach status '${expectedStatus}'`).toBe(expectedStatus); -} - -afterEach(async () => { - for (const svc of activeServices) { - svc.dispose(); - } - activeServices.length = 0; - // Allow port to fully release between tests - await waitMs(50); -}); - -describe("linearOAuthService", () => { - it("throws when OAuth client credentials are not configured", async () => { - const service = createLinearOAuthService({ - credentials: { - getOAuthClientCredentials: vi.fn(() => null), - setOAuthToken: vi.fn(), - } as any, - logger: createLogger(), - }); - activeServices.push(service); - - await expect(service.startSession()).rejects.toThrow("not configured"); - }); - - it("starts a session and returns a valid authUrl with required OAuth params", async () => { - const credentials = createCredentialsMock(); - const service = createLinearOAuthService({ - credentials: credentials as any, - logger: createLogger(), - }); - activeServices.push(service); - - const result = await service.startSession(); - - expect(result.sessionId).toBeTruthy(); - expect(result.sessionId.startsWith("linear-oauth-")).toBe(true); - expect(result.authUrl).toContain("linear.app/oauth/authorize"); - expect(result.authUrl).toContain("client_id=test-client-id"); - expect(result.authUrl).toContain("response_type=code"); - expect(result.authUrl).toContain("scope=read"); - expect(result.authUrl).toContain("prompt=consent"); - expect(result.redirectUri).toContain("/oauth/callback"); - - const session = service.getSession(result.sessionId); - expect(session.status).toBe("pending"); - expect(session.error).toBeNull(); - }); - - it("getSession returns expired for unknown session id", () => { - const credentials = createCredentialsMock(); - const service = createLinearOAuthService({ - credentials: credentials as any, - logger: createLogger(), - }); - activeServices.push(service); - - const session = service.getSession("nonexistent-session"); - expect(session.status).toBe("expired"); - expect(session.error).toContain("not found"); - }); - - it("exchanges authorization code for access token via the callback", async () => { - const credentials = createCredentialsMock(); - const mockFetch = vi.fn(async () => ({ - ok: true, - status: 200, - json: async () => ({ - access_token: "linear-access-token-123", - refresh_token: "linear-refresh-token-456", - expires_in: 3600, - }), - })) as any; - - const service = createLinearOAuthService({ - credentials: credentials as any, - logger: createLogger(), - fetchImpl: mockFetch, - }); - activeServices.push(service); - - const { sessionId, authUrl, redirectUri } = await service.startSession(); - - // Extract the state parameter from the authUrl - const stateParam = new URL(authUrl).searchParams.get("state")!; - expect(stateParam).toBeTruthy(); - - // Simulate the OAuth callback - const callbackUrl = `${redirectUri}?code=test-code-123&state=${stateParam}`; - const response = await httpGet(callbackUrl); - - expect(response.statusCode).toBe(200); - expect(response.body).toContain("Linear connected"); - - expect(mockFetch).toHaveBeenCalledTimes(1); - const fetchCall = mockFetch.mock.calls[0]![1] as { body: string }; - expect(fetchCall.body).toContain("code=test-code-123"); - expect(fetchCall.body).toContain("client_id=test-client-id"); - expect(fetchCall.body).toContain("client_secret=test-client-secret"); - - expect(credentials.setOAuthToken).toHaveBeenCalledWith( - expect.objectContaining({ - accessToken: "linear-access-token-123", - refreshToken: "linear-refresh-token-456", - }) - ); - - const session = service.getSession(sessionId); - expect(session.status).toBe("completed"); - expect(session.error).toBeNull(); - }); - - it("handles OAuth callback with error parameter from Linear", async () => { - const credentials = createCredentialsMock(); - const service = createLinearOAuthService({ - credentials: credentials as any, - logger: createLogger(), - }); - activeServices.push(service); - - const { sessionId, authUrl, redirectUri } = await service.startSession(); - const stateParam = new URL(authUrl).searchParams.get("state")!; - - const callbackUrl = `${redirectUri}?error=access_denied&error_description=User+declined&state=${stateParam}`; - await httpGet(callbackUrl); - - // The server may close before the HTTP response is fully consumed, - // so we wait for the session state to transition. - await waitForSessionStatus(service, sessionId, "failed"); - const session = service.getSession(sessionId); - expect(session.error).toContain("User declined"); - }); - - it("handles OAuth callback with state mismatch", async () => { - const credentials = createCredentialsMock(); - const service = createLinearOAuthService({ - credentials: credentials as any, - logger: createLogger(), - }); - activeServices.push(service); - - const { sessionId, redirectUri } = await service.startSession(); - - const callbackUrl = `${redirectUri}?code=test-code&state=wrong-state`; - await httpGet(callbackUrl); - - await waitForSessionStatus(service, sessionId, "failed"); - const session = service.getSession(sessionId); - expect(session.error).toContain("state did not match"); - }); - - it("handles OAuth callback without authorization code", async () => { - const credentials = createCredentialsMock(); - const service = createLinearOAuthService({ - credentials: credentials as any, - logger: createLogger(), - }); - activeServices.push(service); - - const { sessionId, authUrl, redirectUri } = await service.startSession(); - const stateParam = new URL(authUrl).searchParams.get("state")!; - - const callbackUrl = `${redirectUri}?state=${stateParam}`; - await httpGet(callbackUrl); - - await waitForSessionStatus(service, sessionId, "failed"); - const session = service.getSession(sessionId); - expect(session.error).toContain("did not include an authorization code"); - }); - - it("handles token exchange failure gracefully", async () => { - const credentials = createCredentialsMock(); - const mockFetch = vi.fn(async () => ({ - ok: false, - status: 400, - json: async () => ({ - error: "invalid_grant", - error_description: "The authorization code has expired.", - }), - })) as any; - - const service = createLinearOAuthService({ - credentials: credentials as any, - logger: createLogger(), - fetchImpl: mockFetch, - }); - activeServices.push(service); - - const { sessionId, authUrl, redirectUri } = await service.startSession(); - const stateParam = new URL(authUrl).searchParams.get("state")!; - - const callbackUrl = `${redirectUri}?code=expired-code&state=${stateParam}`; - await httpGet(callbackUrl); - - await waitForSessionStatus(service, sessionId, "failed"); - const session = service.getSession(sessionId); - expect(session.error).toContain("expired"); - }); - - it("supersedes previous pending sessions when starting a new one", async () => { - const credentials = createCredentialsMock(); - const service = createLinearOAuthService({ - credentials: credentials as any, - logger: createLogger(), - }); - activeServices.push(service); - - const first = await service.startSession(); - expect(service.getSession(first.sessionId).status).toBe("pending"); - - const second = await service.startSession(); - expect(service.getSession(second.sessionId).status).toBe("pending"); - - // First session should be superseded - const firstStatus = service.getSession(first.sessionId); - expect(firstStatus.status).toBe("expired"); - expect(firstStatus.error).toContain("Superseded"); - }); - - it("dispose clears all sessions and closes servers", async () => { - const credentials = createCredentialsMock(); - const service = createLinearOAuthService({ - credentials: credentials as any, - logger: createLogger(), - }); - // Do NOT push to activeServices since we call dispose manually - const { sessionId } = await service.startSession(); - - service.dispose(); - - const session = service.getSession(sessionId); - expect(session.status).toBe("expired"); - }); - - it("uses PKCE flow when no client secret is provided", async () => { - const credentials = createCredentialsMock({ clientSecret: null }); - credentials.getOAuthClientCredentials.mockReturnValue({ - clientId: "public-client-id", - clientSecret: null as any, - }); - - const service = createLinearOAuthService({ - credentials: credentials as any, - logger: createLogger(), - }); - activeServices.push(service); - - const result = await service.startSession(); - const authUrl = new URL(result.authUrl); - - expect(authUrl.searchParams.get("code_challenge_method")).toBe("S256"); - expect(authUrl.searchParams.get("code_challenge")).toBeTruthy(); - expect(authUrl.searchParams.get("client_id")).toBe("public-client-id"); - }); - - it("does not use PKCE when client secret is provided", async () => { - const credentials = createCredentialsMock(); - const service = createLinearOAuthService({ - credentials: credentials as any, - logger: createLogger(), - }); - activeServices.push(service); - - const result = await service.startSession(); - const authUrl = new URL(result.authUrl); - - // PKCE params should not be present when client_secret is available - expect(authUrl.searchParams.get("code_challenge_method")).toBeNull(); - expect(authUrl.searchParams.get("code_challenge")).toBeNull(); - }); -}); diff --git a/apps/desktop/src/main/services/cto/linearOutboundService.test.ts b/apps/desktop/src/main/services/cto/linearOutboundService.test.ts deleted file mode 100644 index ab4c9fb56..000000000 --- a/apps/desktop/src/main/services/cto/linearOutboundService.test.ts +++ /dev/null @@ -1,367 +0,0 @@ -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; -import { pathToFileURL } from "node:url"; -import { describe, expect, it, vi } from "vitest"; -import type { NormalizedLinearIssue } from "../../../shared/types"; -import { openKvDb } from "../state/kvDb"; -import { createLinearOutboundService } from "./linearOutboundService"; - -function createLogger() { - return { - debug: () => {}, - info: () => {}, - warn: () => {}, - error: () => {}, - } as any; -} - -const issueFixture: NormalizedLinearIssue = { - id: "issue-1", - identifier: "ABC-12", - title: "Stabilize auth refresh", - description: "Auth refresh sometimes fails after idle timeout.", - url: "https://linear.app/acme/issue/ABC-12", - projectId: "proj-1", - projectSlug: "acme-platform", - teamId: "team-1", - teamKey: "ACME", - stateId: "state-1", - stateName: "Todo", - stateType: "unstarted", - priority: 2, - priorityLabel: "high", - labels: ["bug"], - assigneeId: null, - assigneeName: null, - ownerId: "user-1", - blockerIssueIds: [], - hasOpenBlockers: false, - createdAt: "2026-03-05T00:00:00.000Z", - updatedAt: "2026-03-05T00:00:00.000Z", - raw: {}, -}; - -describe("linearOutboundService", () => { - it("keeps one persistent workpad comment and avoids duplicate updates for identical body", async () => { - const root = fs.mkdtempSync(path.join(os.tmpdir(), "ade-linear-outbound-")); - const db = await openKvDb(path.join(root, "ade.db"), createLogger()); - - const createComment = vi.fn(async () => ({ commentId: "comment-1" })); - const updateComment = vi.fn(async () => {}); - const service = createLinearOutboundService({ - db, - projectId: "project-1", - projectRoot: root, - issueTracker: { - createComment, - updateComment, - uploadAttachment: vi.fn(), - } as any, - logger: createLogger(), - }); - - await service.publishMissionStart({ - issue: issueFixture, - missionId: "mission-1", - missionTitle: "Fix auth refresh", - templateId: "bug-fix", - routeReason: "Matched bug rule", - workerName: "Backend Dev", - }); - expect(createComment).toHaveBeenCalledTimes(1); - - await service.publishMissionProgress({ - issue: issueFixture, - missionId: "mission-1", - status: "in_progress", - stepSummary: "1/3 steps completed.", - }); - await service.publishMissionProgress({ - issue: issueFixture, - missionId: "mission-1", - status: "in_progress", - stepSummary: "1/3 steps completed.", - }); - - expect(updateComment).toHaveBeenCalledTimes(1); - db.close(); - }); - - it("reuses the same workpad comment across workflow launch, progress, and final closeout", async () => { - const root = fs.mkdtempSync(path.join(os.tmpdir(), "ade-linear-workpad-lifecycle-")); - const db = await openKvDb(path.join(root, "ade.db"), createLogger()); - const createComment = vi.fn(async () => ({ commentId: "comment-workpad-1" })); - const updateBodies: string[] = []; - const updateComment = vi.fn(async (_commentId: string, body: string) => { - updateBodies.push(body); - }); - const service = createLinearOutboundService({ - db, - projectId: "project-1", - projectRoot: root, - issueTracker: { - createComment, - updateComment, - uploadAttachment: vi.fn(), - } as any, - logger: createLogger(), - }); - - await service.publishWorkflowStatus({ - issue: issueFixture, - workflowName: "Assigned worker run", - runId: "run-1", - targetType: "worker_run", - state: "waiting_for_target", - currentStep: "Launch worker run", - delegatedOwner: "backend-dev", - laneId: "lane-22", - workerRunId: "worker-run-22", - note: "Delegated the issue into a dedicated worker lane.", - }); - await service.publishWorkflowStatus({ - issue: issueFixture, - workflowName: "Assigned worker run", - runId: "run-1", - targetType: "worker_run", - state: "waiting_for_pr", - currentStep: "Wait for PR", - delegatedOwner: "backend-dev", - laneId: "lane-22", - workerRunId: "worker-run-22", - prId: "pr-22", - waitingFor: "review-ready PR", - note: "PR linked and awaiting review-ready state.", - }); - await service.publishWorkflowCloseout({ - issue: issueFixture, - status: "completed", - summary: "Validated proof and closed out the delegated workflow.", - targetLabel: "worker run", - targetId: "worker-run-22", - contextLines: ["Lane: lane-22", "Linked PR record: pr-22"], - prLinks: ["https://github.com/acme/repo/pull/22"], - artifactMode: "links", - }); - - expect(createComment).toHaveBeenCalledTimes(1); - expect(updateComment).toHaveBeenCalledTimes(2); - expect(updateBodies[0]).toContain("- Lane: lane-22"); - expect(updateBodies[0]).toContain("- Worker run: worker-run-22"); - expect(updateBodies[1]).toContain("### Closeout Summary"); - expect(updateBodies[1]).toContain("https://github.com/acme/repo/pull/22"); - - const stored = db.get<{ comment_id: string }>( - `select comment_id from linear_workpads where project_id = ? and issue_id = ? limit 1`, - ["project-1", issueFixture.id] - ); - expect(stored?.comment_id).toBe("comment-workpad-1"); - db.close(); - }); - - it("preserves inside-project artifact links and rejects files outside the project root", async () => { - const root = fs.mkdtempSync(path.join(os.tmpdir(), "ade-linear-artifacts-")); - const db = await openKvDb(path.join(root, "ade.db"), createLogger()); - const insideArtifact = path.join(root, "build.log"); - fs.writeFileSync(insideArtifact, "log", "utf8"); - const outsideArtifact = path.join(os.tmpdir(), "outside.log"); - fs.writeFileSync(outsideArtifact, "outside", "utf8"); - const insideCanonicalUri = pathToFileURL(fs.realpathSync(insideArtifact)).href; - const outsideCanonicalUri = pathToFileURL(fs.realpathSync(outsideArtifact)).href; - const insideUri = pathToFileURL(insideArtifact).href; - const outsideUri = pathToFileURL(outsideArtifact).href; - - const updateBodies: string[] = []; - const service = createLinearOutboundService({ - db, - projectId: "project-1", - projectRoot: root, - issueTracker: { - createComment: vi.fn(async () => ({ commentId: "comment-1" })), - updateComment: vi.fn(async (_commentId: string, body: string) => { - updateBodies.push(body); - }), - uploadAttachment: vi.fn(), - } as any, - logger: createLogger(), - }); - - await service.publishMissionStart({ - issue: issueFixture, - missionId: "mission-1", - missionTitle: "Auth mission", - templateId: "bug-fix", - routeReason: "Matched bug", - }); - - await service.publishWorkflowCloseout({ - issue: issueFixture, - status: "completed", - summary: "Shipped", - targetLabel: "employee session", - targetId: "session-1", - contextLines: ["Workflow target: employee_session"], - artifactMode: "links", - artifactPaths: [insideArtifact, insideUri, outsideArtifact, outsideUri, "https://example.com/artifact.txt"], - }); - - const latest = updateBodies[updateBodies.length - 1] ?? ""; - expect(latest).toContain(insideCanonicalUri); - expect(latest).toContain("https://example.com/artifact.txt"); - expect(latest).not.toContain(outsideCanonicalUri); - db.close(); - }); - - it("uploads attachment-mode artifacts from inside the project root and skips files outside it", async () => { - const root = fs.mkdtempSync(path.join(os.tmpdir(), "ade-linear-attachment-upload-")); - const db = await openKvDb(path.join(root, "ade.db"), createLogger()); - const insideArtifact = path.join(root, "inside.png"); - const outsideArtifact = path.join(os.tmpdir(), `ade-outside-${Date.now()}.png`); - fs.writeFileSync(insideArtifact, "inside", "utf8"); - fs.writeFileSync(outsideArtifact, "outside", "utf8"); - - const uploadAttachment = vi.fn(async ({ filePath }: { filePath: string }) => ({ - url: `https://linear.example/${path.basename(filePath)}`, - })); - const updateBodies: string[] = []; - const service = createLinearOutboundService({ - db, - projectId: "project-1", - projectRoot: root, - issueTracker: { - createComment: vi.fn(async () => ({ commentId: "comment-1" })), - updateComment: vi.fn(async (_commentId: string, body: string) => { - updateBodies.push(body); - }), - uploadAttachment, - } as any, - logger: createLogger(), - }); - - await service.publishMissionStart({ - issue: issueFixture, - missionId: "mission-1", - missionTitle: "Auth mission", - templateId: "bug-fix", - routeReason: "Matched bug", - }); - - await service.publishWorkflowCloseout({ - issue: issueFixture, - status: "completed", - summary: "Uploaded proof artifacts.", - targetLabel: "employee session", - targetId: "session-1", - artifactMode: "attachments", - artifactPaths: [insideArtifact, outsideArtifact], - }); - - expect(uploadAttachment).toHaveBeenCalledTimes(1); - expect(uploadAttachment).toHaveBeenCalledWith({ - issueId: issueFixture.id, - filePath: insideArtifact, - title: path.basename(insideArtifact), - }); - - const latest = updateBodies[updateBodies.length - 1] ?? ""; - expect(latest).toContain(`https://linear.example/${path.basename(insideArtifact)}`); - expect(latest).not.toContain(`https://linear.example/${path.basename(outsideArtifact)}`); - db.close(); - }); - - it("keeps the mission closeout wrapper body shape stable", async () => { - const root = fs.mkdtempSync(path.join(os.tmpdir(), "ade-linear-mission-closeout-")); - const db = await openKvDb(path.join(root, "ade.db"), createLogger()); - const updateBodies: string[] = []; - const service = createLinearOutboundService({ - db, - projectId: "project-1", - projectRoot: root, - issueTracker: { - createComment: vi.fn(async () => ({ commentId: "comment-1" })), - updateComment: vi.fn(async (_commentId: string, body: string) => { - updateBodies.push(body); - }), - uploadAttachment: vi.fn(), - } as any, - logger: createLogger(), - }); - - await service.publishMissionStart({ - issue: issueFixture, - missionId: "mission-1", - missionTitle: "Auth mission", - templateId: "bug-fix", - routeReason: "Matched bug", - }); - - await service.publishMissionCloseout({ - issue: issueFixture, - missionId: "mission-1", - status: "completed", - summary: "Shipped", - artifactMode: "links", - }); - - const latest = updateBodies[updateBodies.length - 1] ?? ""; - expect(latest).toContain("- Mission: mission-1"); - expect(latest).not.toContain("- Target:"); - db.close(); - }); - - it("renders comment templates for workflow status and closeout bodies", async () => { - const root = fs.mkdtempSync(path.join(os.tmpdir(), "ade-linear-template-comment-")); - const db = await openKvDb(path.join(root, "ade.db"), createLogger()); - const createBodies: string[] = []; - const bodies: string[] = []; - const service = createLinearOutboundService({ - db, - projectId: "project-1", - projectRoot: root, - issueTracker: { - createComment: vi.fn(async (_issueId: string, body: string) => { - createBodies.push(body); - return { commentId: "comment-1" }; - }), - updateComment: vi.fn(async (_commentId: string, body: string) => { - bodies.push(body); - }), - uploadAttachment: vi.fn(), - } as any, - logger: createLogger(), - }); - - await service.publishWorkflowStatus({ - issue: issueFixture, - workflowName: "Assigned worker run", - runId: "run-7", - targetType: "worker_run", - state: "waiting_for_target", - note: "Delegated the issue.", - waitingFor: "delegated work", - commentTemplate: [ - "Issue {{ issue.identifier }}", - "Workflow {{ workflow.name }}", - "Target {{ target.type }}", - "Note {{ note }}", - ].join("\n"), - }); - - await service.publishWorkflowCloseout({ - issue: issueFixture, - status: "completed", - summary: "Closed.", - targetLabel: "worker run", - targetId: "worker-22", - artifactMode: "links", - commentTemplate: "Closeout {{ issue.identifier }} {{ target.id }} {{ note }}", - }); - - expect(createBodies[0]).toContain("Issue ABC-12"); - expect(createBodies[0]).toContain("Workflow Assigned worker run"); - expect(createBodies[0]).toContain("Target worker_run"); - expect(bodies[0]).toContain("Closeout ABC-12 worker-22 Closed."); - db.close(); - }); -}); diff --git a/apps/desktop/src/main/services/cto/linearRoutingService.test.ts b/apps/desktop/src/main/services/cto/linearRoutingService.test.ts deleted file mode 100644 index 594bda483..000000000 --- a/apps/desktop/src/main/services/cto/linearRoutingService.test.ts +++ /dev/null @@ -1,144 +0,0 @@ -import { describe, expect, it } from "vitest"; -import type { LinearWorkflowConfig, NormalizedLinearIssue } from "../../../shared/types"; -import { createLinearRoutingService } from "./linearRoutingService"; - -const baseIssue: NormalizedLinearIssue = { - id: "issue-1", - identifier: "ABC-10", - title: "Fix login bug", - description: "Users cannot login when refresh token is stale.", - url: null, - projectId: "proj-1", - projectSlug: "acme-platform", - teamId: "team-1", - teamKey: "ACME", - stateId: "state-1", - stateName: "Todo", - stateType: "unstarted", - previousStateId: "state-backlog", - previousStateName: "Backlog", - previousStateType: "backlog", - priority: 2, - priorityLabel: "high", - labels: ["bug", "fast-lane"], - metadataTags: ["ui"], - assigneeId: null, - assigneeName: "CTO", - ownerId: "owner-1", - creatorId: "creator-1", - creatorName: "Taylor", - blockerIssueIds: [], - hasOpenBlockers: false, - createdAt: "2026-03-05T00:00:00.000Z", - updatedAt: "2026-03-05T00:00:00.000Z", - raw: {}, -}; - -function buildPolicy(): LinearWorkflowConfig { - return { - version: 1, - source: "repo", - intake: { - projectSlugs: ["acme-platform"], - activeStateTypes: ["backlog", "unstarted", "started"], - terminalStateTypes: ["completed", "canceled"], - }, - settings: { ctoLinearAssigneeName: "CTO", ctoLinearAssigneeAliases: ["cto"] }, - workflows: [ - { - id: "review", - name: "Review gate", - enabled: true, - priority: 50, - triggers: { assignees: ["CTO"], labels: ["needs-triage"] }, - target: { type: "review_gate" }, - steps: [{ id: "launch", type: "launch_target" }], - }, - { - id: "fast-lane", - name: "PR fast lane", - enabled: true, - priority: 120, - triggers: { assignees: ["CTO"], labels: ["fast-lane"], priority: ["high"], projectSlugs: ["acme-platform"] }, - target: { type: "pr_resolution", runMode: "autopilot" }, - steps: [{ id: "launch", type: "launch_target", name: "Launch PR flow" }], - }, - ], - files: [], - migration: { hasLegacyConfig: false, needsSave: false }, - legacyConfig: null, - }; -} - -describe("linearRoutingService", () => { - it("returns all candidate explanations and picks the highest-priority match", async () => { - const policy = buildPolicy(); - const service = createLinearRoutingService({ - flowPolicyService: { - getPolicy: () => policy, - normalizePolicy: (input?: LinearWorkflowConfig) => input ?? policy, - } as any, - }); - - const decision = await service.routeIssue({ issue: baseIssue }); - expect(decision.workflowId).toBe("fast-lane"); - expect(decision.target?.type).toBe("pr_resolution"); - expect(decision.candidates).toHaveLength(2); - expect(decision.candidates.find((candidate) => candidate.workflowId === "review")?.matched).toBe(false); - }); - - it("explains when nothing matched", async () => { - const policy = buildPolicy(); - const service = createLinearRoutingService({ - flowPolicyService: { - getPolicy: () => policy, - normalizePolicy: (input?: LinearWorkflowConfig) => input ?? policy, - } as any, - }); - - const decision = await service.routeIssue({ - issue: { ...baseIssue, labels: ["bug"], assigneeName: "Someone Else" }, - }); - expect(decision.workflowId).toBeNull(); - expect(decision.reason).toContain("No workflow matched"); - }); - - it("requires both assignee and workflow label, while allowing employee identity mappings", async () => { - const policy = buildPolicy(); - policy.workflows[1] = { - ...policy.workflows[1]!, - triggers: { - ...policy.workflows[1]!.triggers, - assignees: ["agent-1"], - }, - }; - const service = createLinearRoutingService({ - flowPolicyService: { - getPolicy: () => policy, - normalizePolicy: (input?: LinearWorkflowConfig) => input ?? policy, - } as any, - workerAgentService: { - listAgents: () => [ - { - id: "agent-1", - slug: "backend-dev", - name: "Backend Dev", - linearIdentity: { userIds: ["user-1"], displayNames: ["Alex Johnson"], aliases: ["alex"] }, - }, - ], - } as any, - }); - - const missingLabel = await service.routeIssue({ - issue: { ...baseIssue, assigneeId: "user-1", assigneeName: "Alex Johnson", labels: ["bug"] }, - }); - expect(missingLabel.workflowId).toBeNull(); - expect(missingLabel.candidates[1]?.missingSignals).toContain("Missing label"); - - const matched = await service.routeIssue({ - issue: { ...baseIssue, assigneeId: "user-1", assigneeName: "Alex Johnson", labels: ["fast-lane"] }, - }); - expect(matched.workflowId).toBe("fast-lane"); - expect(matched.simulation?.explainsAndAcrossFields).toBe(true); - }); -}); diff --git a/apps/desktop/src/main/services/cto/linearSync.test.ts b/apps/desktop/src/main/services/cto/linearSync.test.ts new file mode 100644 index 000000000..4a00bc6d8 --- /dev/null +++ b/apps/desktop/src/main/services/cto/linearSync.test.ts @@ -0,0 +1,3152 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import type { LinearSyncConfig } from "../../../shared/types"; +import type { LinearWorkflowConfig, LinearWorkflowMatchResult, NormalizedLinearIssue } from "../../../shared/types"; +import type { LinearWorkflowConfig, NormalizedLinearIssue } from "../../../shared/types"; +import type { NormalizedLinearIssue } from "../../../shared/types"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { createLinearCloseoutService } from "./linearCloseoutService"; +import { createLinearDispatcherService } from "./linearDispatcherService"; +import { createLinearOutboundService } from "./linearOutboundService"; +import { createLinearSyncService } from "./linearSyncService"; +import { createLinearTemplateService } from "./linearTemplateService"; +import { createLinearWorkflowFileService } from "./linearWorkflowFileService"; +import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; +import { openKvDb } from "../state/kvDb"; +import { pathToFileURL } from "node:url"; + +describe("linearSyncService (file group)", () => { + + const issueFixture: NormalizedLinearIssue = { + id: "issue-1", + identifier: "ABC-42", + title: "Fix flaky sync run", + description: "Occasional sync failure under load.", + url: "https://linear.app/acme/issue/ABC-42", + projectId: "proj-1", + projectSlug: "acme-platform", + teamId: "team-1", + teamKey: "ACME", + stateId: "state-todo", + stateName: "Todo", + stateType: "unstarted", + priority: 2, + priorityLabel: "high", + labels: ["bug"], + assigneeId: null, + assigneeName: "CTO", + ownerId: "owner-1", + creatorId: "creator-1", + creatorName: "Taylor", + blockerIssueIds: [], + hasOpenBlockers: false, + createdAt: "2026-03-05T00:00:00.000Z", + updatedAt: "2026-03-05T00:00:00.000Z", + raw: {}, + }; + + const policy: LinearWorkflowConfig = { + version: 1, + source: "repo", + intake: { + projectSlugs: ["acme-platform"], + activeStateTypes: ["backlog", "unstarted", "started"], + terminalStateTypes: ["completed", "canceled"], + }, + settings: { ctoLinearAssigneeName: "CTO", ctoLinearAssigneeAliases: ["cto"] }, + workflows: [], + files: [], + migration: { hasLegacyConfig: false, needsSave: false }, + legacyConfig: null, + }; + + describe("linearSyncService", () => { + afterEach(() => { + vi.useRealTimers(); + }); + + it("runs an intake cycle and updates the dashboard heartbeat", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "ade-linear-sync-")); + const db = await openKvDb(path.join(root, "ade.db"), { debug() {}, info() {}, warn() {}, error() {} } as any); + const advanceRun = vi.fn(async () => null); + + const service = createLinearSyncService({ + db, + projectId: "project-1", + flowPolicyService: { getPolicy: () => policy } as any, + routingService: { + routeIssue: vi.fn(async () => ({ + workflowId: null, + workflowName: null, + workflow: null, + target: null, + reason: "No match", + candidates: [], + nextStepsPreview: [], + })), + } as any, + intakeService: { + fetchCandidates: vi.fn(async () => [ + { + ...issueFixture, + raw: { + _snapshotHash: "hash-1", + _previousSnapshotHash: "hash-0", + }, + }, + ]), + persistSnapshot: vi.fn(() => {}), + } as any, + issueTracker: { + fetchIssueById: vi.fn(async () => issueFixture), + } as any, + dispatcherService: { + hasActiveRuns: vi.fn(() => false), + findActiveRunForIssue: vi.fn(() => null), + createRun: vi.fn(), + advanceRun, + listActiveRuns: vi.fn(() => []), + listQueue: vi.fn(() => []), + resolveRunAction: vi.fn(), + } as any, + autoStart: false, + }); + + await service.runSyncNow(); + expect(service.getDashboard().lastSuccessAt).toBeTruthy(); + expect(advanceRun).not.toHaveBeenCalled(); + db.close(); + }); + + it("buffers webhook issue updates while a sync cycle is in flight and replays them once", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "ade-linear-sync-buffer-")); + const db = await openKvDb(path.join(root, "ade.db"), { debug() {}, info() {}, warn() {}, error() {} } as any); + let resolveAdvance: (() => void) | null = null; + let allowActiveRun = true; + const advanceRun = vi.fn(async () => { + if (!allowActiveRun) return null; + return await new Promise((resolve) => { + resolveAdvance = () => { + allowActiveRun = false; + resolve(null); + }; + }); + }); + const fetchIssueById = vi.fn(async (issueId: string) => + issueId === "issue-2" + ? { ...issueFixture, id: "issue-2", identifier: "ABC-43" } + : issueFixture + ); + + const service = createLinearSyncService({ + db, + projectId: "project-1", + flowPolicyService: { getPolicy: () => policy } as any, + routingService: { + routeIssue: vi.fn(async () => ({ + workflowId: null, + workflowName: null, + workflow: null, + target: null, + reason: "No match", + candidates: [], + nextStepsPreview: [], + })), + } as any, + intakeService: { + fetchCandidates: vi.fn(async () => []), + persistSnapshot: vi.fn(() => {}), + issueHash: vi.fn(() => "hash-current"), + } as any, + issueTracker: { + fetchIssueById, + } as any, + dispatcherService: { + hasActiveRuns: vi.fn(() => allowActiveRun), + findActiveRunForIssue: vi.fn(() => null), + createRun: vi.fn(), + advanceRun, + listActiveRuns: vi.fn(() => ( + allowActiveRun + ? [{ + id: "run-1", + issueId: "issue-1", + workflowId: "workflow-1", + status: "in_progress", + retryAfter: null, + reviewState: "approved", + }] + : [] + )), + listQueue: vi.fn(() => []), + resolveRunAction: vi.fn(), + } as any, + autoStart: false, + }); + + const cycle = service.runSyncNow(); + await service.processIssueUpdate("issue-1"); + await service.processIssueUpdate("issue-1"); + await service.processIssueUpdate("issue-2"); + if (!resolveAdvance) { + throw new Error("Expected the reconciliation cycle to be blocked."); + } + (resolveAdvance as () => void)(); + + await cycle; + + expect(fetchIssueById).toHaveBeenCalledTimes(2); + expect(fetchIssueById).toHaveBeenNthCalledWith(1, "issue-1"); + expect(fetchIssueById).toHaveBeenNthCalledWith(2, "issue-2"); + db.close(); + }); + + it("retains buffered issue updates that fail during replay for a later retry", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "ade-linear-sync-buffer-retry-")); + const db = await openKvDb(path.join(root, "ade.db"), { debug() {}, info() {}, warn() {}, error() {} } as any); + let resolveAdvance: (() => void) | null = null; + let allowActiveRun = true; + let shouldFailReplay = true; + const advanceRun = vi.fn(async () => { + if (!allowActiveRun) return null; + return await new Promise((resolve) => { + resolveAdvance = () => { + allowActiveRun = false; + resolve(null); + }; + }); + }); + const fetchIssueById = vi.fn(async (issueId: string) => { + if (issueId === "issue-2" && shouldFailReplay) { + shouldFailReplay = false; + throw new Error("Temporary Linear failure"); + } + return { ...issueFixture, id: issueId, identifier: issueId === "issue-2" ? "ABC-43" : issueFixture.identifier }; + }); + + const service = createLinearSyncService({ + db, + projectId: "project-1", + flowPolicyService: { getPolicy: () => policy } as any, + routingService: { + routeIssue: vi.fn(async () => ({ + workflowId: null, + workflowName: null, + workflow: null, + target: null, + reason: "No match", + candidates: [], + nextStepsPreview: [], + })), + } as any, + intakeService: { + fetchCandidates: vi.fn(async () => []), + persistSnapshot: vi.fn(() => {}), + issueHash: vi.fn(() => "hash-current"), + } as any, + issueTracker: { fetchIssueById } as any, + dispatcherService: { + hasActiveRuns: vi.fn(() => allowActiveRun), + findActiveRunForIssue: vi.fn(() => null), + createRun: vi.fn(), + advanceRun, + listActiveRuns: vi.fn(() => ( + allowActiveRun + ? [{ + id: "run-1", + issueId: "issue-1", + workflowId: "workflow-1", + status: "in_progress", + retryAfter: null, + reviewState: "approved", + }] + : [] + )), + listQueue: vi.fn(() => []), + resolveRunAction: vi.fn(), + } as any, + autoStart: false, + }); + + const cycle = service.runSyncNow(); + await service.processIssueUpdate("issue-2"); + if (!resolveAdvance) { + throw new Error("Expected the reconciliation cycle to be blocked."); + } + (resolveAdvance as () => void)(); + await cycle; + + expect(fetchIssueById).toHaveBeenCalledTimes(1); + await service.runSyncNow(); + expect(fetchIssueById).toHaveBeenCalledTimes(2); + expect(fetchIssueById).toHaveBeenNthCalledWith(2, "issue-2"); + db.close(); + }); + + it("serializes concurrent webhook issue updates and coalesces duplicates", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "ade-linear-sync-webhook-buffer-")); + const db = await openKvDb(path.join(root, "ade.db"), { debug() {}, info() {}, warn() {}, error() {} } as any); + let releaseFirstFetch!: () => void; + const firstFetchGate = new Promise((resolve) => { + releaseFirstFetch = resolve; + }); + const fetchIssueById = vi.fn(async (issueId: string) => { + if (issueId === "issue-1") { + await firstFetchGate; + return { + ...issueFixture, + id: "issue-1", + raw: { _snapshotHash: "hash-issue-1", _previousSnapshotHash: "hash-0" }, + }; + } + return { + ...issueFixture, + id: "issue-2", + identifier: "ABC-43", + raw: { _snapshotHash: "hash-issue-2", _previousSnapshotHash: "hash-0" }, + }; + }); + + const service = createLinearSyncService({ + db, + projectId: "project-1", + flowPolicyService: { getPolicy: () => policy } as any, + routingService: { + routeIssue: vi.fn(async () => ({ + workflowId: null, + workflowName: null, + workflow: null, + target: null, + reason: "No match", + candidates: [], + nextStepsPreview: [], + })), + } as any, + intakeService: { + fetchCandidates: vi.fn(async () => []), + persistSnapshot: vi.fn(() => {}), + issueHash: vi.fn((issue: NormalizedLinearIssue) => String(issue.raw?._snapshotHash ?? "hash-current")), + } as any, + issueTracker: { fetchIssueById } as any, + dispatcherService: { + hasActiveRuns: vi.fn(() => false), + findActiveRunForIssue: vi.fn(() => null), + createRun: vi.fn(), + advanceRun: vi.fn(async () => null), + listActiveRuns: vi.fn(() => []), + listQueue: vi.fn(() => []), + resolveRunAction: vi.fn(), + } as any, + autoStart: false, + }); + + const first = service.processIssueUpdate("issue-1"); + await Promise.resolve(); + await service.processIssueUpdate("issue-2"); + await service.processIssueUpdate("issue-2"); + releaseFirstFetch(); + await first; + + expect(fetchIssueById).toHaveBeenCalledTimes(2); + expect(fetchIssueById).toHaveBeenNthCalledWith(1, "issue-1"); + expect(fetchIssueById).toHaveBeenNthCalledWith(2, "issue-2"); + db.close(); + }); + + it("starts immediately and continues on the reconciliation interval", async () => { + vi.useFakeTimers(); + const root = fs.mkdtempSync(path.join(os.tmpdir(), "ade-linear-sync-")); + const db = await openKvDb(path.join(root, "ade.db"), { debug() {}, info() {}, warn() {}, error() {} } as any); + const fetchCandidates = vi.fn(async () => []); + + const service = createLinearSyncService({ + db, + projectId: "project-1", + flowPolicyService: { + getPolicy: () => ({ + ...policy, + workflows: [ + { + id: "workflow-1", + enabled: true, + }, + ], + }), + } as any, + routingService: { + routeIssue: vi.fn(async () => ({ + workflowId: null, + workflowName: null, + workflow: null, + target: null, + reason: "No match", + candidates: [], + nextStepsPreview: [], + })), + } as any, + intakeService: { + fetchCandidates, + persistSnapshot: vi.fn(() => {}), + } as any, + issueTracker: { + fetchIssueById: vi.fn(async () => issueFixture), + } as any, + dispatcherService: { + hasActiveRuns: vi.fn(() => false), + findActiveRunForIssue: vi.fn(() => null), + createRun: vi.fn(), + advanceRun: vi.fn(async () => null), + listActiveRuns: vi.fn(() => []), + listQueue: vi.fn(() => []), + resolveRunAction: vi.fn(), + } as any, + hasCredentials: () => true, + reconciliationIntervalSec: 30, + autoStart: false, + }); + + await service.start(); + expect(fetchCandidates).toHaveBeenCalledTimes(1); + + await vi.advanceTimersByTimeAsync(30_000); + expect(fetchCandidates).toHaveBeenCalledTimes(2); + + service.dispose(); + db.close(); + }); + + it("immediately advances queue actions after a supervisor decision", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "ade-linear-sync-review-action-")); + const db = await openKvDb(path.join(root, "ade.db"), { debug() {}, info() {}, warn() {}, error() {} } as any); + const advanceRun = vi.fn(async () => ({ id: "run-1", status: "queued" })); + + const service = createLinearSyncService({ + db, + projectId: "project-1", + flowPolicyService: { getPolicy: () => policy } as any, + routingService: { + routeIssue: vi.fn(async () => ({ + workflowId: null, + workflowName: null, + workflow: null, + target: null, + reason: "No match", + candidates: [], + nextStepsPreview: [], + })), + } as any, + intakeService: { + fetchCandidates: vi.fn(async () => []), + persistSnapshot: vi.fn(() => {}), + } as any, + issueTracker: { + fetchIssueById: vi.fn(async () => issueFixture), + } as any, + dispatcherService: { + findActiveRunForIssue: vi.fn(() => null), + createRun: vi.fn(), + advanceRun, + listActiveRuns: vi.fn(() => []), + listQueue: vi.fn(() => [{ id: "run-1" }]), + resolveRunAction: vi.fn(async () => ({ id: "run-1", status: "queued" })), + getRunDetail: vi.fn(async () => null), + } as any, + autoStart: false, + }); + + await service.resolveQueueItem({ queueItemId: "run-1", action: "approve" }); + expect(advanceRun).toHaveBeenCalledWith("run-1", policy); + db.close(); + }); + + it("records watch-only matches in the dashboard without creating runs", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "ade-linear-sync-watch-only-")); + const db = await openKvDb(path.join(root, "ade.db"), { debug() {}, info() {}, warn() {}, error() {} } as any); + const createRun = vi.fn(); + + const watchOnlyIssue: NormalizedLinearIssue = { + ...issueFixture, + raw: { _snapshotHash: "hash-new", _previousSnapshotHash: "hash-old" }, + }; + + const service = createLinearSyncService({ + db, + projectId: "project-1", + flowPolicyService: { + getPolicy: () => ({ + ...policy, + workflows: [ + { + id: "watch-only", + name: "Watch only", + enabled: true, + priority: 100, + triggers: { projectSlugs: ["acme-platform"] }, + routing: { watchOnly: true }, + target: { type: "review_gate" }, + steps: [{ id: "review", type: "request_human_review" }], + }, + ], + }), + } as any, + routingService: { + routeIssue: vi.fn(async () => ({ + workflowId: "watch-only", + workflowName: "Watch only", + workflow: { + id: "watch-only", + name: "Watch only", + enabled: true, + priority: 100, + routing: { watchOnly: true }, + concurrency: {}, + }, + target: { type: "review_gate" }, + reason: "Matched watch-only workflow", + candidates: [{ workflowId: "watch-only", workflowName: "Watch only", priority: 100, matched: true, reasons: ["Project matched"], matchedSignals: ["Project matched"] }], + nextStepsPreview: [], + })), + } as any, + intakeService: { + fetchCandidates: vi.fn(async () => [watchOnlyIssue]), + persistSnapshot: vi.fn(() => {}), + } as any, + issueTracker: { + fetchIssueById: vi.fn(async () => issueFixture), + } as any, + dispatcherService: { + hasActiveRuns: vi.fn(() => false), + findActiveRunForIssue: vi.fn(() => null), + createRun, + advanceRun: vi.fn(async () => null), + listActiveRuns: vi.fn(() => []), + listQueue: vi.fn(() => []), + resolveRunAction: vi.fn(), + } as any, + autoStart: false, + }); + + await service.runSyncNow(); + expect(createRun).not.toHaveBeenCalled(); + const dashboard = service.getDashboard(); + expect(dashboard.watchOnlyHits).toBe(1); + expect(dashboard.recentEvents[0]?.eventType).toBe("watch_only_match"); + db.close(); + }); + + it("hydrates webhook issue updates with snapshot hashes before routing", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "ade-linear-sync-webhook-")); + const db = await openKvDb(path.join(root, "ade.db"), { debug() {}, info() {}, warn() {}, error() {} } as any); + const createRun = vi.fn(() => ({ id: "run-1" })); + + db.run( + ` + insert into linear_issue_snapshots( + id, project_id, issue_id, identifier, state_type, assignee_id, updated_at_linear, payload_json, hash, created_at, updated_at + ) + values(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `, + [ + "project-1:issue-1", + "project-1", + "issue-1", + issueFixture.identifier, + "backlog", + null, + "2026-03-04T00:00:00.000Z", + JSON.stringify({ + ...issueFixture, + stateId: "state-backlog", + stateName: "Backlog", + stateType: "backlog", + }), + "hash-previous", + "2026-03-04T00:00:00.000Z", + "2026-03-04T00:00:00.000Z", + ], + ); + + const service = createLinearSyncService({ + db, + projectId: "project-1", + flowPolicyService: { + getPolicy: () => ({ + ...policy, + workflows: [ + { + id: "workflow-1", + name: "Dispatch issue", + enabled: true, + priority: 100, + triggers: { projectSlugs: ["acme-platform"] }, + routing: {}, + target: { type: "review_gate" }, + steps: [{ id: "review", type: "request_human_review" }], + }, + ], + }), + } as any, + routingService: { + routeIssue: vi.fn(async () => ({ + workflowId: "workflow-1", + workflowName: "Dispatch issue", + workflow: { + id: "workflow-1", + name: "Dispatch issue", + enabled: true, + routing: {}, + concurrency: {}, + }, + target: { type: "review_gate" }, + reason: "Matched project", + candidates: [], + nextStepsPreview: [], + })), + } as any, + intakeService: { + fetchCandidates: vi.fn(async () => []), + persistSnapshot: vi.fn(() => {}), + issueHash: vi.fn(() => "hash-current"), + } as any, + issueTracker: { + fetchIssueById: vi.fn(async () => issueFixture), + } as any, + dispatcherService: { + hasActiveRuns: vi.fn(() => false), + findActiveRunForIssue: vi.fn(() => null), + createRun, + advanceRun: vi.fn(async () => null), + listActiveRuns: vi.fn(() => []), + listQueue: vi.fn(() => []), + resolveRunAction: vi.fn(), + } as any, + autoStart: false, + }); + + await service.processIssueUpdate("issue-1"); + + expect(createRun).toHaveBeenCalledTimes(1); + expect(createRun).toHaveBeenCalledWith( + expect.objectContaining({ + raw: expect.objectContaining({ + _snapshotHash: "hash-current", + _previousSnapshotHash: "hash-previous", + }), + previousStateId: "state-backlog", + previousStateType: "backlog", + }), + expect.anything(), + ); + db.close(); + }); + + it("cancels every active run for an issue when the issue closes", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "ade-linear-sync-close-all-")); + const db = await openKvDb(path.join(root, "ade.db"), { debug() {}, info() {}, warn() {}, error() {} } as any); + const cancelRun = vi.fn(async () => {}); + + const service = createLinearSyncService({ + db, + projectId: "project-1", + flowPolicyService: { + getPolicy: () => ({ + ...policy, + workflows: [ + { + id: "workflow-1", + name: "Dispatch issue", + enabled: true, + priority: 100, + triggers: { projectSlugs: ["acme-platform"] }, + routing: {}, + target: { type: "review_gate" }, + steps: [{ id: "review", type: "request_human_review" }], + }, + ], + }), + } as any, + routingService: { + routeIssue: vi.fn(async () => ({ + workflowId: null, + workflowName: null, + workflow: null, + target: null, + reason: "No match", + candidates: [], + nextStepsPreview: [], + })), + } as any, + intakeService: { + fetchCandidates: vi.fn(async () => []), + persistSnapshot: vi.fn(() => {}), + issueHash: vi.fn(() => "hash-current"), + } as any, + issueTracker: { + fetchIssueById: vi.fn(async () => ({ + ...issueFixture, + stateId: "state-done", + stateName: "Done", + stateType: "completed", + })), + } as any, + dispatcherService: { + hasActiveRuns: vi.fn(() => true), + findActiveRunForIssue: vi.fn(() => ({ id: "run-2", issueId: "issue-1" })), + createRun: vi.fn(), + cancelRun, + advanceRun: vi.fn(async () => null), + listActiveRuns: vi.fn(() => [ + { id: "run-1", issueId: "issue-1" }, + { id: "run-2", issueId: "issue-1" }, + { id: "run-3", issueId: "issue-2" }, + ]), + listQueue: vi.fn(() => []), + resolveRunAction: vi.fn(), + } as any, + autoStart: false, + }); + + await service.processIssueUpdate("issue-1"); + + expect(cancelRun).toHaveBeenCalledTimes(2); + expect(cancelRun).toHaveBeenNthCalledWith( + 1, + "run-1", + "Issue externally completed", + expect.objectContaining({ workflows: expect.any(Array) }), + ); + expect(cancelRun).toHaveBeenNthCalledWith( + 2, + "run-2", + "Issue externally completed", + expect.objectContaining({ workflows: expect.any(Array) }), + ); + db.close(); + }); + + it("forwards employee overrides through queue resolution", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "ade-linear-sync-override-")); + const db = await openKvDb(path.join(root, "ade.db"), { debug() {}, info() {}, warn() {}, error() {} } as any); + const resolveRunAction = vi.fn(async () => ({ id: "run-1", status: "queued" })); + + const service = createLinearSyncService({ + db, + projectId: "project-1", + flowPolicyService: { getPolicy: () => policy } as any, + routingService: { + routeIssue: vi.fn(async () => ({ + workflowId: null, + workflowName: null, + workflow: null, + target: null, + reason: "No match", + candidates: [], + nextStepsPreview: [], + })), + } as any, + intakeService: { + fetchCandidates: vi.fn(async () => []), + persistSnapshot: vi.fn(() => {}), + } as any, + issueTracker: { + fetchIssueById: vi.fn(async () => issueFixture), + } as any, + dispatcherService: { + hasActiveRuns: vi.fn(() => false), + findActiveRunForIssue: vi.fn(() => null), + createRun: vi.fn(), + advanceRun: vi.fn(async () => null), + listActiveRuns: vi.fn(() => []), + listQueue: vi.fn(() => [{ id: "run-1", employeeOverride: "agent:worker-1" }]), + resolveRunAction, + getRunDetail: vi.fn(async () => null), + } as any, + autoStart: false, + }); + + await service.resolveQueueItem({ queueItemId: "run-1", action: "retry", employeeOverride: "agent:worker-1" }); + expect(resolveRunAction).toHaveBeenCalledWith("run-1", "retry", undefined, policy, "agent:worker-1", undefined); + db.close(); + }); + + it("forwards laneId through queue resolution resumes", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "ade-linear-sync-lane-")); + const db = await openKvDb(path.join(root, "ade.db"), { debug() {}, info() {}, warn() {}, error() {} } as any); + const resolveRunAction = vi.fn(async () => ({ id: "run-1", status: "queued" })); + + const service = createLinearSyncService({ + db, + projectId: "project-1", + flowPolicyService: { getPolicy: () => policy } as any, + routingService: { + routeIssue: vi.fn(async () => ({ + workflowId: null, + workflowName: null, + workflow: null, + target: null, + reason: "No match", + candidates: [], + nextStepsPreview: [], + })), + } as any, + intakeService: { + fetchCandidates: vi.fn(async () => []), + persistSnapshot: vi.fn(() => {}), + } as any, + issueTracker: { + fetchIssueById: vi.fn(async () => issueFixture), + } as any, + dispatcherService: { + hasActiveRuns: vi.fn(() => false), + findActiveRunForIssue: vi.fn(() => null), + createRun: vi.fn(), + advanceRun: vi.fn(async () => null), + listActiveRuns: vi.fn(() => []), + listQueue: vi.fn(() => [{ id: "run-1", laneId: "lane-2" }]), + resolveRunAction, + getRunDetail: vi.fn(async () => null), + } as any, + autoStart: false, + }); + + await service.resolveQueueItem({ queueItemId: "run-1", action: "resume", laneId: "lane-2" }); + expect(resolveRunAction).toHaveBeenCalledWith("run-1", "resume", undefined, policy, undefined, "lane-2"); + db.close(); + }); + }); + +}); + +describe("linearDispatcherService (file group)", () => { + + const issueFixture: NormalizedLinearIssue = { + id: "issue-1", + identifier: "ABC-42", + title: "Fix flaky sync run", + description: "Occasional sync failure under load.", + url: "https://linear.app/acme/issue/ABC-42", + projectId: "proj-1", + projectSlug: "acme-platform", + teamId: "team-1", + teamKey: "ACME", + stateId: "state-todo", + stateName: "Todo", + stateType: "unstarted", + priority: 2, + priorityLabel: "high", + labels: ["bug"], + assigneeId: null, + assigneeName: "CTO", + ownerId: "owner-1", + creatorId: "creator-1", + creatorName: "Taylor", + blockerIssueIds: [], + hasOpenBlockers: false, + createdAt: "2026-03-05T00:00:00.000Z", + updatedAt: "2026-03-05T00:00:00.000Z", + raw: {}, + }; + + const intake = { + projectSlugs: ["acme-platform"], + activeStateTypes: ["backlog", "unstarted", "started"], + terminalStateTypes: ["completed", "canceled"], + }; + + function buildPolicy(targetType: "mission" | "review_gate" | "worker_run"): LinearWorkflowConfig { + return { + version: 1, + source: "repo", + intake, + settings: { ctoLinearAssigneeName: "CTO", ctoLinearAssigneeAliases: ["cto"] }, + workflows: [ + { + id: "flow-1", + name: "Flow 1", + enabled: true, + priority: 100, + triggers: { assignees: ["CTO"], projectSlugs: ["acme-platform"] }, + target: { type: targetType, runMode: targetType === "review_gate" ? "manual" : "autopilot", workerSelector: { mode: "slug", value: "backend-dev" } }, + steps: [ + { id: "launch", type: "launch_target", name: "Launch target" }, + ...(targetType === "review_gate" + ? [{ id: "review", type: "request_human_review", name: "Review gate" } as const] + : [{ id: "wait", type: "wait_for_target_status", name: "Wait", targetStatus: targetType === "mission" ? "completed" : "runtime_completed" } as const]), + { id: "complete", type: "complete_issue", name: "Complete issue" }, + ], + closeout: { successState: "done", failureState: "blocked", applyLabels: ["ade"], resolveOnSuccess: true, reopenOnFailure: true, artifactMode: "links" }, + }, + ], + files: [], + migration: { hasLegacyConfig: false, needsSave: false }, + legacyConfig: null, + }; + } + + function buildEmployeeSessionPolicy(): LinearWorkflowConfig { + return { + version: 1, + source: "repo", + intake, + settings: { ctoLinearAssigneeName: "CTO", ctoLinearAssigneeAliases: ["cto"] }, + workflows: [ + { + id: "employee-flow", + name: "Employee flow", + enabled: true, + priority: 100, + triggers: { assignees: ["agent-1"], labels: ["workflow:backend"] }, + target: { type: "employee_session", runMode: "assisted" }, + steps: [ + { id: "set", type: "set_linear_state", name: "Move to progress", state: "in_progress" }, + { id: "launch", type: "launch_target", name: "Launch chat" }, + { id: "wait", type: "wait_for_target_status", name: "Wait", targetStatus: "completed" }, + { id: "complete", type: "complete_issue", name: "Complete issue" }, + ], + closeout: { successState: "in_review", failureState: "blocked", applyLabels: ["ade"], resolveOnSuccess: true, reopenOnFailure: true, artifactMode: "links" }, + }, + ], + files: [], + migration: { hasLegacyConfig: false, needsSave: false }, + legacyConfig: null, + }; + } + + function buildDirectCtoSessionPolicy(overrides?: Partial): LinearWorkflowConfig { + return { + version: 1, + source: "repo", + intake, + settings: { ctoLinearAssigneeName: "CTO", ctoLinearAssigneeAliases: ["cto"] }, + workflows: [ + { + id: "cto-session-flow", + name: "CTO direct session", + enabled: true, + priority: 100, + triggers: { assignees: ["CTO"], labels: ["workflow:backend"] }, + target: { + type: "employee_session", + runMode: "assisted", + employeeIdentityKey: "cto", + sessionTemplate: "default", + laneSelection: "primary", + sessionReuse: "reuse_existing", + ...overrides, + }, + steps: [ + { id: "launch", type: "launch_target", name: "Launch chat" }, + { id: "wait", type: "wait_for_target_status", name: "Wait", targetStatus: "completed" }, + { id: "complete", type: "complete_issue", name: "Complete issue" }, + ], + closeout: { successState: "in_review", failureState: "blocked", applyLabels: ["ade"], resolveOnSuccess: true, reopenOnFailure: true, artifactMode: "links" }, + }, + ], + files: [], + migration: { hasLegacyConfig: false, needsSave: false }, + legacyConfig: null, + }; + } + + function buildSupervisedWorkerPolicy(): LinearWorkflowConfig { + return { + version: 1, + source: "repo", + intake, + settings: { ctoLinearAssigneeName: "CTO", ctoLinearAssigneeAliases: ["cto"] }, + workflows: [ + { + id: "supervised-worker", + name: "Supervised worker", + enabled: true, + priority: 140, + triggers: { assignees: ["CTO"], labels: ["workflow:backend-supervised"] }, + target: { + type: "worker_run", + runMode: "autopilot", + workerSelector: { mode: "slug", value: "backend-dev" }, + laneSelection: "fresh_issue_lane", + }, + steps: [ + { id: "launch", type: "launch_target", name: "Launch worker run" }, + { id: "wait", type: "wait_for_target_status", name: "Wait", targetStatus: "runtime_completed" }, + { + id: "review", + type: "request_human_review", + name: "Supervisor review", + reviewerIdentityKey: "cto", + rejectAction: "loop_back", + loopToStepId: "launch", + }, + { id: "complete", type: "complete_issue", name: "Complete issue" }, + ], + closeout: { successState: "in_review", failureState: "blocked", applyLabels: ["ade"], resolveOnSuccess: true, reopenOnFailure: true, artifactMode: "links" }, + }, + ], + files: [], + migration: { hasLegacyConfig: false, needsSave: false }, + legacyConfig: null, + }; + } + + function buildWorkerExplicitCompletionPolicy(): LinearWorkflowConfig { + return { + version: 1, + source: "repo", + intake, + settings: { ctoLinearAssigneeName: "CTO", ctoLinearAssigneeAliases: ["cto"] }, + workflows: [ + { + id: "worker-explicit", + name: "Worker explicit completion", + enabled: true, + priority: 120, + triggers: { assignees: ["CTO"], labels: ["workflow:explicit-complete"] }, + target: { + type: "worker_run", + runMode: "autopilot", + workerSelector: { mode: "slug", value: "backend-dev" }, + laneSelection: "primary", + }, + steps: [ + { id: "launch", type: "launch_target", name: "Launch worker run" }, + { id: "wait", type: "wait_for_target_status", name: "Wait", targetStatus: "explicit_completion" }, + { id: "complete", type: "complete_issue", name: "Complete issue" }, + ], + closeout: { successState: "in_review", failureState: "blocked", applyLabels: ["ade"], resolveOnSuccess: true, reopenOnFailure: true, artifactMode: "links" }, + }, + ], + files: [], + migration: { hasLegacyConfig: false, needsSave: false }, + legacyConfig: null, + }; + } + + function buildPrReadyPolicy(): LinearWorkflowConfig { + return { + version: 1, + source: "repo", + intake, + settings: { ctoLinearAssigneeName: "CTO", ctoLinearAssigneeAliases: ["cto"] }, + workflows: [ + { + id: "pr-ready-flow", + name: "PR ready flow", + enabled: true, + priority: 120, + triggers: { assignees: ["CTO"], labels: ["workflow:backend"] }, + target: { + type: "pr_resolution", + runMode: "autopilot", + workerSelector: { mode: "slug", value: "backend-dev" }, + prStrategy: { kind: "per-lane", draft: true }, + prTiming: "after_target_complete", + laneSelection: "primary", + }, + steps: [ + { id: "launch", type: "launch_target", name: "Launch PR flow" }, + { id: "wait-pr", type: "wait_for_pr", name: "Wait for PR" }, + { id: "complete", type: "complete_issue", name: "Complete issue" }, + ], + closeout: { successState: "in_review", failureState: "blocked", applyLabels: ["ade"], resolveOnSuccess: true, reopenOnFailure: true, artifactMode: "links", reviewReadyWhen: "pr_ready" }, + }, + ], + files: [], + migration: { hasLegacyConfig: false, needsSave: false }, + legacyConfig: null, + }; + } + + function buildPrPolicy(): LinearWorkflowConfig { + return { + version: 1, + source: "repo", + intake, + settings: { ctoLinearAssigneeName: "CTO", ctoLinearAssigneeAliases: ["cto"] }, + workflows: [ + { + id: "pr-flow", + name: "PR flow", + enabled: true, + priority: 120, + triggers: { assignees: ["CTO"], labels: ["workflow:backend"] }, + target: { type: "pr_resolution", runMode: "autopilot", workerSelector: { mode: "slug", value: "backend-dev" }, prStrategy: { kind: "per-lane", draft: true } }, + steps: [ + { id: "launch", type: "launch_target", name: "Launch PR flow" }, + { id: "wait-pr", type: "wait_for_pr", name: "Wait for PR" }, + { id: "notify", type: "emit_app_notification", name: "Notify", notifyOn: "review_ready" }, + { id: "complete", type: "complete_issue", name: "Complete issue" }, + ], + closeout: { successState: "in_review", failureState: "blocked", applyLabels: ["ade"], resolveOnSuccess: true, reopenOnFailure: true, artifactMode: "links", reviewReadyWhen: "pr_created" }, + }, + ], + files: [], + migration: { hasLegacyConfig: false, needsSave: false }, + legacyConfig: null, + }; + } + + function buildDownstreamEmployeeSessionPolicy(): LinearWorkflowConfig { + return { + version: 1, + source: "repo", + intake, + settings: { ctoLinearAssigneeName: "CTO", ctoLinearAssigneeAliases: ["cto"] }, + workflows: [ + { + id: "downstream-session-flow", + name: "Downstream session flow", + enabled: true, + priority: 125, + triggers: { assignees: ["CTO"], labels: ["workflow:downstream-session"] }, + target: { + type: "worker_run", + runMode: "autopilot", + workerSelector: { mode: "slug", value: "backend-dev" }, + laneSelection: "primary", + downstreamTarget: { + type: "employee_session", + employeeIdentityKey: "cto", + runMode: "assisted", + laneSelection: "primary", + sessionReuse: "reuse_existing", + }, + }, + steps: [ + { id: "launch", type: "launch_target", name: "Launch worker run" }, + { id: "wait", type: "wait_for_target_status", name: "Wait", targetStatus: "runtime_completed" }, + { id: "complete", type: "complete_issue", name: "Complete issue" }, + ], + closeout: { successState: "in_review", failureState: "blocked", applyLabels: ["ade"], resolveOnSuccess: true, reopenOnFailure: true, artifactMode: "links" }, + }, + ], + files: [], + migration: { hasLegacyConfig: false, needsSave: false }, + legacyConfig: null, + }; + } + + function buildDownstreamManualCompletionPolicy(): LinearWorkflowConfig { + return { + version: 1, + source: "repo", + intake, + settings: { ctoLinearAssigneeName: "CTO", ctoLinearAssigneeAliases: ["cto"] }, + workflows: [ + { + id: "downstream-manual-flow", + name: "Downstream manual flow", + enabled: true, + priority: 126, + triggers: { assignees: ["CTO"], labels: ["workflow:downstream-manual"] }, + target: { + type: "employee_session", + runMode: "assisted", + employeeIdentityKey: "cto", + laneSelection: "primary", + sessionReuse: "fresh_session", + downstreamTarget: { + type: "employee_session", + runMode: "assisted", + employeeIdentityKey: "cto", + laneSelection: "primary", + sessionReuse: "fresh_session", + }, + }, + steps: [ + { id: "launch", type: "launch_target", name: "Launch chat" }, + { id: "wait", type: "wait_for_target_status", name: "Wait" }, + { id: "complete", type: "complete_issue", name: "Complete issue" }, + ], + closeout: { successState: "in_review", failureState: "blocked", applyLabels: ["ade"], resolveOnSuccess: true, reopenOnFailure: true, artifactMode: "links" }, + }, + ], + files: [], + migration: { hasLegacyConfig: false, needsSave: false }, + legacyConfig: null, + }; + } + + function buildEmployeeToWorkerHandoffPolicy(): LinearWorkflowConfig { + return { + version: 1, + source: "repo", + intake, + settings: { ctoLinearAssigneeName: "CTO", ctoLinearAssigneeAliases: ["cto"] }, + workflows: [ + { + id: "employee-to-worker-flow", + name: "Employee to worker flow", + enabled: true, + priority: 127, + triggers: { assignees: ["CTO"], labels: ["workflow:employee-to-worker"] }, + target: { + type: "employee_session", + runMode: "assisted", + employeeIdentityKey: "cto", + laneSelection: "primary", + sessionReuse: "reuse_existing", + downstreamTarget: { + type: "worker_run", + runMode: "autopilot", + workerSelector: { mode: "slug", value: "backend-dev" }, + laneSelection: "primary", + }, + }, + steps: [ + { id: "launch", type: "launch_target", name: "Launch chat" }, + { id: "wait", type: "wait_for_target_status", name: "Wait" }, + { id: "complete", type: "complete_issue", name: "Complete issue" }, + ], + closeout: { successState: "in_review", failureState: "blocked", applyLabels: ["ade"], resolveOnSuccess: true, reopenOnFailure: true, artifactMode: "links" }, + }, + ], + files: [], + migration: { hasLegacyConfig: false, needsSave: false }, + legacyConfig: null, + }; + } + function buildInvalidDownstreamPrPolicy(): LinearWorkflowConfig { + return { + version: 1, + source: "repo", + intake, + settings: { ctoLinearAssigneeName: "CTO", ctoLinearAssigneeAliases: ["cto"] }, + workflows: [ + { + id: "invalid-downstream-pr", + name: "Invalid downstream PR", + enabled: true, + priority: 130, + triggers: { assignees: ["CTO"], labels: ["workflow:downstream-pr"] }, + target: { + type: "worker_run", + runMode: "autopilot", + workerSelector: { mode: "slug", value: "backend-dev" }, + laneSelection: "primary", + downstreamTarget: { + type: "pr_resolution", + runMode: "autopilot", + workerSelector: { mode: "slug", value: "backend-dev" }, + laneSelection: "primary", + }, + }, + steps: [ + { id: "launch", type: "launch_target", name: "Launch worker run" }, + { id: "wait", type: "wait_for_target_status", name: "Wait", targetStatus: "runtime_completed" }, + { id: "complete", type: "complete_issue", name: "Complete issue" }, + ], + closeout: { successState: "in_review", failureState: "blocked", applyLabels: ["ade"], resolveOnSuccess: true, reopenOnFailure: true, artifactMode: "links" }, + }, + ], + files: [], + migration: { hasLegacyConfig: false, needsSave: false }, + legacyConfig: null, + }; + } + + function buildMatch(policy: LinearWorkflowConfig): LinearWorkflowMatchResult { + return { + workflowId: policy.workflows[0]!.id, + workflowName: policy.workflows[0]!.name, + workflow: policy.workflows[0]!, + target: policy.workflows[0]!.target, + reason: "Matched the configured workflow.", + candidates: [{ workflowId: "flow-1", workflowName: "Flow 1", priority: 100, matched: true, reasons: ["Assignee matched CTO"], matchedSignals: ["Assignee matched CTO"] }], + nextStepsPreview: ["Launch target", "Wait", "Complete issue"], + }; + } + + function createOutboundServiceMocks() { + return { + ensureWorkpad: vi.fn(async () => ({ commentId: "comment-1" })), + updateWorkpad: vi.fn(async () => ({ commentId: "comment-1" })), + publishMissionStart: vi.fn(async () => {}), + publishMissionProgress: vi.fn(async () => {}), + publishWorkflowStatus: vi.fn(async () => {}), + publishWorkflowCloseout: vi.fn(async () => {}), + publishMissionCloseout: vi.fn(async () => {}), + } as any; + } + + describe("linearDispatcherService", () => { + it("launches a mission target and records the mission id", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "ade-linear-dispatcher-")); + const db = await openKvDb(path.join(root, "ade.db"), { debug() {}, info() {}, warn() {}, error() {} } as any); + const policy = buildPolicy("mission"); + const missionCreate = vi.fn(() => ({ id: "mission-1", title: "Mission" })); + + const dispatcher = createLinearDispatcherService({ + db, + projectId: "project-1", + issueTracker: { + fetchIssueById: vi.fn(async () => issueFixture), + fetchWorkflowStates: vi.fn(async () => [{ id: "done", name: "Done", type: "completed", teamId: "team-1", teamKey: "ACME" }]), + updateIssueState: vi.fn(async () => {}), + addLabel: vi.fn(async () => {}), + createComment: vi.fn(async () => ({ commentId: "comment-1" })), + } as any, + workerAgentService: { listAgents: vi.fn(() => [{ id: "agent-1", slug: "backend-dev", capabilities: [] }]) } as any, + workerHeartbeatService: { triggerWakeup: vi.fn(), listRuns: vi.fn(() => []) } as any, + missionService: { create: missionCreate, get: vi.fn(() => ({ id: "mission-1", status: "completed", artifacts: [] })) } as any, + aiOrchestratorService: { startMissionRun: vi.fn(async () => ({ runId: "run-1" })) } as any, + agentChatService: { + ensureIdentitySession: vi.fn(async () => ({ id: "session-1" })), + sendMessage: vi.fn(async () => {}), + listSessions: vi.fn(async () => []), + } as any, + laneService: { ensurePrimaryLane: vi.fn(async () => {}), list: vi.fn(async () => [{ id: "lane-1", laneType: "primary" }]) } as any, + templateService: { renderTemplate: vi.fn(() => ({ prompt: "Fix it." })) } as any, + closeoutService: { applyOutcome: vi.fn(async () => {}) } as any, + outboundService: createOutboundServiceMocks(), + workerTaskSessionService: { + deriveTaskKey: vi.fn(() => "task-1"), + ensureTaskSession: vi.fn(() => ({ id: "task-session-1" })), + } as any, + prService: { + getForLane: vi.fn(() => null), + createFromLane: vi.fn(async () => ({ id: "pr-1", githubPrNumber: 101 })), + } as any, + }); + + const run = dispatcher.createRun(issueFixture, buildMatch(policy)); + await dispatcher.advanceRun(run.id, policy); + expect(missionCreate).toHaveBeenCalledTimes(1); + expect(dispatcher.listQueue()[0]?.missionId).toBe("mission-1"); + db.close(); + }); + + it("holds review_gate targets in escalated status", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "ade-linear-dispatcher-review-")); + const db = await openKvDb(path.join(root, "ade.db"), { debug() {}, info() {}, warn() {}, error() {} } as any); + const policy = buildPolicy("review_gate"); + + const dispatcher = createLinearDispatcherService({ + db, + projectId: "project-1", + issueTracker: { + fetchIssueById: vi.fn(async () => issueFixture), + fetchWorkflowStates: vi.fn(async () => []), + updateIssueState: vi.fn(async () => {}), + addLabel: vi.fn(async () => {}), + createComment: vi.fn(async () => ({ commentId: "comment-1" })), + } as any, + workerAgentService: { listAgents: vi.fn(() => []) } as any, + workerHeartbeatService: { triggerWakeup: vi.fn(), listRuns: vi.fn(() => []) } as any, + missionService: { create: vi.fn(), get: vi.fn() } as any, + aiOrchestratorService: { startMissionRun: vi.fn() } as any, + agentChatService: { + ensureIdentitySession: vi.fn(async () => ({ id: "session-1" })), + sendMessage: vi.fn(async () => {}), + listSessions: vi.fn(async () => []), + } as any, + laneService: { ensurePrimaryLane: vi.fn(async () => {}), list: vi.fn(async () => [{ id: "lane-1", laneType: "primary" }]) } as any, + templateService: { renderTemplate: vi.fn(() => ({ prompt: "noop" })) } as any, + closeoutService: { applyOutcome: vi.fn(async () => {}) } as any, + outboundService: createOutboundServiceMocks(), + workerTaskSessionService: { + deriveTaskKey: vi.fn(() => "task-1"), + ensureTaskSession: vi.fn(() => ({ id: "task-session-1" })), + } as any, + prService: { + getForLane: vi.fn(() => null), + createFromLane: vi.fn(async () => ({ id: "pr-1", githubPrNumber: 101 })), + } as any, + }); + + const run = dispatcher.createRun(issueFixture, buildMatch(policy)); + await dispatcher.advanceRun(run.id, policy); + expect(dispatcher.listQueue()[0]?.status).toBe("escalated"); + db.close(); + }); + + it("delegates employee_session runs into the assigned employee chat", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "ade-linear-dispatcher-session-")); + const db = await openKvDb(path.join(root, "ade.db"), { debug() {}, info() {}, warn() {}, error() {} } as any); + const policy = buildEmployeeSessionPolicy(); + const sendMessage = vi.fn(async () => ({ id: "message-1" })); + const ensureTaskSession = vi.fn(() => ({ id: "task-session-1" })); + const employeeIssue = { ...issueFixture, assigneeId: "user-1", assigneeName: "Alex", labels: ["workflow:backend"] }; + + const dispatcher = createLinearDispatcherService({ + db, + projectId: "project-1", + issueTracker: { + fetchIssueById: vi.fn(async () => employeeIssue), + fetchWorkflowStates: vi.fn(async () => [{ id: "state-progress", name: "In Progress", type: "started", teamId: "team-1", teamKey: "ACME" }]), + updateIssueState: vi.fn(async () => {}), + addLabel: vi.fn(async () => {}), + createComment: vi.fn(async () => ({ commentId: "comment-1" })), + } as any, + workerAgentService: { + listAgents: vi.fn(() => [{ id: "agent-1", slug: "backend-dev", name: "Backend Dev", adapterType: "claude-local", capabilities: [], linearIdentity: { userIds: ["user-1"], displayNames: ["Alex"] } }]), + getAgent: vi.fn(() => null), + } as any, + workerHeartbeatService: { triggerWakeup: vi.fn(), listRuns: vi.fn(() => []) } as any, + missionService: { create: vi.fn(), get: vi.fn() } as any, + aiOrchestratorService: { startMissionRun: vi.fn() } as any, + agentChatService: { + ensureIdentitySession: vi.fn(async () => ({ id: "session-1" })), + sendMessage, + listSessions: vi.fn(async () => [{ sessionId: "session-1", laneId: "lane-1", status: "idle" }]), + } as any, + laneService: { ensurePrimaryLane: vi.fn(async () => {}), list: vi.fn(async () => [{ id: "lane-1", laneType: "primary" }]) } as any, + templateService: { renderTemplate: vi.fn(() => ({ prompt: "Please implement the issue." })) } as any, + closeoutService: { applyOutcome: vi.fn(async () => {}) } as any, + outboundService: createOutboundServiceMocks(), + workerTaskSessionService: { + deriveTaskKey: vi.fn(() => "task-key-1"), + ensureTaskSession, + } as any, + prService: { + getForLane: vi.fn(() => null), + createFromLane: vi.fn(async () => ({ id: "pr-1", githubPrNumber: 101 })), + } as any, + }); + + const run = dispatcher.createRun({ ...issueFixture, assigneeId: "user-1", assigneeName: "Alex", labels: ["workflow:backend"] }, buildMatch(policy)); + await dispatcher.advanceRun(run.id, policy); + + expect(ensureTaskSession).toHaveBeenCalledTimes(1); + expect(sendMessage).toHaveBeenCalledWith({ + sessionId: "session-1", + text: expect.stringContaining("Start work immediately for Linear issue ABC-42."), + }); + expect(dispatcher.listQueue()[0]?.sessionId).toBe("session-1"); + db.close(); + }); + + it("keeps employee_session runs visible as queued when manual delegation is required", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "ade-linear-dispatcher-awaiting-delegation-")); + const db = await openKvDb(path.join(root, "ade.db"), { debug() {}, info() {}, warn() {}, error() {} } as any); + const policy = buildEmployeeSessionPolicy(); + const employeeIssue = { ...issueFixture, assigneeId: "user-missing", assigneeName: "Missing Person", labels: ["workflow:backend"] }; + + const dispatcher = createLinearDispatcherService({ + db, + projectId: "project-1", + issueTracker: { + fetchIssueById: vi.fn(async () => employeeIssue), + fetchWorkflowStates: vi.fn(async () => []), + updateIssueState: vi.fn(async () => {}), + addLabel: vi.fn(async () => {}), + createComment: vi.fn(async () => ({ commentId: "comment-1" })), + } as any, + workerAgentService: { listAgents: vi.fn(() => []) } as any, + workerHeartbeatService: { triggerWakeup: vi.fn(), listRuns: vi.fn(() => []) } as any, + missionService: { create: vi.fn(), get: vi.fn() } as any, + aiOrchestratorService: { startMissionRun: vi.fn() } as any, + agentChatService: { + ensureIdentitySession: vi.fn(async () => ({ id: "session-1" })), + sendMessage: vi.fn(async () => {}), + listSessions: vi.fn(async () => []), + } as any, + laneService: { ensurePrimaryLane: vi.fn(async () => {}), list: vi.fn(async () => [{ id: "lane-1", laneType: "primary" }]) } as any, + templateService: { renderTemplate: vi.fn(() => ({ prompt: "Fix it." })) } as any, + closeoutService: { applyOutcome: vi.fn(async () => {}) } as any, + outboundService: createOutboundServiceMocks(), + workerTaskSessionService: { + deriveTaskKey: vi.fn(() => "task-1"), + ensureTaskSession: vi.fn(() => ({ id: "task-session-1" })), + } as any, + prService: { + getForLane: vi.fn(() => null), + createFromLane: vi.fn(async () => ({ id: "pr-1", githubPrNumber: 101 })), + } as any, + }); + + const run = dispatcher.createRun(employeeIssue, buildMatch(policy)); + await dispatcher.advanceRun(run.id, policy); + + expect(dispatcher.listQueue()[0]?.status).toBe("queued"); + const detail = await dispatcher.getRunDetail(run.id, policy); + expect(detail?.run.status).toBe("awaiting_delegation"); + db.close(); + }); + + it("rewinds launch_target when retrying a run that is awaiting delegation", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "ade-linear-dispatcher-retry-awaiting-delegation-")); + const db = await openKvDb(path.join(root, "ade.db"), { debug() {}, info() {}, warn() {}, error() {} } as any); + const policy = buildEmployeeSessionPolicy(); + const employeeIssue = { + ...issueFixture, + assigneeId: "unknown-agent", + assigneeName: "Unknown Agent", + labels: ["workflow:backend"], + }; + + const dispatcher = createLinearDispatcherService({ + db, + projectId: "project-1", + issueTracker: { + fetchIssueById: vi.fn(async () => employeeIssue), + fetchWorkflowStates: vi.fn(async () => []), + updateIssueState: vi.fn(async () => {}), + addLabel: vi.fn(async () => {}), + createComment: vi.fn(async () => ({ commentId: "comment-1" })), + } as any, + workerAgentService: { listAgents: vi.fn(() => []) } as any, + workerHeartbeatService: { triggerWakeup: vi.fn(), listRuns: vi.fn(() => []) } as any, + missionService: { create: vi.fn(), get: vi.fn() } as any, + aiOrchestratorService: { startMissionRun: vi.fn() } as any, + agentChatService: { ensureIdentitySession: vi.fn(), sendMessage: vi.fn(async () => {}), listSessions: vi.fn(async () => []) } as any, + laneService: { ensurePrimaryLane: vi.fn(async () => {}), list: vi.fn(async () => [{ id: "lane-1", laneType: "primary" }]) } as any, + templateService: { renderTemplate: vi.fn(() => ({ prompt: "Please own this issue." })) } as any, + closeoutService: { applyOutcome: vi.fn(async () => {}) } as any, + outboundService: createOutboundServiceMocks(), + workerTaskSessionService: { + deriveTaskKey: vi.fn(() => "task-key-1"), + ensureTaskSession: vi.fn(() => ({ id: "task-session-1" })), + } as any, + prService: { + getForLane: vi.fn(() => null), + createFromLane: vi.fn(async () => ({ id: "pr-1", githubPrNumber: 101 })), + } as any, + }); + + const run = dispatcher.createRun(employeeIssue, buildMatch(policy)); + await dispatcher.advanceRun(run.id, policy); + + const detailBeforeRetry = await dispatcher.getRunDetail(run.id, policy); + expect(detailBeforeRetry?.steps.find((step) => step.workflowStepId === "launch")?.status).toBe("completed"); + + await dispatcher.resolveRunAction(run.id, "retry", "Try again.", policy); + + const detailAfterRetry = await dispatcher.getRunDetail(run.id, policy); + expect(detailAfterRetry?.steps.find((step) => step.workflowStepId === "launch")?.status).toBe("pending"); + db.close(); + }); + + it("resumes awaiting-delegation runs after an operator picks an override", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "ade-linear-dispatcher-resume-awaiting-delegation-")); + const db = await openKvDb(path.join(root, "ade.db"), { debug() {}, info() {}, warn() {}, error() {} } as any); + const policy = buildEmployeeSessionPolicy(); + const employeeIssue = { + ...issueFixture, + assigneeId: "unknown-agent", + assigneeName: "Unknown Agent", + labels: ["workflow:backend"], + }; + const ensureIdentitySession = vi.fn(async () => ({ id: "session-override-1" })); + + const dispatcher = createLinearDispatcherService({ + db, + projectId: "project-1", + issueTracker: { + fetchIssueById: vi.fn(async () => employeeIssue), + fetchWorkflowStates: vi.fn(async () => []), + updateIssueState: vi.fn(async () => {}), + addLabel: vi.fn(async () => {}), + createComment: vi.fn(async () => ({ commentId: "comment-1" })), + } as any, + workerAgentService: { listAgents: vi.fn(() => []) } as any, + workerHeartbeatService: { triggerWakeup: vi.fn(), listRuns: vi.fn(() => []) } as any, + missionService: { create: vi.fn(), get: vi.fn() } as any, + aiOrchestratorService: { startMissionRun: vi.fn() } as any, + agentChatService: { + ensureIdentitySession, + sendMessage: vi.fn(async () => ({ id: "message-1" })), + listSessions: vi.fn(async () => []), + } as any, + laneService: { ensurePrimaryLane: vi.fn(async () => {}), list: vi.fn(async () => [{ id: "lane-1", laneType: "primary" }]) } as any, + templateService: { renderTemplate: vi.fn(() => ({ prompt: "Please own this issue." })) } as any, + closeoutService: { applyOutcome: vi.fn(async () => {}) } as any, + outboundService: createOutboundServiceMocks(), + workerTaskSessionService: { + deriveTaskKey: vi.fn(() => "task-key-1"), + ensureTaskSession: vi.fn(() => ({ id: "task-session-1" })), + } as any, + prService: { + getForLane: vi.fn(() => null), + createFromLane: vi.fn(async () => ({ id: "pr-1", githubPrNumber: 101 })), + } as any, + }); + + const run = dispatcher.createRun(employeeIssue, buildMatch(policy)); + const awaitingDelegation = await dispatcher.advanceRun(run.id, policy); + expect(awaitingDelegation?.status).toBe("awaiting_delegation"); + + const queued = await dispatcher.resolveRunAction(run.id, "resume", "Use CTO.", policy, "cto"); + expect(queued?.status).toBe("queued"); + + const resumed = await dispatcher.advanceRun(run.id, policy); + expect(resumed?.status).toBe("waiting_for_target"); + expect(ensureIdentitySession).toHaveBeenCalledWith(expect.objectContaining({ + identityKey: "cto", + laneId: "lane-1", + })); + db.close(); + }); + + it("launches a direct CTO employee session when the workflow targets CTO", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "ade-linear-dispatcher-cto-session-")); + const db = await openKvDb(path.join(root, "ade.db"), { debug() {}, info() {}, warn() {}, error() {} } as any); + const policy = buildDirectCtoSessionPolicy(); + const ensureIdentitySession = vi.fn(async () => ({ id: "session-cto-1" })); + + const dispatcher = createLinearDispatcherService({ + db, + projectId: "project-1", + issueTracker: { + fetchIssueById: vi.fn(async () => issueFixture), + fetchWorkflowStates: vi.fn(async () => []), + updateIssueState: vi.fn(async () => {}), + addLabel: vi.fn(async () => {}), + createComment: vi.fn(async () => ({ commentId: "comment-1" })), + } as any, + workerAgentService: { listAgents: vi.fn(() => []) } as any, + workerHeartbeatService: { triggerWakeup: vi.fn(), listRuns: vi.fn(() => []) } as any, + missionService: { create: vi.fn(), get: vi.fn() } as any, + aiOrchestratorService: { startMissionRun: vi.fn() } as any, + agentChatService: { + ensureIdentitySession, + sendMessage: vi.fn(async () => ({ id: "message-1" })), + listSessions: vi.fn(async () => [{ sessionId: "session-cto-1", laneId: "lane-1", status: "idle" }]), + } as any, + laneService: { ensurePrimaryLane: vi.fn(async () => {}), list: vi.fn(async () => [{ id: "lane-1", laneType: "primary" }]) } as any, + templateService: { renderTemplate: vi.fn(() => ({ prompt: "Please own this issue." })) } as any, + closeoutService: { applyOutcome: vi.fn(async () => {}) } as any, + outboundService: createOutboundServiceMocks(), + workerTaskSessionService: { + deriveTaskKey: vi.fn(() => "task-key-1"), + ensureTaskSession: vi.fn(() => ({ id: "task-session-1" })), + } as any, + prService: { + getForLane: vi.fn(() => null), + createFromLane: vi.fn(async () => ({ id: "pr-1", githubPrNumber: 101 })), + } as any, + }); + + const run = dispatcher.createRun({ ...issueFixture, labels: ["workflow:backend"], assigneeName: "CTO" }, buildMatch(policy)); + await dispatcher.advanceRun(run.id, policy); + + expect(ensureIdentitySession).toHaveBeenCalledWith(expect.objectContaining({ + identityKey: "cto", + laneId: "lane-1", + })); + expect(dispatcher.listQueue()[0]?.sessionId).toBe("session-cto-1"); + db.close(); + }); + + it("resumes awaiting-lane-choice runs after an operator picks a lane", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "ade-linear-dispatcher-lane-choice-")); + const db = await openKvDb(path.join(root, "ade.db"), { debug() {}, info() {}, warn() {}, error() {} } as any); + const policy = buildDirectCtoSessionPolicy({ + laneSelection: "operator_prompt", + sessionReuse: "reuse_existing", + }); + const ensureIdentitySession = vi.fn(async () => ({ id: "session-cto-2" })); + + const dispatcher = createLinearDispatcherService({ + db, + projectId: "project-1", + issueTracker: { + fetchIssueById: vi.fn(async () => issueFixture), + fetchWorkflowStates: vi.fn(async () => []), + updateIssueState: vi.fn(async () => {}), + addLabel: vi.fn(async () => {}), + createComment: vi.fn(async () => ({ commentId: "comment-1" })), + } as any, + workerAgentService: { listAgents: vi.fn(() => []) } as any, + workerHeartbeatService: { triggerWakeup: vi.fn(), listRuns: vi.fn(() => []) } as any, + missionService: { create: vi.fn(), get: vi.fn() } as any, + aiOrchestratorService: { startMissionRun: vi.fn() } as any, + agentChatService: { + ensureIdentitySession, + sendMessage: vi.fn(async () => ({ id: "message-1" })), + listSessions: vi.fn(async () => []), + } as any, + laneService: { + ensurePrimaryLane: vi.fn(async () => {}), + list: vi.fn(async () => [ + { id: "lane-1", laneType: "primary", name: "Primary" }, + { id: "lane-2", laneType: "worktree", name: "Existing lane" }, + ]), + } as any, + templateService: { renderTemplate: vi.fn(() => ({ prompt: "Please own this issue." })) } as any, + closeoutService: { applyOutcome: vi.fn(async () => {}) } as any, + outboundService: createOutboundServiceMocks(), + workerTaskSessionService: { + deriveTaskKey: vi.fn(() => "task-key-1"), + ensureTaskSession: vi.fn(() => ({ id: "task-session-1" })), + } as any, + prService: { + getForLane: vi.fn(() => null), + createFromLane: vi.fn(async () => ({ id: "pr-1", githubPrNumber: 101 })), + } as any, + }); + + const run = dispatcher.createRun({ ...issueFixture, labels: ["workflow:backend"], assigneeName: "CTO" }, buildMatch(policy)); + const awaitingLaneChoice = await dispatcher.advanceRun(run.id, policy); + + expect(awaitingLaneChoice?.status).toBe("awaiting_lane_choice"); + + const queued = await dispatcher.resolveRunAction(run.id, "resume", "Use the existing lane.", policy, undefined, "lane-2"); + expect(queued?.executionLaneId).toBe("lane-2"); + + await dispatcher.advanceRun(run.id, policy); + + expect(ensureIdentitySession).toHaveBeenCalledWith(expect.objectContaining({ + identityKey: "cto", + laneId: "lane-2", + })); + expect(dispatcher.listQueue()[0]).toEqual(expect.objectContaining({ + laneId: "lane-2", + sessionId: "session-cto-2", + })); + db.close(); + }); + + it("keeps employee-session workflows waiting when a chat runtime ends and relinks to the active identity session", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "ade-linear-dispatcher-session-relink-")); + const db = await openKvDb(path.join(root, "ade.db"), { debug() {}, info() {}, warn() {}, error() {} } as any); + const policy = buildDirectCtoSessionPolicy(); + let sessions = [ + { + sessionId: "session-cto-1", + laneId: "lane-1", + identityKey: "cto", + status: "idle", + lastActivityAt: "2026-03-05T00:00:00.000Z", + }, + ]; + + const dispatcher = createLinearDispatcherService({ + db, + projectId: "project-1", + issueTracker: { + fetchIssueById: vi.fn(async () => issueFixture), + fetchWorkflowStates: vi.fn(async () => []), + updateIssueState: vi.fn(async () => {}), + addLabel: vi.fn(async () => {}), + createComment: vi.fn(async () => ({ commentId: "comment-1" })), + } as any, + workerAgentService: { listAgents: vi.fn(() => []) } as any, + workerHeartbeatService: { triggerWakeup: vi.fn(), listRuns: vi.fn(() => []) } as any, + missionService: { create: vi.fn(), get: vi.fn() } as any, + aiOrchestratorService: { startMissionRun: vi.fn() } as any, + agentChatService: { + ensureIdentitySession: vi.fn(async () => ({ id: "session-cto-1" })), + sendMessage: vi.fn(async () => ({ id: "message-1" })), + listSessions: vi.fn(async () => sessions), + } as any, + laneService: { ensurePrimaryLane: vi.fn(async () => {}), list: vi.fn(async () => [{ id: "lane-1", laneType: "primary" }]) } as any, + templateService: { renderTemplate: vi.fn(() => ({ prompt: "Please own this issue." })) } as any, + closeoutService: { applyOutcome: vi.fn(async () => {}) } as any, + outboundService: createOutboundServiceMocks(), + workerTaskSessionService: { + deriveTaskKey: vi.fn(() => "task-key-1"), + ensureTaskSession: vi.fn(() => ({ id: "task-session-1" })), + } as any, + prService: { + getForLane: vi.fn(() => null), + createFromLane: vi.fn(async () => ({ id: "pr-1", githubPrNumber: 101 })), + } as any, + }); + + const run = dispatcher.createRun({ ...issueFixture, labels: ["workflow:backend"], assigneeName: "CTO" }, buildMatch(policy)); + await dispatcher.advanceRun(run.id, policy); + + sessions = [ + { + sessionId: "session-cto-1", + laneId: "lane-1", + identityKey: "cto", + status: "ended", + lastActivityAt: "2026-03-05T00:05:00.000Z", + }, + ]; + const waiting = await dispatcher.advanceRun(run.id, policy); + expect(waiting?.status).toBe("waiting_for_target"); + + sessions = [ + { + sessionId: "session-cto-2", + laneId: "lane-1", + identityKey: "cto", + status: "idle", + lastActivityAt: "2026-03-05T00:06:00.000Z", + }, + ]; + const relinked = await dispatcher.advanceRun(run.id, policy); + expect(relinked?.status).toBe("waiting_for_target"); + expect(dispatcher.listQueue()[0]?.sessionId).toBe("session-cto-2"); + const detail = await dispatcher.getRunDetail(run.id, policy); + expect(detail?.steps.find((step) => step.workflowStepId === "wait")?.targetStatus).toBe("explicit_completion"); + db.close(); + }); + + it("creates a fresh issue lane and fresh session when configured", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "ade-linear-dispatcher-fresh-lane-")); + const db = await openKvDb(path.join(root, "ade.db"), { debug() {}, info() {}, warn() {}, error() {} } as any); + const policy = buildDirectCtoSessionPolicy({ + laneSelection: "fresh_issue_lane", + sessionReuse: "fresh_session", + freshLaneName: "Backend supervised lane", + }); + const ensureIdentitySession = vi.fn(async () => ({ id: "session-fresh-1" })); + const createLane = vi.fn(async () => ({ id: "lane-2", name: "Backend supervised lane" })); + + const dispatcher = createLinearDispatcherService({ + db, + projectId: "project-1", + issueTracker: { + fetchIssueById: vi.fn(async () => issueFixture), + fetchWorkflowStates: vi.fn(async () => []), + updateIssueState: vi.fn(async () => {}), + addLabel: vi.fn(async () => {}), + createComment: vi.fn(async () => ({ commentId: "comment-1" })), + } as any, + workerAgentService: { listAgents: vi.fn(() => []) } as any, + workerHeartbeatService: { triggerWakeup: vi.fn(), listRuns: vi.fn(() => []) } as any, + missionService: { create: vi.fn(), get: vi.fn() } as any, + aiOrchestratorService: { startMissionRun: vi.fn() } as any, + agentChatService: { + ensureIdentitySession, + sendMessage: vi.fn(async () => ({ id: "message-1" })), + listSessions: vi.fn(async () => [{ sessionId: "session-fresh-1", laneId: "lane-2", status: "idle" }]), + } as any, + laneService: { + ensurePrimaryLane: vi.fn(async () => {}), + list: vi.fn(async () => [{ id: "lane-1", laneType: "primary" }]), + create: createLane, + } as any, + templateService: { renderTemplate: vi.fn(() => ({ prompt: "Please own this issue." })) } as any, + closeoutService: { applyOutcome: vi.fn(async () => {}) } as any, + outboundService: createOutboundServiceMocks(), + workerTaskSessionService: { + deriveTaskKey: vi.fn(() => "task-key-1"), + ensureTaskSession: vi.fn(() => ({ id: "task-session-1" })), + } as any, + prService: { + getForLane: vi.fn(() => null), + createFromLane: vi.fn(async () => ({ id: "pr-1", githubPrNumber: 101 })), + } as any, + }); + + const run = dispatcher.createRun({ ...issueFixture, labels: ["workflow:backend"], assigneeName: "CTO" }, buildMatch(policy)); + await dispatcher.advanceRun(run.id, policy); + + expect(createLane).toHaveBeenCalledTimes(1); + expect(ensureIdentitySession).toHaveBeenCalledWith(expect.objectContaining({ + identityKey: "cto", + laneId: "lane-2", + reuseExisting: false, + })); + expect(dispatcher.listQueue()[0]?.laneId).toBe("lane-2"); + db.close(); + }); + + it("requires an explicit ADE completion signal for worker runs when configured", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "ade-linear-dispatcher-worker-explicit-")); + const db = await openKvDb(path.join(root, "ade.db"), { debug() {}, info() {}, warn() {}, error() {} } as any); + const policy = buildWorkerExplicitCompletionPolicy(); + const closeout = vi.fn(async () => {}); + + const dispatcher = createLinearDispatcherService({ + db, + projectId: "project-1", + issueTracker: { + fetchIssueById: vi.fn(async () => issueFixture), + fetchWorkflowStates: vi.fn(async () => []), + updateIssueState: vi.fn(async () => {}), + addLabel: vi.fn(async () => {}), + createComment: vi.fn(async () => ({ commentId: "comment-1" })), + } as any, + workerAgentService: { + listAgents: vi.fn(() => [{ id: "agent-1", slug: "backend-dev", adapterType: "claude-local", capabilities: [] }]), + } as any, + workerHeartbeatService: { + triggerWakeup: vi.fn(async () => ({ runId: "worker-run-1" })), + listRuns: vi.fn(() => [{ id: "worker-run-1", status: "completed" }]), + } as any, + missionService: { create: vi.fn(), get: vi.fn() } as any, + aiOrchestratorService: { startMissionRun: vi.fn() } as any, + agentChatService: { ensureIdentitySession: vi.fn(), sendMessage: vi.fn(async () => {}), listSessions: vi.fn(async () => []) } as any, + laneService: { ensurePrimaryLane: vi.fn(async () => {}), list: vi.fn(async () => [{ id: "lane-1", laneType: "primary" }]) } as any, + templateService: { renderTemplate: vi.fn(() => ({ prompt: "Implement the issue." })) } as any, + closeoutService: { applyOutcome: closeout } as any, + outboundService: createOutboundServiceMocks(), + workerTaskSessionService: { + deriveTaskKey: vi.fn(() => "task-key-1"), + ensureTaskSession: vi.fn(() => ({ id: "task-session-1" })), + } as any, + prService: { + getForLane: vi.fn(() => null), + createFromLane: vi.fn(async () => ({ id: "pr-1", githubPrNumber: 101 })), + } as any, + }); + + const run = dispatcher.createRun({ ...issueFixture, labels: ["workflow:explicit-complete"] }, buildMatch(policy)); + await dispatcher.advanceRun(run.id, policy); + const waiting = await dispatcher.advanceRun(run.id, policy); + expect(waiting?.status).toBe("waiting_for_target"); + + const detailBefore = await dispatcher.getRunDetail(run.id, policy); + expect(detailBefore?.steps.find((step) => step.workflowStepId === "wait")?.targetStatus).toBe("explicit_completion"); + + await dispatcher.resolveRunAction(run.id, "complete", "Validated via ADE closeout.", policy); + const completed = await dispatcher.advanceRun(run.id, policy); + expect(completed?.status).toBe("completed"); + expect(closeout).toHaveBeenCalledTimes(1); + db.close(); + }); + + it("scopes manual completion markers to the active downstream stage", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "ade-linear-dispatcher-downstream-manual-")); + const db = await openKvDb(path.join(root, "ade.db"), { debug() {}, info() {}, warn() {}, error() {} } as any); + const policy = buildDownstreamManualCompletionPolicy(); + const ensureIdentitySession = vi + .fn() + .mockResolvedValueOnce({ id: "session-1" }) + .mockResolvedValueOnce({ id: "session-2" }); + + const dispatcher = createLinearDispatcherService({ + db, + projectId: "project-1", + issueTracker: { + fetchIssueById: vi.fn(async () => issueFixture), + fetchWorkflowStates: vi.fn(async () => []), + updateIssueState: vi.fn(async () => {}), + addLabel: vi.fn(async () => {}), + createComment: vi.fn(async () => ({ commentId: "comment-1" })), + } as any, + workerAgentService: { listAgents: vi.fn(() => []) } as any, + workerHeartbeatService: { triggerWakeup: vi.fn(), listRuns: vi.fn(() => []) } as any, + missionService: { create: vi.fn(), get: vi.fn() } as any, + aiOrchestratorService: { startMissionRun: vi.fn() } as any, + agentChatService: { + ensureIdentitySession, + sendMessage: vi.fn(async () => ({ id: "message-1" })), + listSessions: vi.fn(async () => [ + { sessionId: "session-1", laneId: "lane-1", identityKey: "cto", status: "idle", lastActivityAt: "2026-03-05T00:00:00.000Z" }, + { sessionId: "session-2", laneId: "lane-1", identityKey: "cto", status: "idle", lastActivityAt: "2026-03-05T00:01:00.000Z" }, + ]), + } as any, + laneService: { ensurePrimaryLane: vi.fn(async () => {}), list: vi.fn(async () => [{ id: "lane-1", laneType: "primary" }]) } as any, + templateService: { renderTemplate: vi.fn(() => ({ prompt: "Please own this issue." })) } as any, + closeoutService: { applyOutcome: vi.fn(async () => {}) } as any, + outboundService: createOutboundServiceMocks(), + workerTaskSessionService: { + deriveTaskKey: vi.fn(() => "task-key-1"), + ensureTaskSession: vi.fn(() => ({ id: "task-session-1" })), + } as any, + prService: { + getForLane: vi.fn(() => null), + createFromLane: vi.fn(async () => ({ id: "pr-1", githubPrNumber: 101 })), + } as any, + }); + + const run = dispatcher.createRun({ ...issueFixture, labels: ["workflow:downstream-manual"], assigneeName: "CTO" }, buildMatch(policy)); + await dispatcher.advanceRun(run.id, policy); + await dispatcher.resolveRunAction(run.id, "complete", "Stage 1 finished.", policy); + + const afterHandoff = await dispatcher.advanceRun(run.id, policy); + expect(afterHandoff?.status).toBe("waiting_for_target"); + expect(dispatcher.listQueue()[0]?.sessionId).toBe("session-2"); + + const stillWaiting = await dispatcher.advanceRun(run.id, policy); + expect(stillWaiting?.status).toBe("waiting_for_target"); + expect(dispatcher.listQueue()[0]?.sessionId).toBe("session-2"); + expect(ensureIdentitySession).toHaveBeenCalledTimes(2); + db.close(); + }); + + it("clears incompatible CTO overrides before handing off to a worker-backed downstream target", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "ade-linear-dispatcher-override-handoff-")); + const db = await openKvDb(path.join(root, "ade.db"), { debug() {}, info() {}, warn() {}, error() {} } as any); + const policy = buildEmployeeToWorkerHandoffPolicy(); + const triggerWakeup = vi.fn(async () => ({ runId: "worker-run-1" })); + + const dispatcher = createLinearDispatcherService({ + db, + projectId: "project-1", + issueTracker: { + fetchIssueById: vi.fn(async () => issueFixture), + fetchWorkflowStates: vi.fn(async () => []), + updateIssueState: vi.fn(async () => {}), + addLabel: vi.fn(async () => {}), + createComment: vi.fn(async () => ({ commentId: "comment-1" })), + } as any, + workerAgentService: { + listAgents: vi.fn(() => [{ id: "agent-1", slug: "backend-dev", adapterType: "claude-local", capabilities: [] }]), + } as any, + workerHeartbeatService: { + triggerWakeup, + listRuns: vi.fn(() => []), + } as any, + missionService: { create: vi.fn(), get: vi.fn() } as any, + aiOrchestratorService: { startMissionRun: vi.fn() } as any, + agentChatService: { + ensureIdentitySession: vi.fn(async () => ({ id: "session-1" })), + sendMessage: vi.fn(async () => ({ id: "message-1" })), + listSessions: vi.fn(async () => [{ sessionId: "session-1", laneId: "lane-1", identityKey: "cto", status: "idle", lastActivityAt: "2026-03-05T00:00:00.000Z" }]), + } as any, + laneService: { ensurePrimaryLane: vi.fn(async () => {}), list: vi.fn(async () => [{ id: "lane-1", laneType: "primary" }]) } as any, + templateService: { renderTemplate: vi.fn(() => ({ prompt: "Please own this issue." })) } as any, + closeoutService: { applyOutcome: vi.fn(async () => {}) } as any, + outboundService: createOutboundServiceMocks(), + workerTaskSessionService: { + deriveTaskKey: vi.fn(() => "task-key-1"), + ensureTaskSession: vi.fn(() => ({ id: "task-session-1" })), + } as any, + prService: { + getForLane: vi.fn(() => null), + createFromLane: vi.fn(async () => ({ id: "pr-1", githubPrNumber: 101 })), + } as any, + }); + + const run = dispatcher.createRun({ ...issueFixture, labels: ["workflow:employee-to-worker"], assigneeName: "CTO" }, buildMatch(policy)); + await dispatcher.advanceRun(run.id, policy); + await dispatcher.resolveRunAction(run.id, "complete", "Hand off to a worker.", policy, "cto"); + + const handedOff = await dispatcher.advanceRun(run.id, policy); + expect(handedOff?.status).toBe("waiting_for_target"); + expect(handedOff?.linkedWorkerRunId).toBe("worker-run-1"); + expect(dispatcher.listQueue()[0]?.employeeOverride).toBeNull(); + expect(triggerWakeup).toHaveBeenCalledTimes(1); + db.close(); + }); + + it("preserves a launched session instead of scheduling a retry after a partial launch failure", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "ade-linear-dispatcher-partial-launch-")); + const db = await openKvDb(path.join(root, "ade.db"), { debug() {}, info() {}, warn() {}, error() {} } as any); + const policy = buildDirectCtoSessionPolicy(); + const ensureIdentitySession = vi.fn(async () => ({ id: "session-cto-1" })); + + const dispatcher = createLinearDispatcherService({ + db, + projectId: "project-1", + issueTracker: { + fetchIssueById: vi.fn(async () => issueFixture), + fetchWorkflowStates: vi.fn(async () => []), + updateIssueState: vi.fn(async () => {}), + addLabel: vi.fn(async () => {}), + createComment: vi.fn(async () => ({ commentId: "comment-1" })), + } as any, + workerAgentService: { listAgents: vi.fn(() => []) } as any, + workerHeartbeatService: { triggerWakeup: vi.fn(), listRuns: vi.fn(() => []) } as any, + missionService: { create: vi.fn(), get: vi.fn() } as any, + aiOrchestratorService: { startMissionRun: vi.fn() } as any, + agentChatService: { + ensureIdentitySession, + sendMessage: vi.fn(async () => { + throw new Error("Chat delivery failed after session creation."); + }), + listSessions: vi.fn(async () => [{ sessionId: "session-cto-1", laneId: "lane-1", identityKey: "cto", status: "idle", lastActivityAt: "2026-03-05T00:00:00.000Z" }]), + } as any, + laneService: { ensurePrimaryLane: vi.fn(async () => {}), list: vi.fn(async () => [{ id: "lane-1", laneType: "primary" }]) } as any, + templateService: { renderTemplate: vi.fn(() => ({ prompt: "Please own this issue." })) } as any, + closeoutService: { applyOutcome: vi.fn(async () => {}) } as any, + outboundService: createOutboundServiceMocks(), + workerTaskSessionService: { + deriveTaskKey: vi.fn(() => "task-key-1"), + ensureTaskSession: vi.fn(() => ({ id: "task-session-1" })), + } as any, + prService: { + getForLane: vi.fn(() => null), + createFromLane: vi.fn(async () => ({ id: "pr-1", githubPrNumber: 101 })), + } as any, + }); + + const run = dispatcher.createRun({ ...issueFixture, labels: ["workflow:backend"], assigneeName: "CTO" }, buildMatch(policy)); + const preserved = await dispatcher.advanceRun(run.id, policy); + + expect(preserved?.status).toBe("waiting_for_target"); + expect(preserved?.linkedSessionId).toBe("session-cto-1"); + expect(dispatcher.listQueue()[0]?.status).not.toBe("retry_wait"); + + const detail = await dispatcher.getRunDetail(run.id, policy); + expect(detail?.steps.find((step) => step.workflowStepId === "launch")?.status).toBe("completed"); + + const resumed = await dispatcher.advanceRun(run.id, policy); + expect(resumed?.status).toBe("waiting_for_target"); + expect(ensureIdentitySession).toHaveBeenCalledTimes(1); + db.close(); + }); + + it("still completes delegated workflows that are configured to complete on launch", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "ade-linear-dispatcher-complete-on-launch-")); + const db = await openKvDb(path.join(root, "ade.db"), { debug() {}, info() {}, warn() {}, error() {} } as any); + const policy = buildDirectCtoSessionPolicy(); + policy.workflows[0]!.steps = [ + { id: "launch", type: "launch_target", name: "Launch chat" }, + { id: "complete", type: "complete_issue", name: "Complete issue" }, + ]; + const closeout = vi.fn(async () => {}); + + const dispatcher = createLinearDispatcherService({ + db, + projectId: "project-1", + issueTracker: { + fetchIssueById: vi.fn(async () => issueFixture), + fetchWorkflowStates: vi.fn(async () => []), + updateIssueState: vi.fn(async () => {}), + addLabel: vi.fn(async () => {}), + createComment: vi.fn(async () => ({ commentId: "comment-1" })), + } as any, + workerAgentService: { listAgents: vi.fn(() => []) } as any, + workerHeartbeatService: { triggerWakeup: vi.fn(), listRuns: vi.fn(() => []) } as any, + missionService: { create: vi.fn(), get: vi.fn() } as any, + aiOrchestratorService: { startMissionRun: vi.fn() } as any, + agentChatService: { + ensureIdentitySession: vi.fn(async () => ({ id: "session-cto-1" })), + sendMessage: vi.fn(async () => ({ id: "message-1" })), + listSessions: vi.fn(async () => [{ sessionId: "session-cto-1", laneId: "lane-1", status: "idle" }]), + } as any, + laneService: { ensurePrimaryLane: vi.fn(async () => {}), list: vi.fn(async () => [{ id: "lane-1", laneType: "primary" }]) } as any, + templateService: { renderTemplate: vi.fn(() => ({ prompt: "Please own this issue." })) } as any, + closeoutService: { applyOutcome: closeout } as any, + outboundService: createOutboundServiceMocks(), + workerTaskSessionService: { + deriveTaskKey: vi.fn(() => "task-key-1"), + ensureTaskSession: vi.fn(() => ({ id: "task-session-1" })), + } as any, + prService: { + getForLane: vi.fn(() => null), + createFromLane: vi.fn(async () => ({ id: "pr-1", githubPrNumber: 101 })), + } as any, + }); + + const run = dispatcher.createRun({ ...issueFixture, labels: ["workflow:backend"], assigneeName: "CTO" }, buildMatch(policy)); + const completed = await dispatcher.advanceRun(run.id, policy); + + expect(completed?.status).toBe("completed"); + expect(closeout).toHaveBeenCalledTimes(1); + db.close(); + }); + + it("keeps a single Linear workpad comment live through delegated worker execution, PR linking, and closeout", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "ade-linear-dispatcher-workpad-integration-")); + const db = await openKvDb(path.join(root, "ade.db"), { debug() {}, info() {}, warn() {}, error() {} } as any); + const artifactPath = path.join(root, "proof.txt"); + fs.writeFileSync(artifactPath, "proof", "utf8"); + + const policy = buildWorkerExplicitCompletionPolicy(); + const workflow = policy.workflows[0]!; + workflow.target.laneSelection = "fresh_issue_lane"; + workflow.target.prStrategy = { kind: "per-lane", draft: true }; + workflow.target.prTiming = "after_start"; + + const createComment = vi.fn(async () => ({ commentId: "comment-1" })); + const updateBodies: string[] = []; + const updateComment = vi.fn(async (_commentId: string, body: string) => { + updateBodies.push(body); + }); + const issueTracker = { + fetchIssueById: vi.fn(async () => issueFixture), + fetchWorkflowStates: vi.fn(async () => [{ id: "state-review", name: "In Review", type: "started", teamId: "team-1", teamKey: "ACME" }]), + updateIssueState: vi.fn(async () => {}), + addLabel: vi.fn(async () => {}), + createComment, + updateComment, + uploadAttachment: vi.fn(), + } as any; + const prService = { + getForLane: vi.fn(() => null), + getStatus: vi.fn(async () => ({ + prId: "pr-55", + state: "open", + checksStatus: "passing", + reviewStatus: "requested", + isMergeable: true, + mergeConflicts: false, + behindBaseBy: 0, + })), + createFromLane: vi.fn(async () => ({ id: "pr-55", githubPrNumber: 55 })), + listAll: vi.fn(() => [{ id: "pr-55", githubUrl: "https://github.com/acme/repo/pull/55" }]), + } as any; + const outboundService = createLinearOutboundService({ + db, + projectId: "project-1", + projectRoot: root, + issueTracker, + logger: { debug() {}, info() {}, warn() {}, error() {} } as any, + }); + const closeoutService = createLinearCloseoutService({ + issueTracker, + outboundService, + missionService: { get: vi.fn(() => null) } as any, + orchestratorService: { getArtifactsForMission: vi.fn(() => []) } as any, + prService, + computerUseArtifactBrokerService: { + listArtifacts: vi.fn(() => [{ uri: artifactPath }]), + } as any, + }); + + const dispatcher = createLinearDispatcherService({ + db, + projectId: "project-1", + issueTracker, + workerAgentService: { + listAgents: vi.fn(() => [{ id: "agent-1", slug: "backend-dev", adapterType: "codex-local", capabilities: [] }]), + } as any, + workerHeartbeatService: { + triggerWakeup: vi.fn(async () => ({ runId: "worker-run-1" })), + listRuns: vi.fn(() => [{ id: "worker-run-1", status: "completed" }]), + } as any, + missionService: { create: vi.fn(), get: vi.fn() } as any, + aiOrchestratorService: { startMissionRun: vi.fn() } as any, + agentChatService: { ensureIdentitySession: vi.fn(), sendMessage: vi.fn(async () => {}), listSessions: vi.fn(async () => []) } as any, + laneService: { + ensurePrimaryLane: vi.fn(async () => {}), + list: vi.fn(async () => [{ id: "lane-1", laneType: "primary" }]), + create: vi.fn(async () => ({ id: "lane-2", name: "ABC-42 fresh lane" })), + } as any, + templateService: { renderTemplate: vi.fn(() => ({ prompt: "Implement the issue." })) } as any, + closeoutService, + outboundService, + workerTaskSessionService: { + deriveTaskKey: vi.fn(() => "task-key-1"), + ensureTaskSession: vi.fn(() => ({ id: "task-session-1" })), + } as any, + prService, + }); + + const run = dispatcher.createRun({ ...issueFixture, labels: ["workflow:explicit-complete"] }, buildMatch(policy)); + await dispatcher.advanceRun(run.id, policy); + await dispatcher.resolveRunAction(run.id, "complete", "Validated with proof and PR.", policy); + const completed = await dispatcher.advanceRun(run.id, policy); + + expect(completed?.status).toBe("completed"); + expect(createComment).toHaveBeenCalledTimes(1); + expect(updateBodies.length).toBeGreaterThanOrEqual(2); + expect(updateBodies[0]).toContain("- Lane: lane-2"); + expect(updateBodies[0]).toContain("- Worker run: worker-run-1"); + expect(updateBodies[0]).toContain("- PR: pr-55"); + expect(updateBodies[updateBodies.length - 1]).toContain("### Closeout Summary"); + expect(updateBodies[updateBodies.length - 1]).toContain("https://github.com/acme/repo/pull/55"); + expect(updateBodies[updateBodies.length - 1]).toContain(pathToFileURL(fs.realpathSync(artifactPath)).href); + db.close(); + }); + + it("persists downstream session ownership after handing work from a worker to an employee session", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "ade-linear-dispatcher-downstream-session-")); + const db = await openKvDb(path.join(root, "ade.db"), { debug() {}, info() {}, warn() {}, error() {} } as any); + const policy = buildDownstreamEmployeeSessionPolicy(); + const outboundService = createOutboundServiceMocks(); + + const dispatcher = createLinearDispatcherService({ + db, + projectId: "project-1", + issueTracker: { + fetchIssueById: vi.fn(async () => issueFixture), + fetchWorkflowStates: vi.fn(async () => []), + updateIssueState: vi.fn(async () => {}), + addLabel: vi.fn(async () => {}), + createComment: vi.fn(async () => ({ commentId: "comment-1" })), + } as any, + workerAgentService: { + listAgents: vi.fn(() => [{ id: "agent-1", slug: "backend-dev", adapterType: "claude-local", capabilities: [] }]), + } as any, + workerHeartbeatService: { + triggerWakeup: vi.fn(async () => ({ runId: "worker-run-1" })), + listRuns: vi.fn(() => [{ id: "worker-run-1", status: "completed" }]), + } as any, + missionService: { create: vi.fn(), get: vi.fn() } as any, + aiOrchestratorService: { startMissionRun: vi.fn() } as any, + agentChatService: { + ensureIdentitySession: vi.fn(async () => ({ id: "session-cto-2" })), + sendMessage: vi.fn(async () => ({ id: "message-1" })), + listSessions: vi.fn(async () => [{ sessionId: "session-cto-2", laneId: "lane-1", status: "idle", identityKey: "cto" }]), + } as any, + laneService: { ensurePrimaryLane: vi.fn(async () => {}), list: vi.fn(async () => [{ id: "lane-1", laneType: "primary" }]) } as any, + templateService: { renderTemplate: vi.fn(() => ({ prompt: "Please own this issue." })) } as any, + closeoutService: { applyOutcome: vi.fn(async () => {}) } as any, + outboundService, + workerTaskSessionService: { + deriveTaskKey: vi.fn(() => "task-key-1"), + ensureTaskSession: vi.fn(() => ({ id: "task-session-1" })), + } as any, + prService: { + getForLane: vi.fn(() => null), + createFromLane: vi.fn(async () => ({ id: "pr-1", githubPrNumber: 101 })), + } as any, + }); + + const run = dispatcher.createRun({ ...issueFixture, labels: ["workflow:downstream-session"] }, buildMatch(policy)); + await dispatcher.advanceRun(run.id, policy); + await dispatcher.advanceRun(run.id, policy); + + const queueItem = dispatcher.listQueue()[0]!; + expect(queueItem.sessionId).toBe("session-cto-2"); + expect(queueItem.sessionLabel).toBe("CTO"); + expect(queueItem.workerId).toBeNull(); + expect(queueItem.workerSlug).toBeNull(); + expect(outboundService.publishWorkflowStatus).toHaveBeenLastCalledWith(expect.objectContaining({ + delegatedOwner: "CTO", + sessionId: "session-cto-2", + })); + db.close(); + }); + + it("pauses for supervisor approval and can resume after approval", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "ade-linear-dispatcher-supervisor-")); + const db = await openKvDb(path.join(root, "ade.db"), { debug() {}, info() {}, warn() {}, error() {} } as any); + const policy = buildSupervisedWorkerPolicy(); + const createLane = vi.fn(async () => ({ id: "lane-2", name: "Fresh lane" })); + const listRuns = vi.fn(() => [{ id: "worker-run-1", status: "completed" }]); + + const dispatcher = createLinearDispatcherService({ + db, + projectId: "project-1", + issueTracker: { + fetchIssueById: vi.fn(async () => issueFixture), + fetchWorkflowStates: vi.fn(async () => []), + updateIssueState: vi.fn(async () => {}), + addLabel: vi.fn(async () => {}), + createComment: vi.fn(async () => ({ commentId: "comment-1" })), + } as any, + workerAgentService: { + listAgents: vi.fn(() => [{ id: "agent-1", slug: "backend-dev", adapterType: "claude-local", capabilities: [] }]), + } as any, + workerHeartbeatService: { + triggerWakeup: vi.fn(async () => ({ runId: "worker-run-1" })), + listRuns, + } as any, + missionService: { create: vi.fn(), get: vi.fn() } as any, + aiOrchestratorService: { startMissionRun: vi.fn() } as any, + agentChatService: { ensureIdentitySession: vi.fn(), sendMessage: vi.fn(async () => {}), listSessions: vi.fn(async () => []) } as any, + laneService: { + ensurePrimaryLane: vi.fn(async () => {}), + list: vi.fn(async () => [{ id: "lane-1", laneType: "primary" }]), + create: createLane, + } as any, + templateService: { renderTemplate: vi.fn(() => ({ prompt: "Implement the issue." })) } as any, + closeoutService: { applyOutcome: vi.fn(async () => {}) } as any, + outboundService: createOutboundServiceMocks(), + workerTaskSessionService: { + deriveTaskKey: vi.fn(() => "task-key-1"), + ensureTaskSession: vi.fn(() => ({ id: "task-session-1" })), + } as any, + prService: { + getForLane: vi.fn(() => null), + createFromLane: vi.fn(async () => ({ id: "pr-1", githubPrNumber: 101 })), + } as any, + }); + + const run = dispatcher.createRun({ ...issueFixture, labels: ["workflow:backend-supervised"] }, buildMatch(policy)); + await dispatcher.advanceRun(run.id, policy); + const awaitingReview = await dispatcher.advanceRun(run.id, policy); + + expect(awaitingReview?.status).toBe("awaiting_human_review"); + expect(dispatcher.listQueue()[0]?.status).toBe("escalated"); + + await dispatcher.resolveRunAction(run.id, "approve", "Looks good.", policy); + const completed = await dispatcher.advanceRun(run.id, policy); + expect(completed?.status).toBe("completed"); + db.close(); + }); + + it("loops back to delegated work when supervisor requests changes", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "ade-linear-dispatcher-loopback-")); + const db = await openKvDb(path.join(root, "ade.db"), { debug() {}, info() {}, warn() {}, error() {} } as any); + const policy = buildSupervisedWorkerPolicy(); + + const dispatcher = createLinearDispatcherService({ + db, + projectId: "project-1", + issueTracker: { + fetchIssueById: vi.fn(async () => issueFixture), + fetchWorkflowStates: vi.fn(async () => []), + updateIssueState: vi.fn(async () => {}), + addLabel: vi.fn(async () => {}), + createComment: vi.fn(async () => ({ commentId: "comment-1" })), + } as any, + workerAgentService: { + listAgents: vi.fn(() => [{ id: "agent-1", slug: "backend-dev", adapterType: "claude-local", capabilities: [] }]), + } as any, + workerHeartbeatService: { + triggerWakeup: vi.fn(async () => ({ runId: "worker-run-1" })), + listRuns: vi.fn(() => [{ id: "worker-run-1", status: "completed" }]), + } as any, + missionService: { create: vi.fn(), get: vi.fn() } as any, + aiOrchestratorService: { startMissionRun: vi.fn() } as any, + agentChatService: { ensureIdentitySession: vi.fn(), sendMessage: vi.fn(async () => {}), listSessions: vi.fn(async () => []) } as any, + laneService: { + ensurePrimaryLane: vi.fn(async () => {}), + list: vi.fn(async () => [{ id: "lane-1", laneType: "primary" }]), + create: vi.fn(async () => ({ id: "lane-2", name: "Fresh lane" })), + } as any, + templateService: { renderTemplate: vi.fn(() => ({ prompt: "Implement the issue." })) } as any, + closeoutService: { applyOutcome: vi.fn(async () => {}) } as any, + outboundService: createOutboundServiceMocks(), + workerTaskSessionService: { + deriveTaskKey: vi.fn(() => "task-key-1"), + ensureTaskSession: vi.fn(() => ({ id: "task-session-1" })), + } as any, + prService: { + getForLane: vi.fn(() => null), + createFromLane: vi.fn(async () => ({ id: "pr-1", githubPrNumber: 101 })), + } as any, + }); + + const run = dispatcher.createRun({ ...issueFixture, labels: ["workflow:backend-supervised"] }, buildMatch(policy)); + await dispatcher.advanceRun(run.id, policy); + await dispatcher.advanceRun(run.id, policy); + await dispatcher.resolveRunAction(run.id, "reject", "Please tighten the implementation.", policy); + const looped = await dispatcher.advanceRun(run.id, policy); + + expect(looped?.status).toBe("queued"); + expect(looped?.currentStepId).toBe("launch"); + expect(dispatcher.listQueue()[0]?.reviewState).toBe("changes_requested"); + db.close(); + }); + + it("links or creates a PR before closing out review-ready runs", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "ade-linear-dispatcher-pr-")); + const db = await openKvDb(path.join(root, "ade.db"), { debug() {}, info() {}, warn() {}, error() {} } as any); + const policy = buildPrPolicy(); + const events: Array<{ type: string; milestone?: string; level?: string }> = []; + const closeout = vi.fn(async () => {}); + const createFromLane = vi.fn(async () => ({ id: "pr-42", githubPrNumber: 42 })); + + const dispatcher = createLinearDispatcherService({ + db, + projectId: "project-1", + issueTracker: { + fetchIssueById: vi.fn(async () => issueFixture), + fetchWorkflowStates: vi.fn(async () => [{ id: "state-review", name: "In Review", type: "started", teamId: "team-1", teamKey: "ACME" }]), + updateIssueState: vi.fn(async () => {}), + addLabel: vi.fn(async () => {}), + createComment: vi.fn(async () => ({ commentId: "comment-1" })), + } as any, + workerAgentService: { listAgents: vi.fn(() => [{ id: "agent-1", slug: "backend-dev", adapterType: "claude-local", capabilities: [] }]) } as any, + workerHeartbeatService: { + triggerWakeup: vi.fn(async () => ({ runId: "worker-run-1" })), + listRuns: vi.fn(() => []), + } as any, + missionService: { create: vi.fn(), get: vi.fn() } as any, + aiOrchestratorService: { startMissionRun: vi.fn() } as any, + agentChatService: { ensureIdentitySession: vi.fn(), sendMessage: vi.fn(async () => {}), listSessions: vi.fn(async () => []) } as any, + laneService: { ensurePrimaryLane: vi.fn(async () => {}), list: vi.fn(async () => [{ id: "lane-1", laneType: "primary" }]) } as any, + templateService: { renderTemplate: vi.fn(() => ({ prompt: "Open a PR." })) } as any, + closeoutService: { applyOutcome: closeout } as any, + outboundService: createOutboundServiceMocks(), + workerTaskSessionService: { + deriveTaskKey: vi.fn(() => "task-key-1"), + ensureTaskSession: vi.fn(() => ({ id: "task-session-1" })), + } as any, + prService: { + getForLane: vi.fn(() => null), + createFromLane, + } as any, + onEvent: (event) => events.push({ type: event.type, milestone: (event as any).milestone, level: (event as any).level }), + }); + + const run = dispatcher.createRun({ ...issueFixture, labels: ["workflow:backend"] }, buildMatch(policy)); + await dispatcher.advanceRun(run.id, policy); + const finalRun = await dispatcher.advanceRun(run.id, policy); + + expect(createFromLane).toHaveBeenCalledTimes(1); + expect(finalRun?.linkedPrId).toBe("pr-42"); + expect(finalRun?.status).toBe("completed"); + expect(closeout).toHaveBeenCalledTimes(1); + expect(events.some((entry) => entry.milestone === "pr_linked")).toBe(true); + expect(events.some((entry) => entry.milestone === "review_ready")).toBe(true); + db.close(); + }); + + it("waits for a PR to become review-ready before closing out", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "ade-linear-dispatcher-pr-ready-")); + const db = await openKvDb(path.join(root, "ade.db"), { debug() {}, info() {}, warn() {}, error() {} } as any); + const policy = buildPrReadyPolicy(); + const getStatus = vi + .fn() + .mockResolvedValueOnce({ + prId: "pr-42", + state: "open", + checksStatus: "failing", + reviewStatus: "requested", + isMergeable: false, + mergeConflicts: false, + behindBaseBy: 0, + }) + .mockResolvedValueOnce({ + prId: "pr-42", + state: "open", + checksStatus: "failing", + reviewStatus: "requested", + isMergeable: false, + mergeConflicts: false, + behindBaseBy: 0, + }) + .mockResolvedValueOnce({ + prId: "pr-42", + state: "open", + checksStatus: "passing", + reviewStatus: "approved", + isMergeable: true, + mergeConflicts: false, + behindBaseBy: 0, + }) + .mockResolvedValueOnce({ + prId: "pr-42", + state: "open", + checksStatus: "passing", + reviewStatus: "approved", + isMergeable: true, + mergeConflicts: false, + behindBaseBy: 0, + }); + + const dispatcher = createLinearDispatcherService({ + db, + projectId: "project-1", + issueTracker: { + fetchIssueById: vi.fn(async () => issueFixture), + fetchWorkflowStates: vi.fn(async () => []), + updateIssueState: vi.fn(async () => {}), + addLabel: vi.fn(async () => {}), + createComment: vi.fn(async () => ({ commentId: "comment-1" })), + } as any, + workerAgentService: { listAgents: vi.fn(() => [{ id: "agent-1", slug: "backend-dev", adapterType: "claude-local", capabilities: [] }]) } as any, + workerHeartbeatService: { + triggerWakeup: vi.fn(async () => ({ runId: "worker-run-1" })), + listRuns: vi.fn(() => []), + } as any, + missionService: { create: vi.fn(), get: vi.fn() } as any, + aiOrchestratorService: { startMissionRun: vi.fn() } as any, + agentChatService: { ensureIdentitySession: vi.fn(), sendMessage: vi.fn(async () => {}), listSessions: vi.fn(async () => []) } as any, + laneService: { ensurePrimaryLane: vi.fn(async () => {}), list: vi.fn(async () => [{ id: "lane-1", laneType: "primary" }]) } as any, + templateService: { renderTemplate: vi.fn(() => ({ prompt: "Open a review-ready PR." })) } as any, + closeoutService: { applyOutcome: vi.fn(async () => {}) } as any, + outboundService: createOutboundServiceMocks(), + workerTaskSessionService: { + deriveTaskKey: vi.fn(() => "task-key-1"), + ensureTaskSession: vi.fn(() => ({ id: "task-session-1" })), + } as any, + prService: { + getForLane: vi.fn(() => null), + getStatus, + createFromLane: vi.fn(async () => ({ id: "pr-42", githubPrNumber: 42 })), + } as any, + }); + + const run = dispatcher.createRun({ ...issueFixture, labels: ["workflow:backend"] }, buildMatch(policy)); + await dispatcher.advanceRun(run.id, policy); + const waiting = await dispatcher.advanceRun(run.id, policy); + expect(waiting?.status).toBe("waiting_for_pr"); + + const resolved = await dispatcher.advanceRun(run.id, policy); + expect(resolved?.status).toBe("completed"); + expect(getStatus).toHaveBeenCalledTimes(4); + expect(dispatcher.listQueue()[0]?.prChecksStatus).toBe("passing"); + expect(dispatcher.listQueue()[0]?.prReviewStatus).toBe("approved"); + db.close(); + }); + + it("fails before launching a downstream PR stage when the workflow is missing wait_for_pr", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "ade-linear-dispatcher-invalid-downstream-pr-")); + const db = await openKvDb(path.join(root, "ade.db"), { debug() {}, info() {}, warn() {}, error() {} } as any); + const policy = buildInvalidDownstreamPrPolicy(); + const triggerWakeup = vi + .fn() + .mockResolvedValueOnce({ runId: "worker-run-1" }) + .mockResolvedValueOnce({ runId: "worker-run-2" }); + + const dispatcher = createLinearDispatcherService({ + db, + projectId: "project-1", + issueTracker: { + fetchIssueById: vi.fn(async () => issueFixture), + fetchWorkflowStates: vi.fn(async () => []), + updateIssueState: vi.fn(async () => {}), + addLabel: vi.fn(async () => {}), + createComment: vi.fn(async () => ({ commentId: "comment-1" })), + } as any, + workerAgentService: { listAgents: vi.fn(() => [{ id: "agent-1", slug: "backend-dev", adapterType: "claude-local", capabilities: [] }]) } as any, + workerHeartbeatService: { + triggerWakeup, + listRuns: vi.fn(() => [{ id: "worker-run-1", status: "completed" }]), + } as any, + missionService: { create: vi.fn(), get: vi.fn() } as any, + aiOrchestratorService: { startMissionRun: vi.fn() } as any, + agentChatService: { ensureIdentitySession: vi.fn(), sendMessage: vi.fn(async () => {}), listSessions: vi.fn(async () => []) } as any, + laneService: { ensurePrimaryLane: vi.fn(async () => {}), list: vi.fn(async () => [{ id: "lane-1", laneType: "primary" }]) } as any, + templateService: { renderTemplate: vi.fn(() => ({ prompt: "Open a PR." })) } as any, + closeoutService: { applyOutcome: vi.fn(async () => {}) } as any, + outboundService: createOutboundServiceMocks(), + workerTaskSessionService: { + deriveTaskKey: vi.fn(() => "task-key-1"), + ensureTaskSession: vi.fn(() => ({ id: "task-session-1" })), + } as any, + prService: { + getForLane: vi.fn(() => null), + createFromLane: vi.fn(async () => ({ id: "pr-42", githubPrNumber: 42 })), + } as any, + }); + + const run = dispatcher.createRun({ ...issueFixture, labels: ["workflow:downstream-pr"] }, buildMatch(policy)); + await dispatcher.advanceRun(run.id, policy); + + const retried = await dispatcher.advanceRun(run.id, policy); + expect(retried?.status).toBe("failed"); + expect(triggerWakeup).toHaveBeenCalledTimes(1); + db.close(); + }); + }); + +}); + +describe("linearOutboundService (file group)", () => { + + function createLogger() { + return { + debug: () => {}, + info: () => {}, + warn: () => {}, + error: () => {}, + } as any; + } + + const issueFixture: NormalizedLinearIssue = { + id: "issue-1", + identifier: "ABC-12", + title: "Stabilize auth refresh", + description: "Auth refresh sometimes fails after idle timeout.", + url: "https://linear.app/acme/issue/ABC-12", + projectId: "proj-1", + projectSlug: "acme-platform", + teamId: "team-1", + teamKey: "ACME", + stateId: "state-1", + stateName: "Todo", + stateType: "unstarted", + priority: 2, + priorityLabel: "high", + labels: ["bug"], + assigneeId: null, + assigneeName: null, + ownerId: "user-1", + blockerIssueIds: [], + hasOpenBlockers: false, + createdAt: "2026-03-05T00:00:00.000Z", + updatedAt: "2026-03-05T00:00:00.000Z", + raw: {}, + }; + + describe("linearOutboundService", () => { + it("keeps one persistent workpad comment and avoids duplicate updates for identical body", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "ade-linear-outbound-")); + const db = await openKvDb(path.join(root, "ade.db"), createLogger()); + + const createComment = vi.fn(async () => ({ commentId: "comment-1" })); + const updateComment = vi.fn(async () => {}); + const service = createLinearOutboundService({ + db, + projectId: "project-1", + projectRoot: root, + issueTracker: { + createComment, + updateComment, + uploadAttachment: vi.fn(), + } as any, + logger: createLogger(), + }); + + await service.publishMissionStart({ + issue: issueFixture, + missionId: "mission-1", + missionTitle: "Fix auth refresh", + templateId: "bug-fix", + routeReason: "Matched bug rule", + workerName: "Backend Dev", + }); + expect(createComment).toHaveBeenCalledTimes(1); + + await service.publishMissionProgress({ + issue: issueFixture, + missionId: "mission-1", + status: "in_progress", + stepSummary: "1/3 steps completed.", + }); + await service.publishMissionProgress({ + issue: issueFixture, + missionId: "mission-1", + status: "in_progress", + stepSummary: "1/3 steps completed.", + }); + + expect(updateComment).toHaveBeenCalledTimes(1); + db.close(); + }); + + it("reuses the same workpad comment across workflow launch, progress, and final closeout", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "ade-linear-workpad-lifecycle-")); + const db = await openKvDb(path.join(root, "ade.db"), createLogger()); + const createComment = vi.fn(async () => ({ commentId: "comment-workpad-1" })); + const updateBodies: string[] = []; + const updateComment = vi.fn(async (_commentId: string, body: string) => { + updateBodies.push(body); + }); + const service = createLinearOutboundService({ + db, + projectId: "project-1", + projectRoot: root, + issueTracker: { + createComment, + updateComment, + uploadAttachment: vi.fn(), + } as any, + logger: createLogger(), + }); + + await service.publishWorkflowStatus({ + issue: issueFixture, + workflowName: "Assigned worker run", + runId: "run-1", + targetType: "worker_run", + state: "waiting_for_target", + currentStep: "Launch worker run", + delegatedOwner: "backend-dev", + laneId: "lane-22", + workerRunId: "worker-run-22", + note: "Delegated the issue into a dedicated worker lane.", + }); + await service.publishWorkflowStatus({ + issue: issueFixture, + workflowName: "Assigned worker run", + runId: "run-1", + targetType: "worker_run", + state: "waiting_for_pr", + currentStep: "Wait for PR", + delegatedOwner: "backend-dev", + laneId: "lane-22", + workerRunId: "worker-run-22", + prId: "pr-22", + waitingFor: "review-ready PR", + note: "PR linked and awaiting review-ready state.", + }); + await service.publishWorkflowCloseout({ + issue: issueFixture, + status: "completed", + summary: "Validated proof and closed out the delegated workflow.", + targetLabel: "worker run", + targetId: "worker-run-22", + contextLines: ["Lane: lane-22", "Linked PR record: pr-22"], + prLinks: ["https://github.com/acme/repo/pull/22"], + artifactMode: "links", + }); + + expect(createComment).toHaveBeenCalledTimes(1); + expect(updateComment).toHaveBeenCalledTimes(2); + expect(updateBodies[0]).toContain("- Lane: lane-22"); + expect(updateBodies[0]).toContain("- Worker run: worker-run-22"); + expect(updateBodies[1]).toContain("### Closeout Summary"); + expect(updateBodies[1]).toContain("https://github.com/acme/repo/pull/22"); + + const stored = db.get<{ comment_id: string }>( + `select comment_id from linear_workpads where project_id = ? and issue_id = ? limit 1`, + ["project-1", issueFixture.id] + ); + expect(stored?.comment_id).toBe("comment-workpad-1"); + db.close(); + }); + + it("preserves inside-project artifact links and rejects files outside the project root", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "ade-linear-artifacts-")); + const db = await openKvDb(path.join(root, "ade.db"), createLogger()); + const insideArtifact = path.join(root, "build.log"); + fs.writeFileSync(insideArtifact, "log", "utf8"); + const outsideArtifact = path.join(os.tmpdir(), "outside.log"); + fs.writeFileSync(outsideArtifact, "outside", "utf8"); + const insideCanonicalUri = pathToFileURL(fs.realpathSync(insideArtifact)).href; + const outsideCanonicalUri = pathToFileURL(fs.realpathSync(outsideArtifact)).href; + const insideUri = pathToFileURL(insideArtifact).href; + const outsideUri = pathToFileURL(outsideArtifact).href; + + const updateBodies: string[] = []; + const service = createLinearOutboundService({ + db, + projectId: "project-1", + projectRoot: root, + issueTracker: { + createComment: vi.fn(async () => ({ commentId: "comment-1" })), + updateComment: vi.fn(async (_commentId: string, body: string) => { + updateBodies.push(body); + }), + uploadAttachment: vi.fn(), + } as any, + logger: createLogger(), + }); + + await service.publishMissionStart({ + issue: issueFixture, + missionId: "mission-1", + missionTitle: "Auth mission", + templateId: "bug-fix", + routeReason: "Matched bug", + }); + + await service.publishWorkflowCloseout({ + issue: issueFixture, + status: "completed", + summary: "Shipped", + targetLabel: "employee session", + targetId: "session-1", + contextLines: ["Workflow target: employee_session"], + artifactMode: "links", + artifactPaths: [insideArtifact, insideUri, outsideArtifact, outsideUri, "https://example.com/artifact.txt"], + }); + + const latest = updateBodies[updateBodies.length - 1] ?? ""; + expect(latest).toContain(insideCanonicalUri); + expect(latest).toContain("https://example.com/artifact.txt"); + expect(latest).not.toContain(outsideCanonicalUri); + db.close(); + }); + + it("uploads attachment-mode artifacts from inside the project root and skips files outside it", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "ade-linear-attachment-upload-")); + const db = await openKvDb(path.join(root, "ade.db"), createLogger()); + const insideArtifact = path.join(root, "inside.png"); + const outsideArtifact = path.join(os.tmpdir(), `ade-outside-${Date.now()}.png`); + fs.writeFileSync(insideArtifact, "inside", "utf8"); + fs.writeFileSync(outsideArtifact, "outside", "utf8"); + + const uploadAttachment = vi.fn(async ({ filePath }: { filePath: string }) => ({ + url: `https://linear.example/${path.basename(filePath)}`, + })); + const updateBodies: string[] = []; + const service = createLinearOutboundService({ + db, + projectId: "project-1", + projectRoot: root, + issueTracker: { + createComment: vi.fn(async () => ({ commentId: "comment-1" })), + updateComment: vi.fn(async (_commentId: string, body: string) => { + updateBodies.push(body); + }), + uploadAttachment, + } as any, + logger: createLogger(), + }); + + await service.publishMissionStart({ + issue: issueFixture, + missionId: "mission-1", + missionTitle: "Auth mission", + templateId: "bug-fix", + routeReason: "Matched bug", + }); + + await service.publishWorkflowCloseout({ + issue: issueFixture, + status: "completed", + summary: "Uploaded proof artifacts.", + targetLabel: "employee session", + targetId: "session-1", + artifactMode: "attachments", + artifactPaths: [insideArtifact, outsideArtifact], + }); + + expect(uploadAttachment).toHaveBeenCalledTimes(1); + expect(uploadAttachment).toHaveBeenCalledWith({ + issueId: issueFixture.id, + filePath: insideArtifact, + title: path.basename(insideArtifact), + }); + + const latest = updateBodies[updateBodies.length - 1] ?? ""; + expect(latest).toContain(`https://linear.example/${path.basename(insideArtifact)}`); + expect(latest).not.toContain(`https://linear.example/${path.basename(outsideArtifact)}`); + db.close(); + }); + + it("keeps the mission closeout wrapper body shape stable", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "ade-linear-mission-closeout-")); + const db = await openKvDb(path.join(root, "ade.db"), createLogger()); + const updateBodies: string[] = []; + const service = createLinearOutboundService({ + db, + projectId: "project-1", + projectRoot: root, + issueTracker: { + createComment: vi.fn(async () => ({ commentId: "comment-1" })), + updateComment: vi.fn(async (_commentId: string, body: string) => { + updateBodies.push(body); + }), + uploadAttachment: vi.fn(), + } as any, + logger: createLogger(), + }); + + await service.publishMissionStart({ + issue: issueFixture, + missionId: "mission-1", + missionTitle: "Auth mission", + templateId: "bug-fix", + routeReason: "Matched bug", + }); + + await service.publishMissionCloseout({ + issue: issueFixture, + missionId: "mission-1", + status: "completed", + summary: "Shipped", + artifactMode: "links", + }); + + const latest = updateBodies[updateBodies.length - 1] ?? ""; + expect(latest).toContain("- Mission: mission-1"); + expect(latest).not.toContain("- Target:"); + db.close(); + }); + + it("renders comment templates for workflow status and closeout bodies", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "ade-linear-template-comment-")); + const db = await openKvDb(path.join(root, "ade.db"), createLogger()); + const createBodies: string[] = []; + const bodies: string[] = []; + const service = createLinearOutboundService({ + db, + projectId: "project-1", + projectRoot: root, + issueTracker: { + createComment: vi.fn(async (_issueId: string, body: string) => { + createBodies.push(body); + return { commentId: "comment-1" }; + }), + updateComment: vi.fn(async (_commentId: string, body: string) => { + bodies.push(body); + }), + uploadAttachment: vi.fn(), + } as any, + logger: createLogger(), + }); + + await service.publishWorkflowStatus({ + issue: issueFixture, + workflowName: "Assigned worker run", + runId: "run-7", + targetType: "worker_run", + state: "waiting_for_target", + note: "Delegated the issue.", + waitingFor: "delegated work", + commentTemplate: [ + "Issue {{ issue.identifier }}", + "Workflow {{ workflow.name }}", + "Target {{ target.type }}", + "Note {{ note }}", + ].join("\n"), + }); + + await service.publishWorkflowCloseout({ + issue: issueFixture, + status: "completed", + summary: "Closed.", + targetLabel: "worker run", + targetId: "worker-22", + artifactMode: "links", + commentTemplate: "Closeout {{ issue.identifier }} {{ target.id }} {{ note }}", + }); + + expect(createBodies[0]).toContain("Issue ABC-12"); + expect(createBodies[0]).toContain("Workflow Assigned worker run"); + expect(createBodies[0]).toContain("Target worker_run"); + expect(bodies[0]).toContain("Closeout ABC-12 worker-22 Closed."); + db.close(); + }); + }); + +}); + +describe("linearTemplateService (file group)", () => { + + const issueFixture: NormalizedLinearIssue = { + id: "issue-1", + identifier: "ABC-12", + title: "Fix auth token refresh", + description: "Refresh token flow fails when access token is expired.", + url: "https://linear.app/acme/issue/ABC-12", + projectId: "proj-1", + projectSlug: "acme-platform", + teamId: "team-1", + teamKey: "ACME", + stateId: "state-1", + stateName: "Todo", + stateType: "unstarted", + priority: 2, + priorityLabel: "high", + labels: ["bug", "auth"], + assigneeId: null, + assigneeName: null, + ownerId: "user-1", + blockerIssueIds: [], + hasOpenBlockers: false, + createdAt: "2026-03-05T00:00:00.000Z", + updatedAt: "2026-03-05T00:00:00.000Z", + raw: {}, + }; + + describe("linearTemplateService", () => { + it("renders placeholders from template yaml", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "ade-linear-template-")); + const templatesDir = path.join(root, ".ade", "templates"); + fs.mkdirSync(templatesDir, { recursive: true }); + fs.writeFileSync( + path.join(templatesDir, "bug.yaml"), + [ + "id: bug-fix", + "name: Bug Fix", + "promptTemplate: |-", + " Issue {{ issue.identifier }}", + " Worker {{ worker.name }}", + " Reason {{ route.reason }}", + ].join("\n"), + "utf8" + ); + + const service = createLinearTemplateService({ adeDir: path.join(root, ".ade") }); + const rendered = service.renderTemplate({ + templateId: "bug-fix", + issue: issueFixture, + route: { reason: "Matched bug rule" }, + worker: { name: "Backend Dev" }, + }); + + expect(rendered.templateId).toBe("bug-fix"); + expect(rendered.prompt).toContain("Issue ABC-12"); + expect(rendered.prompt).toContain("Worker Backend Dev"); + expect(rendered.prompt).toContain("Reason Matched bug rule"); + }); + + it("falls back to default template when no template files exist", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "ade-linear-template-empty-")); + const service = createLinearTemplateService({ adeDir: path.join(root, ".ade") }); + + const rendered = service.renderTemplate({ + templateId: "missing-template", + issue: issueFixture, + }); + + expect(rendered.templateId).toBe("default"); + expect(rendered.prompt).toContain("Handle the following Linear issue end to end."); + expect(rendered.prompt).toContain("ABC-12"); + }); + }); + +}); + +describe("linearWorkflowFileService (file group)", () => { + + function createFixtureRoot(): string { + return fs.mkdtempSync(path.join(os.tmpdir(), "ade-linear-workflows-")); + } + + describe("linearWorkflowFileService", () => { + it("generates editable starter workflows when the repo has no workflow files", () => { + const root = createFixtureRoot(); + const service = createLinearWorkflowFileService({ projectRoot: root }); + + const loaded = service.load(null); + + expect(loaded.source).toBe("generated"); + expect(loaded.migration?.needsSave).toBe(true); + expect(loaded.intake.activeStateTypes).toEqual(["backlog", "unstarted", "started"]); + expect(loaded.intake.terminalStateTypes).toEqual(["completed", "canceled"]); + expect(loaded.settings.ctoLinearAssigneeName).toBe("CTO"); + expect(loaded.workflows.map((workflow) => workflow.id)).toEqual([ + "cto-mission-autopilot", + "cto-direct-employee-session", + "cto-worker-run-autopilot", + "cto-pr-fast-lane", + "cto-human-review-gate", + ]); + expect(loaded.workflows.find((workflow) => workflow.id === "cto-direct-employee-session")?.steps.map((step) => step.type)).toEqual([ + "set_linear_state", + "launch_target", + "wait_for_target_status", + "emit_app_notification", + "complete_issue", + ]); + expect(loaded.workflows.find((workflow) => workflow.id === "cto-worker-run-autopilot")?.steps.map((step) => step.type)).toEqual([ + "set_linear_state", + "launch_target", + "wait_for_target_status", + "emit_app_notification", + "complete_issue", + ]); + expect(loaded.workflows.find((workflow) => workflow.id === "cto-pr-fast-lane")?.steps.map((step) => step.type)).toEqual([ + "set_linear_state", + "launch_target", + "wait_for_pr", + "emit_app_notification", + "complete_issue", + ]); + expect(loaded.workflows.find((workflow) => workflow.id === "cto-direct-employee-session")?.target.laneSelection).toBe("fresh_issue_lane"); + expect(loaded.workflows.find((workflow) => workflow.id === "cto-direct-employee-session")?.target.sessionReuse).toBe("fresh_session"); + expect(loaded.workflows.find((workflow) => workflow.id === "cto-worker-run-autopilot")?.target.laneSelection).toBe("fresh_issue_lane"); + + const saved = service.save(loaded); + + expect(saved.source).toBe("repo"); + expect(saved.files.some((file) => file.kind === "settings")).toBe(true); + expect(saved.files.filter((file) => file.kind === "workflow")).toHaveLength(5); + expect(fs.existsSync(path.join(root, ".ade", "workflows", "linear", "_settings.yaml"))).toBe(true); + }); + + it("migrates legacy LinearSyncConfig into repo workflows and writes a compatibility snapshot", () => { + const root = createFixtureRoot(); + const service = createLinearWorkflowFileService({ projectRoot: root }); + const legacy: LinearSyncConfig = { + enabled: true, + projects: [{ slug: "acme-platform", defaultWorker: "backend-dev" }], + routing: { + byLabel: { + bug: "backend-hotfix", + }, + }, + autoDispatch: { + default: "auto", + rules: [ + { + id: "legacy-bug-rule", + action: "auto", + template: "fast-track", + match: { + labels: ["bug"], + projectSlugs: ["acme-platform"], + priority: ["high"], + }, + }, + ], + }, + concurrency: { + global: 7, + }, + artifacts: { + mode: "attachments", + }, + }; + + const loaded = service.load(legacy); + const migrated = loaded.workflows.find((workflow) => workflow.id === "legacy-bug-rule"); + + expect(loaded.source).toBe("generated"); + expect(loaded.migration?.hasLegacyConfig).toBe(true); + expect(loaded.migration?.needsSave).toBe(true); + expect(loaded.intake.projectSlugs).toEqual(["acme-platform"]); + expect(loaded.intake.activeStateTypes).toEqual(["backlog", "unstarted", "started"]); + expect(loaded.intake.terminalStateTypes).toEqual(["completed", "canceled"]); + expect(migrated?.target.type).toBe("mission"); + expect(migrated?.target.missionTemplate).toBe("fast-track"); + expect(migrated?.target.workerSelector).toEqual({ mode: "slug", value: "backend-hotfix" }); + expect(migrated?.triggers.labels).toEqual(["bug"]); + expect(migrated?.triggers.projectSlugs).toEqual(["acme-platform"]); + expect(migrated?.closeout?.artifactMode).toBe("attachments"); + expect(migrated?.concurrency?.maxActiveRuns).toBe(7); + + const saved = service.save(loaded); + + expect(saved.source).toBe("repo"); + expect(saved.migration?.needsSave).toBe(false); + expect(saved.migration?.compatibilitySnapshotPath).toBe(service.legacySnapshotPath); + expect(fs.existsSync(service.legacySnapshotPath)).toBe(true); + }); + }); + +}); diff --git a/apps/desktop/src/main/services/cto/linearSyncService.test.ts b/apps/desktop/src/main/services/cto/linearSyncService.test.ts deleted file mode 100644 index 4b52e3895..000000000 --- a/apps/desktop/src/main/services/cto/linearSyncService.test.ts +++ /dev/null @@ -1,805 +0,0 @@ -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; -import { afterEach, describe, expect, it, vi } from "vitest"; -import type { LinearWorkflowConfig, NormalizedLinearIssue } from "../../../shared/types"; -import { openKvDb } from "../state/kvDb"; -import { createLinearSyncService } from "./linearSyncService"; - -const issueFixture: NormalizedLinearIssue = { - id: "issue-1", - identifier: "ABC-42", - title: "Fix flaky sync run", - description: "Occasional sync failure under load.", - url: "https://linear.app/acme/issue/ABC-42", - projectId: "proj-1", - projectSlug: "acme-platform", - teamId: "team-1", - teamKey: "ACME", - stateId: "state-todo", - stateName: "Todo", - stateType: "unstarted", - priority: 2, - priorityLabel: "high", - labels: ["bug"], - assigneeId: null, - assigneeName: "CTO", - ownerId: "owner-1", - creatorId: "creator-1", - creatorName: "Taylor", - blockerIssueIds: [], - hasOpenBlockers: false, - createdAt: "2026-03-05T00:00:00.000Z", - updatedAt: "2026-03-05T00:00:00.000Z", - raw: {}, -}; - -const policy: LinearWorkflowConfig = { - version: 1, - source: "repo", - intake: { - projectSlugs: ["acme-platform"], - activeStateTypes: ["backlog", "unstarted", "started"], - terminalStateTypes: ["completed", "canceled"], - }, - settings: { ctoLinearAssigneeName: "CTO", ctoLinearAssigneeAliases: ["cto"] }, - workflows: [], - files: [], - migration: { hasLegacyConfig: false, needsSave: false }, - legacyConfig: null, -}; - -describe("linearSyncService", () => { - afterEach(() => { - vi.useRealTimers(); - }); - - it("runs an intake cycle and updates the dashboard heartbeat", async () => { - const root = fs.mkdtempSync(path.join(os.tmpdir(), "ade-linear-sync-")); - const db = await openKvDb(path.join(root, "ade.db"), { debug() {}, info() {}, warn() {}, error() {} } as any); - const advanceRun = vi.fn(async () => null); - - const service = createLinearSyncService({ - db, - projectId: "project-1", - flowPolicyService: { getPolicy: () => policy } as any, - routingService: { - routeIssue: vi.fn(async () => ({ - workflowId: null, - workflowName: null, - workflow: null, - target: null, - reason: "No match", - candidates: [], - nextStepsPreview: [], - })), - } as any, - intakeService: { - fetchCandidates: vi.fn(async () => [ - { - ...issueFixture, - raw: { - _snapshotHash: "hash-1", - _previousSnapshotHash: "hash-0", - }, - }, - ]), - persistSnapshot: vi.fn(() => {}), - } as any, - issueTracker: { - fetchIssueById: vi.fn(async () => issueFixture), - } as any, - dispatcherService: { - hasActiveRuns: vi.fn(() => false), - findActiveRunForIssue: vi.fn(() => null), - createRun: vi.fn(), - advanceRun, - listActiveRuns: vi.fn(() => []), - listQueue: vi.fn(() => []), - resolveRunAction: vi.fn(), - } as any, - autoStart: false, - }); - - await service.runSyncNow(); - expect(service.getDashboard().lastSuccessAt).toBeTruthy(); - expect(advanceRun).not.toHaveBeenCalled(); - db.close(); - }); - - it("buffers webhook issue updates while a sync cycle is in flight and replays them once", async () => { - const root = fs.mkdtempSync(path.join(os.tmpdir(), "ade-linear-sync-buffer-")); - const db = await openKvDb(path.join(root, "ade.db"), { debug() {}, info() {}, warn() {}, error() {} } as any); - let resolveAdvance: (() => void) | null = null; - let allowActiveRun = true; - const advanceRun = vi.fn(async () => { - if (!allowActiveRun) return null; - return await new Promise((resolve) => { - resolveAdvance = () => { - allowActiveRun = false; - resolve(null); - }; - }); - }); - const fetchIssueById = vi.fn(async (issueId: string) => - issueId === "issue-2" - ? { ...issueFixture, id: "issue-2", identifier: "ABC-43" } - : issueFixture - ); - - const service = createLinearSyncService({ - db, - projectId: "project-1", - flowPolicyService: { getPolicy: () => policy } as any, - routingService: { - routeIssue: vi.fn(async () => ({ - workflowId: null, - workflowName: null, - workflow: null, - target: null, - reason: "No match", - candidates: [], - nextStepsPreview: [], - })), - } as any, - intakeService: { - fetchCandidates: vi.fn(async () => []), - persistSnapshot: vi.fn(() => {}), - issueHash: vi.fn(() => "hash-current"), - } as any, - issueTracker: { - fetchIssueById, - } as any, - dispatcherService: { - hasActiveRuns: vi.fn(() => allowActiveRun), - findActiveRunForIssue: vi.fn(() => null), - createRun: vi.fn(), - advanceRun, - listActiveRuns: vi.fn(() => ( - allowActiveRun - ? [{ - id: "run-1", - issueId: "issue-1", - workflowId: "workflow-1", - status: "in_progress", - retryAfter: null, - reviewState: "approved", - }] - : [] - )), - listQueue: vi.fn(() => []), - resolveRunAction: vi.fn(), - } as any, - autoStart: false, - }); - - const cycle = service.runSyncNow(); - await service.processIssueUpdate("issue-1"); - await service.processIssueUpdate("issue-1"); - await service.processIssueUpdate("issue-2"); - if (!resolveAdvance) { - throw new Error("Expected the reconciliation cycle to be blocked."); - } - (resolveAdvance as () => void)(); - - await cycle; - - expect(fetchIssueById).toHaveBeenCalledTimes(2); - expect(fetchIssueById).toHaveBeenNthCalledWith(1, "issue-1"); - expect(fetchIssueById).toHaveBeenNthCalledWith(2, "issue-2"); - db.close(); - }); - - it("retains buffered issue updates that fail during replay for a later retry", async () => { - const root = fs.mkdtempSync(path.join(os.tmpdir(), "ade-linear-sync-buffer-retry-")); - const db = await openKvDb(path.join(root, "ade.db"), { debug() {}, info() {}, warn() {}, error() {} } as any); - let resolveAdvance: (() => void) | null = null; - let allowActiveRun = true; - let shouldFailReplay = true; - const advanceRun = vi.fn(async () => { - if (!allowActiveRun) return null; - return await new Promise((resolve) => { - resolveAdvance = () => { - allowActiveRun = false; - resolve(null); - }; - }); - }); - const fetchIssueById = vi.fn(async (issueId: string) => { - if (issueId === "issue-2" && shouldFailReplay) { - shouldFailReplay = false; - throw new Error("Temporary Linear failure"); - } - return { ...issueFixture, id: issueId, identifier: issueId === "issue-2" ? "ABC-43" : issueFixture.identifier }; - }); - - const service = createLinearSyncService({ - db, - projectId: "project-1", - flowPolicyService: { getPolicy: () => policy } as any, - routingService: { - routeIssue: vi.fn(async () => ({ - workflowId: null, - workflowName: null, - workflow: null, - target: null, - reason: "No match", - candidates: [], - nextStepsPreview: [], - })), - } as any, - intakeService: { - fetchCandidates: vi.fn(async () => []), - persistSnapshot: vi.fn(() => {}), - issueHash: vi.fn(() => "hash-current"), - } as any, - issueTracker: { fetchIssueById } as any, - dispatcherService: { - hasActiveRuns: vi.fn(() => allowActiveRun), - findActiveRunForIssue: vi.fn(() => null), - createRun: vi.fn(), - advanceRun, - listActiveRuns: vi.fn(() => ( - allowActiveRun - ? [{ - id: "run-1", - issueId: "issue-1", - workflowId: "workflow-1", - status: "in_progress", - retryAfter: null, - reviewState: "approved", - }] - : [] - )), - listQueue: vi.fn(() => []), - resolveRunAction: vi.fn(), - } as any, - autoStart: false, - }); - - const cycle = service.runSyncNow(); - await service.processIssueUpdate("issue-2"); - if (!resolveAdvance) { - throw new Error("Expected the reconciliation cycle to be blocked."); - } - (resolveAdvance as () => void)(); - await cycle; - - expect(fetchIssueById).toHaveBeenCalledTimes(1); - await service.runSyncNow(); - expect(fetchIssueById).toHaveBeenCalledTimes(2); - expect(fetchIssueById).toHaveBeenNthCalledWith(2, "issue-2"); - db.close(); - }); - - it("serializes concurrent webhook issue updates and coalesces duplicates", async () => { - const root = fs.mkdtempSync(path.join(os.tmpdir(), "ade-linear-sync-webhook-buffer-")); - const db = await openKvDb(path.join(root, "ade.db"), { debug() {}, info() {}, warn() {}, error() {} } as any); - let releaseFirstFetch!: () => void; - const firstFetchGate = new Promise((resolve) => { - releaseFirstFetch = resolve; - }); - const fetchIssueById = vi.fn(async (issueId: string) => { - if (issueId === "issue-1") { - await firstFetchGate; - return { - ...issueFixture, - id: "issue-1", - raw: { _snapshotHash: "hash-issue-1", _previousSnapshotHash: "hash-0" }, - }; - } - return { - ...issueFixture, - id: "issue-2", - identifier: "ABC-43", - raw: { _snapshotHash: "hash-issue-2", _previousSnapshotHash: "hash-0" }, - }; - }); - - const service = createLinearSyncService({ - db, - projectId: "project-1", - flowPolicyService: { getPolicy: () => policy } as any, - routingService: { - routeIssue: vi.fn(async () => ({ - workflowId: null, - workflowName: null, - workflow: null, - target: null, - reason: "No match", - candidates: [], - nextStepsPreview: [], - })), - } as any, - intakeService: { - fetchCandidates: vi.fn(async () => []), - persistSnapshot: vi.fn(() => {}), - issueHash: vi.fn((issue: NormalizedLinearIssue) => String(issue.raw?._snapshotHash ?? "hash-current")), - } as any, - issueTracker: { fetchIssueById } as any, - dispatcherService: { - hasActiveRuns: vi.fn(() => false), - findActiveRunForIssue: vi.fn(() => null), - createRun: vi.fn(), - advanceRun: vi.fn(async () => null), - listActiveRuns: vi.fn(() => []), - listQueue: vi.fn(() => []), - resolveRunAction: vi.fn(), - } as any, - autoStart: false, - }); - - const first = service.processIssueUpdate("issue-1"); - await Promise.resolve(); - await service.processIssueUpdate("issue-2"); - await service.processIssueUpdate("issue-2"); - releaseFirstFetch(); - await first; - - expect(fetchIssueById).toHaveBeenCalledTimes(2); - expect(fetchIssueById).toHaveBeenNthCalledWith(1, "issue-1"); - expect(fetchIssueById).toHaveBeenNthCalledWith(2, "issue-2"); - db.close(); - }); - - it("starts immediately and continues on the reconciliation interval", async () => { - vi.useFakeTimers(); - const root = fs.mkdtempSync(path.join(os.tmpdir(), "ade-linear-sync-")); - const db = await openKvDb(path.join(root, "ade.db"), { debug() {}, info() {}, warn() {}, error() {} } as any); - const fetchCandidates = vi.fn(async () => []); - - const service = createLinearSyncService({ - db, - projectId: "project-1", - flowPolicyService: { - getPolicy: () => ({ - ...policy, - workflows: [ - { - id: "workflow-1", - enabled: true, - }, - ], - }), - } as any, - routingService: { - routeIssue: vi.fn(async () => ({ - workflowId: null, - workflowName: null, - workflow: null, - target: null, - reason: "No match", - candidates: [], - nextStepsPreview: [], - })), - } as any, - intakeService: { - fetchCandidates, - persistSnapshot: vi.fn(() => {}), - } as any, - issueTracker: { - fetchIssueById: vi.fn(async () => issueFixture), - } as any, - dispatcherService: { - hasActiveRuns: vi.fn(() => false), - findActiveRunForIssue: vi.fn(() => null), - createRun: vi.fn(), - advanceRun: vi.fn(async () => null), - listActiveRuns: vi.fn(() => []), - listQueue: vi.fn(() => []), - resolveRunAction: vi.fn(), - } as any, - hasCredentials: () => true, - reconciliationIntervalSec: 30, - autoStart: false, - }); - - await service.start(); - expect(fetchCandidates).toHaveBeenCalledTimes(1); - - await vi.advanceTimersByTimeAsync(30_000); - expect(fetchCandidates).toHaveBeenCalledTimes(2); - - service.dispose(); - db.close(); - }); - - it("immediately advances queue actions after a supervisor decision", async () => { - const root = fs.mkdtempSync(path.join(os.tmpdir(), "ade-linear-sync-review-action-")); - const db = await openKvDb(path.join(root, "ade.db"), { debug() {}, info() {}, warn() {}, error() {} } as any); - const advanceRun = vi.fn(async () => ({ id: "run-1", status: "queued" })); - - const service = createLinearSyncService({ - db, - projectId: "project-1", - flowPolicyService: { getPolicy: () => policy } as any, - routingService: { - routeIssue: vi.fn(async () => ({ - workflowId: null, - workflowName: null, - workflow: null, - target: null, - reason: "No match", - candidates: [], - nextStepsPreview: [], - })), - } as any, - intakeService: { - fetchCandidates: vi.fn(async () => []), - persistSnapshot: vi.fn(() => {}), - } as any, - issueTracker: { - fetchIssueById: vi.fn(async () => issueFixture), - } as any, - dispatcherService: { - findActiveRunForIssue: vi.fn(() => null), - createRun: vi.fn(), - advanceRun, - listActiveRuns: vi.fn(() => []), - listQueue: vi.fn(() => [{ id: "run-1" }]), - resolveRunAction: vi.fn(async () => ({ id: "run-1", status: "queued" })), - getRunDetail: vi.fn(async () => null), - } as any, - autoStart: false, - }); - - await service.resolveQueueItem({ queueItemId: "run-1", action: "approve" }); - expect(advanceRun).toHaveBeenCalledWith("run-1", policy); - db.close(); - }); - - it("records watch-only matches in the dashboard without creating runs", async () => { - const root = fs.mkdtempSync(path.join(os.tmpdir(), "ade-linear-sync-watch-only-")); - const db = await openKvDb(path.join(root, "ade.db"), { debug() {}, info() {}, warn() {}, error() {} } as any); - const createRun = vi.fn(); - - const watchOnlyIssue: NormalizedLinearIssue = { - ...issueFixture, - raw: { _snapshotHash: "hash-new", _previousSnapshotHash: "hash-old" }, - }; - - const service = createLinearSyncService({ - db, - projectId: "project-1", - flowPolicyService: { - getPolicy: () => ({ - ...policy, - workflows: [ - { - id: "watch-only", - name: "Watch only", - enabled: true, - priority: 100, - triggers: { projectSlugs: ["acme-platform"] }, - routing: { watchOnly: true }, - target: { type: "review_gate" }, - steps: [{ id: "review", type: "request_human_review" }], - }, - ], - }), - } as any, - routingService: { - routeIssue: vi.fn(async () => ({ - workflowId: "watch-only", - workflowName: "Watch only", - workflow: { - id: "watch-only", - name: "Watch only", - enabled: true, - priority: 100, - routing: { watchOnly: true }, - concurrency: {}, - }, - target: { type: "review_gate" }, - reason: "Matched watch-only workflow", - candidates: [{ workflowId: "watch-only", workflowName: "Watch only", priority: 100, matched: true, reasons: ["Project matched"], matchedSignals: ["Project matched"] }], - nextStepsPreview: [], - })), - } as any, - intakeService: { - fetchCandidates: vi.fn(async () => [watchOnlyIssue]), - persistSnapshot: vi.fn(() => {}), - } as any, - issueTracker: { - fetchIssueById: vi.fn(async () => issueFixture), - } as any, - dispatcherService: { - hasActiveRuns: vi.fn(() => false), - findActiveRunForIssue: vi.fn(() => null), - createRun, - advanceRun: vi.fn(async () => null), - listActiveRuns: vi.fn(() => []), - listQueue: vi.fn(() => []), - resolveRunAction: vi.fn(), - } as any, - autoStart: false, - }); - - await service.runSyncNow(); - expect(createRun).not.toHaveBeenCalled(); - const dashboard = service.getDashboard(); - expect(dashboard.watchOnlyHits).toBe(1); - expect(dashboard.recentEvents[0]?.eventType).toBe("watch_only_match"); - db.close(); - }); - - it("hydrates webhook issue updates with snapshot hashes before routing", async () => { - const root = fs.mkdtempSync(path.join(os.tmpdir(), "ade-linear-sync-webhook-")); - const db = await openKvDb(path.join(root, "ade.db"), { debug() {}, info() {}, warn() {}, error() {} } as any); - const createRun = vi.fn(() => ({ id: "run-1" })); - - db.run( - ` - insert into linear_issue_snapshots( - id, project_id, issue_id, identifier, state_type, assignee_id, updated_at_linear, payload_json, hash, created_at, updated_at - ) - values(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - `, - [ - "project-1:issue-1", - "project-1", - "issue-1", - issueFixture.identifier, - "backlog", - null, - "2026-03-04T00:00:00.000Z", - JSON.stringify({ - ...issueFixture, - stateId: "state-backlog", - stateName: "Backlog", - stateType: "backlog", - }), - "hash-previous", - "2026-03-04T00:00:00.000Z", - "2026-03-04T00:00:00.000Z", - ], - ); - - const service = createLinearSyncService({ - db, - projectId: "project-1", - flowPolicyService: { - getPolicy: () => ({ - ...policy, - workflows: [ - { - id: "workflow-1", - name: "Dispatch issue", - enabled: true, - priority: 100, - triggers: { projectSlugs: ["acme-platform"] }, - routing: {}, - target: { type: "review_gate" }, - steps: [{ id: "review", type: "request_human_review" }], - }, - ], - }), - } as any, - routingService: { - routeIssue: vi.fn(async () => ({ - workflowId: "workflow-1", - workflowName: "Dispatch issue", - workflow: { - id: "workflow-1", - name: "Dispatch issue", - enabled: true, - routing: {}, - concurrency: {}, - }, - target: { type: "review_gate" }, - reason: "Matched project", - candidates: [], - nextStepsPreview: [], - })), - } as any, - intakeService: { - fetchCandidates: vi.fn(async () => []), - persistSnapshot: vi.fn(() => {}), - issueHash: vi.fn(() => "hash-current"), - } as any, - issueTracker: { - fetchIssueById: vi.fn(async () => issueFixture), - } as any, - dispatcherService: { - hasActiveRuns: vi.fn(() => false), - findActiveRunForIssue: vi.fn(() => null), - createRun, - advanceRun: vi.fn(async () => null), - listActiveRuns: vi.fn(() => []), - listQueue: vi.fn(() => []), - resolveRunAction: vi.fn(), - } as any, - autoStart: false, - }); - - await service.processIssueUpdate("issue-1"); - - expect(createRun).toHaveBeenCalledTimes(1); - expect(createRun).toHaveBeenCalledWith( - expect.objectContaining({ - raw: expect.objectContaining({ - _snapshotHash: "hash-current", - _previousSnapshotHash: "hash-previous", - }), - previousStateId: "state-backlog", - previousStateType: "backlog", - }), - expect.anything(), - ); - db.close(); - }); - - it("cancels every active run for an issue when the issue closes", async () => { - const root = fs.mkdtempSync(path.join(os.tmpdir(), "ade-linear-sync-close-all-")); - const db = await openKvDb(path.join(root, "ade.db"), { debug() {}, info() {}, warn() {}, error() {} } as any); - const cancelRun = vi.fn(async () => {}); - - const service = createLinearSyncService({ - db, - projectId: "project-1", - flowPolicyService: { - getPolicy: () => ({ - ...policy, - workflows: [ - { - id: "workflow-1", - name: "Dispatch issue", - enabled: true, - priority: 100, - triggers: { projectSlugs: ["acme-platform"] }, - routing: {}, - target: { type: "review_gate" }, - steps: [{ id: "review", type: "request_human_review" }], - }, - ], - }), - } as any, - routingService: { - routeIssue: vi.fn(async () => ({ - workflowId: null, - workflowName: null, - workflow: null, - target: null, - reason: "No match", - candidates: [], - nextStepsPreview: [], - })), - } as any, - intakeService: { - fetchCandidates: vi.fn(async () => []), - persistSnapshot: vi.fn(() => {}), - issueHash: vi.fn(() => "hash-current"), - } as any, - issueTracker: { - fetchIssueById: vi.fn(async () => ({ - ...issueFixture, - stateId: "state-done", - stateName: "Done", - stateType: "completed", - })), - } as any, - dispatcherService: { - hasActiveRuns: vi.fn(() => true), - findActiveRunForIssue: vi.fn(() => ({ id: "run-2", issueId: "issue-1" })), - createRun: vi.fn(), - cancelRun, - advanceRun: vi.fn(async () => null), - listActiveRuns: vi.fn(() => [ - { id: "run-1", issueId: "issue-1" }, - { id: "run-2", issueId: "issue-1" }, - { id: "run-3", issueId: "issue-2" }, - ]), - listQueue: vi.fn(() => []), - resolveRunAction: vi.fn(), - } as any, - autoStart: false, - }); - - await service.processIssueUpdate("issue-1"); - - expect(cancelRun).toHaveBeenCalledTimes(2); - expect(cancelRun).toHaveBeenNthCalledWith( - 1, - "run-1", - "Issue externally completed", - expect.objectContaining({ workflows: expect.any(Array) }), - ); - expect(cancelRun).toHaveBeenNthCalledWith( - 2, - "run-2", - "Issue externally completed", - expect.objectContaining({ workflows: expect.any(Array) }), - ); - db.close(); - }); - - it("forwards employee overrides through queue resolution", async () => { - const root = fs.mkdtempSync(path.join(os.tmpdir(), "ade-linear-sync-override-")); - const db = await openKvDb(path.join(root, "ade.db"), { debug() {}, info() {}, warn() {}, error() {} } as any); - const resolveRunAction = vi.fn(async () => ({ id: "run-1", status: "queued" })); - - const service = createLinearSyncService({ - db, - projectId: "project-1", - flowPolicyService: { getPolicy: () => policy } as any, - routingService: { - routeIssue: vi.fn(async () => ({ - workflowId: null, - workflowName: null, - workflow: null, - target: null, - reason: "No match", - candidates: [], - nextStepsPreview: [], - })), - } as any, - intakeService: { - fetchCandidates: vi.fn(async () => []), - persistSnapshot: vi.fn(() => {}), - } as any, - issueTracker: { - fetchIssueById: vi.fn(async () => issueFixture), - } as any, - dispatcherService: { - hasActiveRuns: vi.fn(() => false), - findActiveRunForIssue: vi.fn(() => null), - createRun: vi.fn(), - advanceRun: vi.fn(async () => null), - listActiveRuns: vi.fn(() => []), - listQueue: vi.fn(() => [{ id: "run-1", employeeOverride: "agent:worker-1" }]), - resolveRunAction, - getRunDetail: vi.fn(async () => null), - } as any, - autoStart: false, - }); - - await service.resolveQueueItem({ queueItemId: "run-1", action: "retry", employeeOverride: "agent:worker-1" }); - expect(resolveRunAction).toHaveBeenCalledWith("run-1", "retry", undefined, policy, "agent:worker-1", undefined); - db.close(); - }); - - it("forwards laneId through queue resolution resumes", async () => { - const root = fs.mkdtempSync(path.join(os.tmpdir(), "ade-linear-sync-lane-")); - const db = await openKvDb(path.join(root, "ade.db"), { debug() {}, info() {}, warn() {}, error() {} } as any); - const resolveRunAction = vi.fn(async () => ({ id: "run-1", status: "queued" })); - - const service = createLinearSyncService({ - db, - projectId: "project-1", - flowPolicyService: { getPolicy: () => policy } as any, - routingService: { - routeIssue: vi.fn(async () => ({ - workflowId: null, - workflowName: null, - workflow: null, - target: null, - reason: "No match", - candidates: [], - nextStepsPreview: [], - })), - } as any, - intakeService: { - fetchCandidates: vi.fn(async () => []), - persistSnapshot: vi.fn(() => {}), - } as any, - issueTracker: { - fetchIssueById: vi.fn(async () => issueFixture), - } as any, - dispatcherService: { - hasActiveRuns: vi.fn(() => false), - findActiveRunForIssue: vi.fn(() => null), - createRun: vi.fn(), - advanceRun: vi.fn(async () => null), - listActiveRuns: vi.fn(() => []), - listQueue: vi.fn(() => [{ id: "run-1", laneId: "lane-2" }]), - resolveRunAction, - getRunDetail: vi.fn(async () => null), - } as any, - autoStart: false, - }); - - await service.resolveQueueItem({ queueItemId: "run-1", action: "resume", laneId: "lane-2" }); - expect(resolveRunAction).toHaveBeenCalledWith("run-1", "resume", undefined, policy, undefined, "lane-2"); - db.close(); - }); -}); diff --git a/apps/desktop/src/main/services/cto/linearTemplateService.test.ts b/apps/desktop/src/main/services/cto/linearTemplateService.test.ts deleted file mode 100644 index 884281fd1..000000000 --- a/apps/desktop/src/main/services/cto/linearTemplateService.test.ts +++ /dev/null @@ -1,79 +0,0 @@ -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; -import { describe, expect, it } from "vitest"; -import type { NormalizedLinearIssue } from "../../../shared/types"; -import { createLinearTemplateService } from "./linearTemplateService"; - -const issueFixture: NormalizedLinearIssue = { - id: "issue-1", - identifier: "ABC-12", - title: "Fix auth token refresh", - description: "Refresh token flow fails when access token is expired.", - url: "https://linear.app/acme/issue/ABC-12", - projectId: "proj-1", - projectSlug: "acme-platform", - teamId: "team-1", - teamKey: "ACME", - stateId: "state-1", - stateName: "Todo", - stateType: "unstarted", - priority: 2, - priorityLabel: "high", - labels: ["bug", "auth"], - assigneeId: null, - assigneeName: null, - ownerId: "user-1", - blockerIssueIds: [], - hasOpenBlockers: false, - createdAt: "2026-03-05T00:00:00.000Z", - updatedAt: "2026-03-05T00:00:00.000Z", - raw: {}, -}; - -describe("linearTemplateService", () => { - it("renders placeholders from template yaml", () => { - const root = fs.mkdtempSync(path.join(os.tmpdir(), "ade-linear-template-")); - const templatesDir = path.join(root, ".ade", "templates"); - fs.mkdirSync(templatesDir, { recursive: true }); - fs.writeFileSync( - path.join(templatesDir, "bug.yaml"), - [ - "id: bug-fix", - "name: Bug Fix", - "promptTemplate: |-", - " Issue {{ issue.identifier }}", - " Worker {{ worker.name }}", - " Reason {{ route.reason }}", - ].join("\n"), - "utf8" - ); - - const service = createLinearTemplateService({ adeDir: path.join(root, ".ade") }); - const rendered = service.renderTemplate({ - templateId: "bug-fix", - issue: issueFixture, - route: { reason: "Matched bug rule" }, - worker: { name: "Backend Dev" }, - }); - - expect(rendered.templateId).toBe("bug-fix"); - expect(rendered.prompt).toContain("Issue ABC-12"); - expect(rendered.prompt).toContain("Worker Backend Dev"); - expect(rendered.prompt).toContain("Reason Matched bug rule"); - }); - - it("falls back to default template when no template files exist", () => { - const root = fs.mkdtempSync(path.join(os.tmpdir(), "ade-linear-template-empty-")); - const service = createLinearTemplateService({ adeDir: path.join(root, ".ade") }); - - const rendered = service.renderTemplate({ - templateId: "missing-template", - issue: issueFixture, - }); - - expect(rendered.templateId).toBe("default"); - expect(rendered.prompt).toContain("Handle the following Linear issue end to end."); - expect(rendered.prompt).toContain("ABC-12"); - }); -}); diff --git a/apps/desktop/src/main/services/cto/linearWorkflowFileService.test.ts b/apps/desktop/src/main/services/cto/linearWorkflowFileService.test.ts deleted file mode 100644 index cb7658659..000000000 --- a/apps/desktop/src/main/services/cto/linearWorkflowFileService.test.ts +++ /dev/null @@ -1,122 +0,0 @@ -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; -import { describe, expect, it } from "vitest"; -import type { LinearSyncConfig } from "../../../shared/types"; -import { createLinearWorkflowFileService } from "./linearWorkflowFileService"; - -function createFixtureRoot(): string { - return fs.mkdtempSync(path.join(os.tmpdir(), "ade-linear-workflows-")); -} - -describe("linearWorkflowFileService", () => { - it("generates editable starter workflows when the repo has no workflow files", () => { - const root = createFixtureRoot(); - const service = createLinearWorkflowFileService({ projectRoot: root }); - - const loaded = service.load(null); - - expect(loaded.source).toBe("generated"); - expect(loaded.migration?.needsSave).toBe(true); - expect(loaded.intake.activeStateTypes).toEqual(["backlog", "unstarted", "started"]); - expect(loaded.intake.terminalStateTypes).toEqual(["completed", "canceled"]); - expect(loaded.settings.ctoLinearAssigneeName).toBe("CTO"); - expect(loaded.workflows.map((workflow) => workflow.id)).toEqual([ - "cto-mission-autopilot", - "cto-direct-employee-session", - "cto-worker-run-autopilot", - "cto-pr-fast-lane", - "cto-human-review-gate", - ]); - expect(loaded.workflows.find((workflow) => workflow.id === "cto-direct-employee-session")?.steps.map((step) => step.type)).toEqual([ - "set_linear_state", - "launch_target", - "wait_for_target_status", - "emit_app_notification", - "complete_issue", - ]); - expect(loaded.workflows.find((workflow) => workflow.id === "cto-worker-run-autopilot")?.steps.map((step) => step.type)).toEqual([ - "set_linear_state", - "launch_target", - "wait_for_target_status", - "emit_app_notification", - "complete_issue", - ]); - expect(loaded.workflows.find((workflow) => workflow.id === "cto-pr-fast-lane")?.steps.map((step) => step.type)).toEqual([ - "set_linear_state", - "launch_target", - "wait_for_pr", - "emit_app_notification", - "complete_issue", - ]); - expect(loaded.workflows.find((workflow) => workflow.id === "cto-direct-employee-session")?.target.laneSelection).toBe("fresh_issue_lane"); - expect(loaded.workflows.find((workflow) => workflow.id === "cto-direct-employee-session")?.target.sessionReuse).toBe("fresh_session"); - expect(loaded.workflows.find((workflow) => workflow.id === "cto-worker-run-autopilot")?.target.laneSelection).toBe("fresh_issue_lane"); - - const saved = service.save(loaded); - - expect(saved.source).toBe("repo"); - expect(saved.files.some((file) => file.kind === "settings")).toBe(true); - expect(saved.files.filter((file) => file.kind === "workflow")).toHaveLength(5); - expect(fs.existsSync(path.join(root, ".ade", "workflows", "linear", "_settings.yaml"))).toBe(true); - }); - - it("migrates legacy LinearSyncConfig into repo workflows and writes a compatibility snapshot", () => { - const root = createFixtureRoot(); - const service = createLinearWorkflowFileService({ projectRoot: root }); - const legacy: LinearSyncConfig = { - enabled: true, - projects: [{ slug: "acme-platform", defaultWorker: "backend-dev" }], - routing: { - byLabel: { - bug: "backend-hotfix", - }, - }, - autoDispatch: { - default: "auto", - rules: [ - { - id: "legacy-bug-rule", - action: "auto", - template: "fast-track", - match: { - labels: ["bug"], - projectSlugs: ["acme-platform"], - priority: ["high"], - }, - }, - ], - }, - concurrency: { - global: 7, - }, - artifacts: { - mode: "attachments", - }, - }; - - const loaded = service.load(legacy); - const migrated = loaded.workflows.find((workflow) => workflow.id === "legacy-bug-rule"); - - expect(loaded.source).toBe("generated"); - expect(loaded.migration?.hasLegacyConfig).toBe(true); - expect(loaded.migration?.needsSave).toBe(true); - expect(loaded.intake.projectSlugs).toEqual(["acme-platform"]); - expect(loaded.intake.activeStateTypes).toEqual(["backlog", "unstarted", "started"]); - expect(loaded.intake.terminalStateTypes).toEqual(["completed", "canceled"]); - expect(migrated?.target.type).toBe("mission"); - expect(migrated?.target.missionTemplate).toBe("fast-track"); - expect(migrated?.target.workerSelector).toEqual({ mode: "slug", value: "backend-hotfix" }); - expect(migrated?.triggers.labels).toEqual(["bug"]); - expect(migrated?.triggers.projectSlugs).toEqual(["acme-platform"]); - expect(migrated?.closeout?.artifactMode).toBe("attachments"); - expect(migrated?.concurrency?.maxActiveRuns).toBe(7); - - const saved = service.save(loaded); - - expect(saved.source).toBe("repo"); - expect(saved.migration?.needsSave).toBe(false); - expect(saved.migration?.compatibilitySnapshotPath).toBe(service.legacySnapshotPath); - expect(fs.existsSync(service.legacySnapshotPath)).toBe(true); - }); -}); diff --git a/apps/desktop/src/main/services/cto/openclawBridgeService.test.ts b/apps/desktop/src/main/services/cto/openclawBridgeService.test.ts deleted file mode 100644 index 84d1d713c..000000000 --- a/apps/desktop/src/main/services/cto/openclawBridgeService.test.ts +++ /dev/null @@ -1,476 +0,0 @@ -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; -import { afterEach, describe, expect, it, vi } from "vitest"; -import YAML from "yaml"; -import { createOpenclawBridgeService } from "./openclawBridgeService"; - -function writeOpenclawConfig(adeDir: string, patch: Record): void { - fs.mkdirSync(adeDir, { recursive: true }); - fs.writeFileSync( - path.join(adeDir, "local.secret.yaml"), - YAML.stringify({ - openclaw: { - bridgePort: 0, - hooksToken: "test-hook-token", - ...patch, - }, - }), - "utf8", - ); -} - -describe("openclawBridgeService", () => { - const services: Array> = []; - - afterEach(async () => { - while (services.length) { - const service = services.pop(); - await service?.stop(); - } - }); - - it("handles synchronous query replies end to end", async () => { - const adeDir = fs.mkdtempSync(path.join(os.tmpdir(), "ade-openclaw-query-")); - writeOpenclawConfig(adeDir, { enabled: false }); - - let service!: ReturnType; - const sentMessages: Array<{ sessionId: string; text: string; displayText?: string }> = []; - const agentChatService = { - listSessions: vi.fn(async () => []), - ensureIdentitySession: vi.fn(async () => ({ id: "session-cto", laneId: "lane-1" })), - sendMessage: vi.fn(async ({ sessionId, text, displayText }: { sessionId: string; text: string; displayText?: string }) => { - sentMessages.push({ sessionId, text, displayText }); - const turnId = "turn-1"; - queueMicrotask(() => { - service.onAgentChatEvent({ - sessionId, - timestamp: new Date().toISOString(), - event: { type: "user_message", text: displayText ?? text, turnId }, - }); - service.onAgentChatEvent({ - sessionId, - timestamp: new Date().toISOString(), - event: { type: "text", text: "CTO reply from ADE", turnId }, - }); - service.onAgentChatEvent({ - sessionId, - timestamp: new Date().toISOString(), - event: { type: "done", turnId, status: "completed" }, - }); - }); - }), - } as any; - - service = createOpenclawBridgeService({ - projectRoot: "/tmp/project", - adeDir, - laneService: { - ensurePrimaryLane: vi.fn(async () => {}), - list: vi.fn(async () => [ - { id: "lane-2", laneType: "feature" }, - { id: "lane-1", laneType: "primary" }, - ]), - } as any, - agentChatService, - ctoStateService: { - getIdentity: vi.fn(() => ({ - openclawContextPolicy: { shareMode: "filtered", blockedCategories: ["secret"] }, - })), - } as any, - }); - services.push(service); - await service.start(); - - const state = service.getState(); - const res = await fetch(state.endpoints.queryUrl!, { - method: "POST", - headers: { - "content-type": "application/json", - authorization: "Bearer test-hook-token", - }, - body: JSON.stringify({ - requestId: "req-query-1", - agentId: "discord-cto", - sessionKey: "discord:thread:123", - message: "What changed?", - context: { channel: "discord", secret: "redact-me" }, - }), - }); - - expect(res.status).toBe(200); - const body = await res.json(); - expect(body.reply).toBe("CTO reply from ADE"); - expect(agentChatService.ensureIdentitySession).toHaveBeenCalledWith( - expect.objectContaining({ identityKey: "cto", laneId: "lane-1" }), - ); - expect(sentMessages[0]?.text).toContain("Treat this routing context as turn-scoped bridge metadata only."); - expect(sentMessages[0]?.text).toContain("What changed?"); - }); - - it("routes worker targets by slug and falls back unknown targets to CTO", async () => { - const adeDir = fs.mkdtempSync(path.join(os.tmpdir(), "ade-openclaw-target-")); - writeOpenclawConfig(adeDir, { enabled: false, allowEmployeeTargets: true }); - - let service!: ReturnType; - const ensureIdentitySession = vi.fn(async ({ identityKey }: { identityKey: string }) => ({ - id: identityKey === "cto" ? "session-cto" : "session-worker", - laneId: "lane-1", - })); - const sendMessage = vi.fn(async ({ sessionId, text, displayText }: { sessionId: string; text: string; displayText?: string }) => { - const turnId = sessionId === "session-worker" ? "turn-worker" : "turn-cto"; - queueMicrotask(() => { - service.onAgentChatEvent({ - sessionId, - timestamp: new Date().toISOString(), - event: { type: "user_message", text: displayText ?? text, turnId }, - }); - service.onAgentChatEvent({ - sessionId, - timestamp: new Date().toISOString(), - event: { type: "text", text: sessionId === "session-worker" ? "worker reply" : "cto fallback reply", turnId }, - }); - service.onAgentChatEvent({ - sessionId, - timestamp: new Date().toISOString(), - event: { type: "done", turnId, status: "completed" }, - }); - }); - }); - - service = createOpenclawBridgeService({ - projectRoot: "/tmp/project", - adeDir, - laneService: { - ensurePrimaryLane: vi.fn(async () => {}), - list: vi.fn(async () => [{ id: "lane-1", laneType: "primary" }]), - } as any, - agentChatService: { - listSessions: vi.fn(async () => []), - ensureIdentitySession, - sendMessage, - } as any, - workerAgentService: { - listAgents: vi.fn(() => [ - { id: "worker-1", slug: "frontend", status: "active", deletedAt: null }, - ]), - } as any, - ctoStateService: { - getIdentity: vi.fn(() => ({ - openclawContextPolicy: { shareMode: "filtered", blockedCategories: [] }, - })), - } as any, - }); - services.push(service); - await service.start(); - - const state = service.getState(); - const good = await fetch(state.endpoints.queryUrl!, { - method: "POST", - headers: { - "content-type": "application/json", - authorization: "Bearer test-hook-token", - }, - body: JSON.stringify({ - requestId: "req-good-target", - message: "Ping frontend worker", - targetHint: "agent:frontend", - }), - }); - expect(good.status).toBe(200); - await expect(good.json()).resolves.toEqual(expect.objectContaining({ - accepted: true, - async: true, - status: "working", - routeTarget: "agent:frontend", - })); - expect(ensureIdentitySession).toHaveBeenCalledWith(expect.objectContaining({ identityKey: "agent:worker-1" })); - - const fallback = await fetch(state.endpoints.queryUrl!, { - method: "POST", - headers: { - "content-type": "application/json", - authorization: "Bearer test-hook-token", - }, - body: JSON.stringify({ - requestId: "req-bad-target", - message: "Ping unknown worker", - targetHint: "agent:ghost", - }), - }); - expect(fallback.status).toBe(200); - const latestInbound = service.listMessages(4).find((entry) => entry.requestId === "req-bad-target" && entry.direction === "inbound"); - expect(latestInbound?.resolvedTarget).toBe("cto"); - expect(latestInbound?.metadata).toEqual(expect.objectContaining({ - fallbackReason: expect.stringContaining("ghost"), - })); - }); - - it("deduplicates async hook requests by idempotency key", async () => { - const adeDir = fs.mkdtempSync(path.join(os.tmpdir(), "ade-openclaw-hook-")); - writeOpenclawConfig(adeDir, { enabled: false }); - - let service!: ReturnType; - const sendMessage = vi.fn(async ({ sessionId, text, displayText }: { sessionId: string; text: string; displayText?: string }) => { - queueMicrotask(() => { - service.onAgentChatEvent({ - sessionId, - timestamp: new Date().toISOString(), - event: { type: "user_message", text: displayText ?? text, turnId: "turn-hook" }, - }); - }); - }); - - service = createOpenclawBridgeService({ - projectRoot: "/tmp/project", - adeDir, - laneService: { - ensurePrimaryLane: vi.fn(async () => {}), - list: vi.fn(async () => [{ id: "lane-1", laneType: "primary" }]), - } as any, - agentChatService: { - listSessions: vi.fn(async () => []), - ensureIdentitySession: vi.fn(async () => ({ id: "session-cto", laneId: "lane-1" })), - sendMessage, - } as any, - ctoStateService: { - getIdentity: vi.fn(() => ({ - openclawContextPolicy: { shareMode: "filtered", blockedCategories: [] }, - })), - } as any, - }); - services.push(service); - await service.start(); - - const state = service.getState(); - const request = { - requestId: "dup-key-1", - message: "Fire and forget", - }; - const first = await fetch(state.endpoints.hookUrl!, { - method: "POST", - headers: { - "content-type": "application/json", - authorization: "Bearer test-hook-token", - }, - body: JSON.stringify(request), - }); - const second = await fetch(state.endpoints.hookUrl!, { - method: "POST", - headers: { - "content-type": "application/json", - authorization: "Bearer test-hook-token", - }, - body: JSON.stringify(request), - }); - - expect(first.status).toBe(202); - expect(second.status).toBe(202); - expect(sendMessage).toHaveBeenCalledTimes(1); - expect(await second.json()).toEqual(expect.objectContaining({ duplicate: true })); - }); - - it("queues outbound messages when the operator socket is unavailable", async () => { - const adeDir = fs.mkdtempSync(path.join(os.tmpdir(), "ade-openclaw-outbox-")); - writeOpenclawConfig(adeDir, { enabled: false }); - - const service = createOpenclawBridgeService({ - projectRoot: "/tmp/project", - adeDir, - laneService: { - ensurePrimaryLane: vi.fn(async () => {}), - list: vi.fn(async () => [{ id: "lane-1", laneType: "primary" }]), - } as any, - agentChatService: { - listSessions: vi.fn(async () => []), - ensureIdentitySession: vi.fn(async () => ({ id: "session-cto", laneId: "lane-1" })), - sendMessage: vi.fn(async () => {}), - } as any, - ctoStateService: { - getIdentity: vi.fn(() => ({ - openclawContextPolicy: { shareMode: "filtered", blockedCategories: ["secret"] }, - })), - } as any, - }); - services.push(service); - await service.start(); - - const record = await service.sendMessage({ - requestId: "queued-message-1", - agentId: "discord-cto", - message: "Mission finished", - context: { secret: "hide-me", lane: "lane-1" }, - }); - - expect(record.status).toBe("queued"); - expect(service.getState().status.queuedMessages).toBe(1); - expect(record.context).toEqual({ lane: "lane-1" }); - }); - - it("recursively redacts inbound bridge context before prompting and persistence", async () => { - const adeDir = fs.mkdtempSync(path.join(os.tmpdir(), "ade-openclaw-redact-")); - writeOpenclawConfig(adeDir, { enabled: false }); - - let service!: ReturnType; - const sentMessages: Array<{ text: string }> = []; - service = createOpenclawBridgeService({ - projectRoot: "/tmp/project", - adeDir, - laneService: { - ensurePrimaryLane: vi.fn(async () => {}), - list: vi.fn(async () => [{ id: "lane-1", laneType: "primary" }]), - } as any, - agentChatService: { - listSessions: vi.fn(async () => []), - ensureIdentitySession: vi.fn(async () => ({ id: "session-cto", laneId: "lane-1" })), - sendMessage: vi.fn(async ({ sessionId, text, displayText }: { sessionId: string; text: string; displayText?: string }) => { - sentMessages.push({ text }); - queueMicrotask(() => { - service.onAgentChatEvent({ - sessionId, - timestamp: new Date().toISOString(), - event: { type: "user_message", text: displayText ?? text, turnId: "turn-1" }, - }); - service.onAgentChatEvent({ - sessionId, - timestamp: new Date().toISOString(), - event: { type: "text", text: "redacted", turnId: "turn-1" }, - }); - service.onAgentChatEvent({ - sessionId, - timestamp: new Date().toISOString(), - event: { type: "done", turnId: "turn-1", status: "completed" }, - }); - }); - }), - } as any, - ctoStateService: { - getIdentity: vi.fn(() => ({ - openclawContextPolicy: { shareMode: "filtered", blockedCategories: ["secret"] }, - })), - } as any, - }); - services.push(service); - await service.start(); - - const res = await fetch(service.getState().endpoints.queryUrl!, { - method: "POST", - headers: { - "content-type": "application/json", - authorization: "Bearer test-hook-token", - }, - body: JSON.stringify({ - requestId: "req-redact-1", - message: "Review this", - context: { - nested: { - apiKey: "test-api-key-placeholder", - note: "safe", - }, - secret: "remove-me", - }, - }), - }); - - expect(res.status).toBe(200); - expect(sentMessages[0]?.text).toContain("\"apiKey\": \"[REDACTED]\""); - expect(sentMessages[0]?.text).toContain("\"note\": \"safe\""); - expect(sentMessages[0]?.text).not.toContain("remove-me"); - const inbound = service.listMessages(10).find((entry) => entry.requestId === "req-redact-1" && entry.direction === "inbound"); - expect(inbound?.context).toEqual({ - nested: { - apiKey: "[REDACTED]", - note: "safe", - }, - }); - }); - - it("keeps shareMode full while still redacting sensitive values", async () => { - const adeDir = fs.mkdtempSync(path.join(os.tmpdir(), "ade-openclaw-full-share-")); - writeOpenclawConfig(adeDir, { enabled: false }); - - const service = createOpenclawBridgeService({ - projectRoot: "/tmp/project", - adeDir, - laneService: { - ensurePrimaryLane: vi.fn(async () => {}), - list: vi.fn(async () => [{ id: "lane-1", laneType: "primary" }]), - } as any, - agentChatService: { - listSessions: vi.fn(async () => []), - ensureIdentitySession: vi.fn(async () => ({ id: "session-cto", laneId: "lane-1" })), - sendMessage: vi.fn(async () => {}), - } as any, - ctoStateService: { - getIdentity: vi.fn(() => ({ - openclawContextPolicy: { shareMode: "full", blockedCategories: ["secret"] }, - })), - } as any, - }); - services.push(service); - await service.start(); - - const record = await service.sendMessage({ - requestId: "queued-message-2", - agentId: "discord-cto", - message: "Mission finished", - context: { - secret: "Bearer very-secret-token-value", - lane: "lane-1", - }, - }); - - expect(record.context).toEqual({ - secret: "[REDACTED]", - lane: "lane-1", - }); - }); - - it("migrates legacy runtime files into cache and removes repo-visible copies", async () => { - const adeDir = fs.mkdtempSync(path.join(os.tmpdir(), "ade-openclaw-migrate-")); - writeOpenclawConfig(adeDir, { enabled: false }); - fs.mkdirSync(path.join(adeDir, "cto"), { recursive: true }); - fs.writeFileSync( - path.join(adeDir, "cto", "openclaw-history.json"), - JSON.stringify([{ - id: "legacy-1", - requestId: "legacy-request", - direction: "inbound", - mode: "hook", - status: "received", - body: "Legacy body", - summary: "Legacy summary", - context: { - apiKey: "test-api-key-placeholder", - }, - createdAt: new Date().toISOString(), - }], null, 2), - "utf8", - ); - - const service = createOpenclawBridgeService({ - projectRoot: "/tmp/project", - adeDir, - laneService: { - ensurePrimaryLane: vi.fn(async () => {}), - list: vi.fn(async () => [{ id: "lane-1", laneType: "primary" }]), - } as any, - agentChatService: { - listSessions: vi.fn(async () => []), - ensureIdentitySession: vi.fn(async () => ({ id: "session-cto", laneId: "lane-1" })), - sendMessage: vi.fn(async () => {}), - } as any, - ctoStateService: { - getIdentity: vi.fn(() => ({ - openclawContextPolicy: { shareMode: "filtered", blockedCategories: [] }, - })), - } as any, - }); - services.push(service); - await service.start(); - - expect(fs.existsSync(path.join(adeDir, "cto", "openclaw-history.json"))).toBe(false); - expect(fs.existsSync(path.join(adeDir, "cache", "openclaw", "openclaw-history.json"))).toBe(true); - expect(service.listMessages(10)[0]?.context).toEqual({ apiKey: "[REDACTED]" }); - }); -}); diff --git a/apps/desktop/src/main/services/cto/workerAdapterRuntimeService.test.ts b/apps/desktop/src/main/services/cto/workerAdapterRuntimeService.test.ts deleted file mode 100644 index b4c07a23f..000000000 --- a/apps/desktop/src/main/services/cto/workerAdapterRuntimeService.test.ts +++ /dev/null @@ -1,287 +0,0 @@ -import { EventEmitter } from "node:events"; -import path from "node:path"; -import { describe, expect, it, vi } from "vitest"; -import { createWorkerAdapterRuntimeService } from "./workerAdapterRuntimeService"; -import type { AgentIdentity } from "../../../shared/types"; - -type SpawnStubCapture = { - command: string; - args: string[]; - stdinWritten: string; -}; - -function makeAgent(overrides: Partial): AgentIdentity { - return { - id: "agent-1", - name: "Worker", - slug: "worker", - role: "engineer", - reportsTo: null, - capabilities: [], - status: "idle", - adapterType: "process", - adapterConfig: {}, - runtimeConfig: {}, - budgetMonthlyCents: 0, - spentMonthlyCents: 0, - createdAt: "2026-03-05T00:00:00.000Z", - updatedAt: "2026-03-05T00:00:00.000Z", - deletedAt: null, - ...overrides, - }; -} - -function createSpawnStub(output = "ok"): { - spawn: any; - capture: SpawnStubCapture; -} { - const capture: SpawnStubCapture = { - command: "", - args: [], - stdinWritten: "", - }; - const spawn = vi.fn((command: string, args: string[]) => { - capture.command = command; - capture.args = [...args]; - const stdout = new EventEmitter(); - const stderr = new EventEmitter(); - const child = new EventEmitter() as any; - child.stdout = stdout; - child.stderr = stderr; - child.stdin = { - write: (chunk: string) => { - capture.stdinWritten += chunk; - }, - end: () => {}, - }; - child.kill = vi.fn(); - queueMicrotask(() => { - stdout.emit("data", output); - child.emit("close", 0, null); - }); - return child; - }); - return { spawn, capture }; -} - -function createSession(id: string, provider: "claude" | "codex" | "opencode", model: string, modelId: string) { - return { - id, - laneId: "lane-1", - provider, - model, - modelId, - status: "idle" as const, - createdAt: "2026-03-05T00:00:00.000Z", - lastActivityAt: "2026-03-05T00:00:00.000Z", - }; -} - -describe("workerAdapterRuntimeService", () => { - it("runs claude-local through CLI spawn path", async () => { - const { spawn, capture } = createSpawnStub("claude-output"); - const service = createWorkerAdapterRuntimeService({ spawnImpl: spawn as any }); - const result = await service.run({ - agent: makeAgent({ - adapterType: "claude-local", - adapterConfig: { model: "sonnet", cliArgs: ["--json"] }, - }), - prompt: "hello", - }); - - expect(capture.command).toBe("claude"); - expect(capture.args).toEqual(["--model", "sonnet", "--json"]); - expect(capture.stdinWritten).toContain("hello"); - expect(result.ok).toBe(true); - expect(result.effectiveSurface).toBe("process"); - expect(result.outputText).toContain("claude-output"); - }); - - it("runs codex-local through CLI spawn path", async () => { - const { spawn, capture } = createSpawnStub("codex-output"); - const service = createWorkerAdapterRuntimeService({ spawnImpl: spawn as any }); - const result = await service.run({ - agent: makeAgent({ - adapterType: "codex-local", - adapterConfig: { model: "gpt-5.3-codex", cliArgs: ["--json"] }, - }), - prompt: "fix this", - }); - - expect(path.basename(capture.command)).toBe("codex"); - expect(capture.args).toEqual(["--model", "gpt-5.3-codex", "--json"]); - expect(result.ok).toBe(true); - expect(result.effectiveSurface).toBe("process"); - expect(result.outputText).toContain("codex-output"); - }); - - it("reuses Claude SDK session handles through the shared chat surface", async () => { - const ensureIdentitySession = vi.fn(async () => - createSession("session-claude-1", "claude", "claude-sonnet-4-6", "anthropic/claude-sonnet-4-6") - ); - const runSessionTurn = vi.fn(async () => ({ - sessionId: "session-claude-1", - provider: "claude", - model: "claude-sonnet-4-6", - modelId: "anthropic/claude-sonnet-4-6", - outputText: "claude session output", - sdkSessionId: "sdk-session-1", - })); - const service = createWorkerAdapterRuntimeService({ - getAgentChatService: () => ({ ensureIdentitySession, runSessionTurn }), - }); - - const result = await service.run({ - agent: makeAgent({ - adapterType: "claude-local", - adapterConfig: { modelId: "anthropic/claude-sonnet-4-6" }, - }), - laneId: "lane-1", - prompt: "resume the delegated issue", - }); - - expect(ensureIdentitySession).toHaveBeenCalledWith({ - identityKey: "agent:agent-1", - laneId: "lane-1", - modelId: "anthropic/claude-sonnet-4-6", - reuseExisting: true, - }); - expect(result.effectiveSurface).toBe("claude_sdk"); - expect(result.continuation).toMatchObject({ - surface: "claude_sdk", - sessionId: "session-claude-1", - sdkSessionId: "sdk-session-1", - }); - }); - - it("reuses Codex app-server thread handles through the shared chat surface", async () => { - const ensureIdentitySession = vi.fn(async () => - createSession("session-codex-1", "codex", "gpt-5.3-codex", "openai/gpt-5.3-codex") - ); - const runSessionTurn = vi.fn(async () => ({ - sessionId: "session-codex-1", - provider: "codex", - model: "gpt-5.3-codex", - modelId: "openai/gpt-5.3-codex", - outputText: "codex session output", - threadId: "thread-77", - })); - const service = createWorkerAdapterRuntimeService({ - getAgentChatService: () => ({ ensureIdentitySession, runSessionTurn }), - }); - - const result = await service.run({ - agent: makeAgent({ - adapterType: "codex-local", - adapterConfig: { modelId: "openai/gpt-5.3-codex" }, - }), - laneId: "lane-1", - prompt: "resume the delegated issue", - }); - - expect(result.effectiveSurface).toBe("codex_app_server"); - expect(result.continuation).toMatchObject({ - surface: "codex_app_server", - sessionId: "session-codex-1", - threadId: "thread-77", - }); - }); - - it("reuses opencode chat sessions for API-key or local-model workers", async () => { - const ensureIdentitySession = vi.fn(async () => - createSession("session-opencode-1", "opencode", "gpt-5.4-mini", "openai/gpt-5.4-mini") - ); - const runSessionTurn = vi.fn(async () => ({ - sessionId: "session-opencode-1", - provider: "opencode", - model: "gpt-5.4-mini", - modelId: "openai/gpt-5.4-mini", - outputText: "opencode chat output", - })); - const service = createWorkerAdapterRuntimeService({ - getAgentChatService: () => ({ ensureIdentitySession, runSessionTurn }), - }); - - const result = await service.run({ - agent: makeAgent({ - adapterType: "process", - adapterConfig: { modelId: "openai/gpt-5.4-mini" }, - }), - continuation: { - surface: "unified_chat", - sessionId: "session-opencode-1", - }, - prompt: "continue the same worker context", - }); - - expect(ensureIdentitySession).not.toHaveBeenCalled(); - expect(runSessionTurn).toHaveBeenCalledWith({ - sessionId: "session-opencode-1", - text: expect.stringContaining("continue the same worker context"), - timeoutMs: 300000, - }); - const firstCall = runSessionTurn.mock.calls[0] as unknown as [{ text: string }] | undefined; - expect(firstCall?.[0]?.text).toContain("## ADE CLI"); - expect(firstCall?.[0]?.text).toContain("Before saying an ADE task is blocked"); - expect(result.effectiveSurface).toBe("unified_chat"); - expect(result.continuation).toMatchObject({ - surface: "unified_chat", - sessionId: "session-opencode-1", - modelId: "openai/gpt-5.4-mini", - }); - }); - - it("sends openclaw-webhook request with resolved env header", async () => { - process.env.OPENCLAW_WEBHOOK_TOKEN = "secret-token"; - const fetchMock = vi.fn(async (_url: string, init?: RequestInit) => { - return { - ok: true, - status: 200, - text: async () => JSON.stringify({ output: "webhook-ok" }), - } as any; - }); - const service = createWorkerAdapterRuntimeService({ fetchImpl: fetchMock as any }); - const result = await service.run({ - agent: makeAgent({ - adapterType: "openclaw-webhook", - adapterConfig: { - url: "https://example.com/hook", - headers: { - Authorization: "Bearer ${env:OPENCLAW_WEBHOOK_TOKEN}", - }, - }, - }), - prompt: "run remote", - }); - - expect(fetchMock).toHaveBeenCalledTimes(1); - const [, init] = fetchMock.mock.calls[0] as [string, RequestInit]; - expect((init.headers as Record).Authorization).toBe("Bearer secret-token"); - expect(result.ok).toBe(true); - expect(result.outputText).toBe("webhook-ok"); - }); - - it("runs process adapter and blocks unsafe commands", async () => { - const { spawn } = createSpawnStub("process-output"); - const service = createWorkerAdapterRuntimeService({ spawnImpl: spawn as any }); - const ok = await service.run({ - agent: makeAgent({ - adapterType: "process", - adapterConfig: { command: "echo", args: ["hello"] }, - }), - prompt: "test", - }); - expect(ok.ok).toBe(true); - expect(ok.outputText).toContain("process-output"); - - await expect( - service.run({ - agent: makeAgent({ - adapterType: "process", - adapterConfig: { command: "rm -rf /" }, - }), - prompt: "test", - }) - ).rejects.toThrow(/unsafe/i); - }); -}); diff --git a/apps/desktop/src/main/services/cto/workerAgentService.test.ts b/apps/desktop/src/main/services/cto/workerAgentService.test.ts deleted file mode 100644 index 84ba10e5e..000000000 --- a/apps/desktop/src/main/services/cto/workerAgentService.test.ts +++ /dev/null @@ -1,217 +0,0 @@ -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; -import { describe, expect, it } from "vitest"; -import { openKvDb } from "../state/kvDb"; -import { createWorkerAgentService } from "./workerAgentService"; - -function createLogger() { - return { - debug: () => {}, - info: () => {}, - warn: () => {}, - error: () => {}, - } as any; -} - -async function createFixture() { - const root = fs.mkdtempSync(path.join(os.tmpdir(), "ade-worker-agents-")); - const adeDir = path.join(root, ".ade"); - fs.mkdirSync(adeDir, { recursive: true }); - const dbPath = path.join(adeDir, "ade.db"); - const db = await openKvDb(dbPath, createLogger()); - const projectId = "project-test"; - const service = createWorkerAgentService({ - db, - projectId, - adeDir, - }); - return { root, adeDir, db, projectId, service }; -} - -describe("workerAgentService", () => { - it("creates, edits, and removes worker agents with unlink-on-delete", async () => { - const fixture = await createFixture(); - const manager = fixture.service.saveAgent({ - name: "Backend Lead", - role: "engineer", - adapterType: "claude-local", - adapterConfig: { model: "sonnet" }, - }); - const report = fixture.service.saveAgent({ - name: "Backend IC", - role: "engineer", - reportsTo: manager.id, - adapterType: "codex-local", - adapterConfig: { model: "gpt-5.3-codex" }, - }); - - const edited = fixture.service.saveAgent({ - id: report.id, - name: "Backend Engineer", - role: "engineer", - reportsTo: manager.id, - adapterType: "codex-local", - adapterConfig: { model: "gpt-5.3-codex-spark" }, - capabilities: ["api", "tests"], - }); - expect(edited.name).toBe("Backend Engineer"); - expect(edited.capabilities).toEqual(["api", "tests"]); - - fixture.service.removeAgent(manager.id); - const unlinked = fixture.service.getAgent(report.id); - expect(unlinked?.reportsTo).toBeNull(); - expect(fixture.service.getAgent(manager.id)).toBeNull(); - expect(fixture.service.getAgent(manager.id, { includeDeleted: true })?.deletedAt).toBeTruthy(); - - fixture.db.close(); - }); - - it("reconstructs org tree and chain-of-command", async () => { - const fixture = await createFixture(); - const lead = fixture.service.saveAgent({ - name: "Lead", - role: "engineer", - adapterType: "claude-local", - adapterConfig: {}, - }); - const mid = fixture.service.saveAgent({ - name: "Mid", - role: "engineer", - reportsTo: lead.id, - adapterType: "claude-local", - adapterConfig: {}, - }); - const junior = fixture.service.saveAgent({ - name: "Junior", - role: "qa", - reportsTo: mid.id, - adapterType: "process", - adapterConfig: { command: "echo" }, - }); - - const tree = fixture.service.listOrgTree(); - expect(tree.length).toBe(1); - expect(tree[0]?.id).toBe(lead.id); - expect(tree[0]?.reports[0]?.id).toBe(mid.id); - expect(tree[0]?.reports[0]?.reports[0]?.id).toBe(junior.id); - - const chain = fixture.service.getChainOfCommand(junior.id); - expect(chain.map((entry) => entry.id)).toEqual([junior.id, mid.id, lead.id]); - - fixture.db.close(); - }); - - it("blocks cycle creation and 50-hop overflow", async () => { - const fixture = await createFixture(); - const a = fixture.service.saveAgent({ - name: "A", - role: "engineer", - adapterType: "claude-local", - adapterConfig: {}, - }); - const b = fixture.service.saveAgent({ - name: "B", - role: "engineer", - reportsTo: a.id, - adapterType: "claude-local", - adapterConfig: {}, - }); - - expect(() => - fixture.service.saveAgent({ - id: a.id, - name: "A", - role: "engineer", - reportsTo: b.id, - adapterType: "claude-local", - adapterConfig: {}, - }) - ).toThrow(/cycle/i); - - let parentId = b.id; - for (let i = 0; i < 49; i += 1) { - const node = fixture.service.saveAgent({ - name: `worker-${i}`, - role: "general", - reportsTo: parentId, - adapterType: "process", - adapterConfig: { command: "echo" }, - }); - parentId = node.id; - } - - expect(() => - fixture.service.saveAgent({ - name: "overflow-node", - role: "general", - reportsTo: parentId, - adapterType: "process", - adapterConfig: { command: "echo" }, - }) - ).toThrow(/50 hops/i); - - fixture.db.close(); - }); - - it("rejects raw secret-like adapter config values", async () => { - const fixture = await createFixture(); - expect(() => - fixture.service.saveAgent({ - name: "Remote", - role: "researcher", - adapterType: "openclaw-webhook", - adapterConfig: { - url: "https://example.com/hook", - headers: { - Authorization: "Bearer sk-secret-value", - }, - }, - }) - ).toThrow(/raw secret-like value/i); - - const ok = fixture.service.saveAgent({ - name: "Remote 2", - role: "researcher", - adapterType: "openclaw-webhook", - adapterConfig: { - url: "https://example.com/hook", - headers: { - Authorization: "Bearer ${env:OPENCLAW_WEBHOOK_TOKEN}", - }, - }, - }); - expect(ok.id).toBeTruthy(); - - fixture.db.close(); - }); - - it("normalizes legacy full_mcp worker session logs as full tooling", async () => { - const fixture = await createFixture(); - const worker = fixture.service.saveAgent({ - name: "Legacy Worker", - role: "engineer", - adapterType: "codex-local", - adapterConfig: {}, - }); - const sessionsPath = path.join(fixture.adeDir, "agents", worker.slug, "sessions.jsonl"); - fs.writeFileSync( - sessionsPath, - `${JSON.stringify({ - sessionId: "legacy-session", - summary: "Legacy worker session", - startedAt: "2026-03-05T10:00:00.000Z", - endedAt: "2026-03-05T10:05:00.000Z", - provider: "codex", - modelId: "openai/gpt-5.3-codex", - capabilityMode: "full_mcp", - createdAt: "2026-03-05T10:06:00.000Z", - })}\n`, - "utf8" - ); - - expect(fixture.service.listSessionLogs(worker.id, 10)[0]?.capabilityMode).toBe("full_tooling"); - - fixture.db.close(); - }); -}); diff --git a/apps/desktop/src/main/services/cto/workerBudgetService.test.ts b/apps/desktop/src/main/services/cto/workerBudgetService.test.ts deleted file mode 100644 index 2ebc33131..000000000 --- a/apps/desktop/src/main/services/cto/workerBudgetService.test.ts +++ /dev/null @@ -1,188 +0,0 @@ -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; -import { describe, expect, it } from "vitest"; -import { openKvDb } from "../state/kvDb"; -import { createWorkerAgentService } from "./workerAgentService"; -import { createWorkerBudgetService } from "./workerBudgetService"; - -function createLogger() { - return { - debug: () => {}, - info: () => {}, - warn: () => {}, - error: () => {}, - } as any; -} - -async function createFixture(config?: { companyBudgetMonthlyCents?: number }) { - const root = fs.mkdtempSync(path.join(os.tmpdir(), "ade-worker-budget-")); - const adeDir = path.join(root, ".ade"); - fs.mkdirSync(adeDir, { recursive: true }); - const dbPath = path.join(adeDir, "ade.db"); - const db = await openKvDb(dbPath, createLogger()); - const projectId = "project-test"; - const workerAgentService = createWorkerAgentService({ - db, - projectId, - adeDir, - }); - const projectConfigService = { - get: () => ({ - effective: { - cto: { - companyBudgetMonthlyCents: config?.companyBudgetMonthlyCents ?? 0, - budgetTelemetry: { - enabled: false, - }, - }, - }, - }), - } as any; - const workerBudgetService = createWorkerBudgetService({ - db, - projectId, - workerAgentService, - projectConfigService, - }); - return { db, workerAgentService, workerBudgetService }; -} - -describe("workerBudgetService", () => { - it("records cost events and combines exact+estimated spend", async () => { - const fixture = await createFixture(); - const worker = fixture.workerAgentService.saveAgent({ - name: "Budget Worker", - role: "engineer", - adapterType: "codex-local", - adapterConfig: { model: "gpt-5.3-codex" }, - budgetMonthlyCents: 10_000, - }); - - fixture.workerBudgetService.recordCostEvent({ - agentId: worker.id, - provider: "openai", - modelId: "gpt-5.3-codex", - costCents: 120, - estimated: false, - source: "api", - }); - fixture.workerBudgetService.recordCostEvent({ - agentId: worker.id, - provider: "codex-local", - modelId: "gpt-5.3-codex", - costCents: 80, - estimated: true, - source: "cli", - }); - - const snapshot = fixture.workerBudgetService.getBudgetSnapshot(); - const row = snapshot.workers.find((entry) => entry.agentId === worker.id); - expect(row?.exactSpentCents).toBe(120); - expect(row?.estimatedSpentCents).toBe(80); - expect(row?.spentMonthlyCents).toBe(200); - expect(snapshot.companySpentMonthlyCents).toBe(200); - expect(snapshot.companyExactSpentCents).toBe(120); - expect(snapshot.companyEstimatedSpentCents).toBe(80); - - fixture.db.close(); - }); - - it("auto-pauses worker when per-worker cap is breached", async () => { - const fixture = await createFixture(); - const worker = fixture.workerAgentService.saveAgent({ - name: "Capped Worker", - role: "qa", - adapterType: "process", - adapterConfig: { command: "echo" }, - budgetMonthlyCents: 150, - }); - - fixture.workerBudgetService.recordCostEvent({ - agentId: worker.id, - provider: "manual", - costCents: 151, - estimated: false, - source: "manual", - }); - const updated = fixture.workerAgentService.getAgent(worker.id); - expect(updated?.status).toBe("paused"); - - fixture.db.close(); - }); - - it("auto-pauses workers when company cap is breached", async () => { - const fixture = await createFixture({ companyBudgetMonthlyCents: 200 }); - const workerA = fixture.workerAgentService.saveAgent({ - name: "A", - role: "engineer", - adapterType: "process", - adapterConfig: { command: "echo" }, - budgetMonthlyCents: 0, - }); - const workerB = fixture.workerAgentService.saveAgent({ - name: "B", - role: "engineer", - adapterType: "process", - adapterConfig: { command: "echo" }, - budgetMonthlyCents: 0, - }); - - fixture.workerBudgetService.recordCostEvent({ - agentId: workerA.id, - provider: "manual", - costCents: 120, - estimated: false, - source: "manual", - }); - fixture.workerBudgetService.recordCostEvent({ - agentId: workerB.id, - provider: "manual", - costCents: 120, - estimated: false, - source: "manual", - }); - - const stateA = fixture.workerAgentService.getAgent(workerA.id); - const stateB = fixture.workerAgentService.getAgent(workerB.id); - expect(stateA?.status).toBe("paused"); - expect(stateB?.status).toBe("paused"); - - fixture.db.close(); - }); - - it("respects monthly boundaries for spend accumulation", async () => { - const fixture = await createFixture(); - const worker = fixture.workerAgentService.saveAgent({ - name: "Month Worker", - role: "general", - adapterType: "process", - adapterConfig: { command: "echo" }, - budgetMonthlyCents: 0, - }); - - fixture.workerBudgetService.recordCostEvent({ - agentId: worker.id, - provider: "manual", - costCents: 50, - estimated: false, - source: "manual", - occurredAt: "2026-01-31T23:59:00.000Z", - }); - fixture.workerBudgetService.recordCostEvent({ - agentId: worker.id, - provider: "manual", - costCents: 75, - estimated: false, - source: "manual", - occurredAt: "2026-02-01T00:00:00.000Z", - }); - - const jan = fixture.workerBudgetService.getBudgetSnapshot({ monthKey: "2026-01" }); - const feb = fixture.workerBudgetService.getBudgetSnapshot({ monthKey: "2026-02" }); - expect(jan.workers.find((entry) => entry.agentId === worker.id)?.spentMonthlyCents).toBe(50); - expect(feb.workers.find((entry) => entry.agentId === worker.id)?.spentMonthlyCents).toBe(75); - - fixture.db.close(); - }); -}); diff --git a/apps/desktop/src/main/services/cto/workerHeartbeatService.test.ts b/apps/desktop/src/main/services/cto/workerHeartbeatService.test.ts deleted file mode 100644 index b80dcc90b..000000000 --- a/apps/desktop/src/main/services/cto/workerHeartbeatService.test.ts +++ /dev/null @@ -1,858 +0,0 @@ -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; -import { describe, expect, it, vi, afterEach } from "vitest"; -import { openKvDb, type AdeDb } from "../state/kvDb"; -import { createWorkerAgentService } from "./workerAgentService"; -import { createWorkerHeartbeatService } from "./workerHeartbeatService"; -import { createWorkerTaskSessionService } from "./workerTaskSessionService"; -import type { AgentIdentity, WorkerAgentRunStatus, WorkerAgentWakeupReason } from "../../../shared/types"; - -function createLogger() { - return { - debug: () => {}, - info: () => {}, - warn: () => {}, - error: () => {}, - } as any; -} - -function nowIso(): string { - return new Date().toISOString(); -} - -async function waitForCondition(assertion: () => void, timeoutMs = 3_000, intervalMs = 15): Promise { - const deadline = Date.now() + timeoutMs; - let lastError: unknown = null; - while (Date.now() <= deadline) { - try { - assertion(); - return; - } catch (error) { - lastError = error; - } - await new Promise((resolve) => setTimeout(resolve, intervalMs)); - } - throw lastError instanceof Error ? lastError : new Error("Condition timed out."); -} - -function insertRunRow( - db: AdeDb, - input: { - id: string; - projectId: string; - agentId: string; - status: WorkerAgentRunStatus; - wakeupReason?: WorkerAgentWakeupReason; - taskKey?: string | null; - issueKey?: string | null; - executionRunId?: string | null; - executionLockedAt?: string | null; - contextJson?: string | null; - resultJson?: string | null; - errorMessage?: string | null; - startedAt?: string | null; - finishedAt?: string | null; - createdAt?: string; - updatedAt?: string; - } -): void { - const createdAt = input.createdAt ?? nowIso(); - const updatedAt = input.updatedAt ?? createdAt; - db.run( - ` - insert into worker_agent_runs( - id, project_id, agent_id, status, wakeup_reason, task_key, issue_key, execution_run_id, execution_locked_at, - context_json, result_json, error_message, started_at, finished_at, created_at, updated_at - ) - values(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - `, - [ - input.id, - input.projectId, - input.agentId, - input.status, - input.wakeupReason ?? "manual", - input.taskKey ?? null, - input.issueKey ?? null, - input.executionRunId ?? null, - input.executionLockedAt ?? null, - input.contextJson ?? "{}", - input.resultJson ?? null, - input.errorMessage ?? null, - input.startedAt ?? null, - input.finishedAt ?? null, - createdAt, - updatedAt, - ] - ); -} - -async function createFixture(options: { - runtimeRun?: ReturnType; - memoryService?: { - getMemoryBudget: ReturnType; - }; - ctoStateService?: { - appendSubordinateActivity: ReturnType; - }; - autoStart?: boolean; - staleLockMs?: number; - maintenanceIntervalMs?: number; -} = {}) { - const root = fs.mkdtempSync(path.join(os.tmpdir(), "ade-worker-heartbeat-")); - const adeDir = path.join(root, ".ade"); - fs.mkdirSync(adeDir, { recursive: true }); - const db = await openKvDb(path.join(adeDir, "ade.db"), createLogger()); - const projectId = "project-heartbeat-test"; - const workerAgentService = createWorkerAgentService({ - db, - projectId, - adeDir, - }); - const workerTaskSessionService = createWorkerTaskSessionService({ - db, - projectId, - }); - const runtimeRun = options.runtimeRun ?? vi.fn(async () => ({ - ok: true, - adapterType: "codex-local", - effectiveSurface: "process", - statusCode: 200, - outputText: "HEARTBEAT_OK", - provider: "codex", - modelId: "openai/gpt-5.3-codex", - continuation: null, - usage: null, - })); - const runtimeAdapter = { - run: vi.fn(async (...runtimeArgs: any[]) => { - const result = await runtimeRun(...runtimeArgs) as Record; - return { - effectiveSurface: "process", - provider: null, - modelId: null, - sessionId: null, - continuation: null, - ...result, - }; - }), - }; - const recordCostEvent = vi.fn(); - const heartbeat = createWorkerHeartbeatService({ - db, - projectId, - workerAgentService, - workerTaskSessionService, - workerAdapterRuntimeService: runtimeAdapter as any, - workerBudgetService: { recordCostEvent } as any, - memoryService: options.memoryService as any, - ctoStateService: options.ctoStateService as any, - logger: createLogger(), - autoStart: options.autoStart ?? false, - staleLockMs: options.staleLockMs, - maintenanceIntervalMs: options.maintenanceIntervalMs, - }); - - const createWorker = (overrides: Partial & { name: string }): AgentIdentity => { - return workerAgentService.saveAgent({ - id: overrides.id, - name: overrides.name, - role: overrides.role ?? "engineer", - title: overrides.title, - reportsTo: overrides.reportsTo, - capabilities: overrides.capabilities ?? [], - adapterType: overrides.adapterType ?? "codex-local", - adapterConfig: (overrides.adapterConfig as Record | undefined) ?? { model: "gpt-5.3-codex" }, - runtimeConfig: (overrides.runtimeConfig as Record | undefined) ?? { - heartbeat: { - enabled: true, - intervalSec: 60, - wakeOnDemand: true, - }, - }, - status: overrides.status, - budgetMonthlyCents: overrides.budgetMonthlyCents, - }); - }; - - const dispose = async () => { - await heartbeat.dispose(); - db.close(); - }; - - return { - root, - adeDir, - db, - projectId, - workerAgentService, - workerTaskSessionService, - heartbeat, - runtimeRun, - recordCostEvent, - createWorker, - dispose, - }; -} - -afterEach(() => { - vi.clearAllTimers(); - vi.useRealTimers(); -}); - -describe("workerHeartbeatService", () => { - it("timer wake fires at configured interval", async () => { - vi.useFakeTimers(); - vi.setSystemTime(new Date("2026-03-05T12:00:00.000Z")); - const fixture = await createFixture(); - const worker = fixture.createWorker({ - name: "Timer Worker", - runtimeConfig: { - heartbeat: { - enabled: true, - intervalSec: 1, - wakeOnDemand: true, - }, - }, - }); - - fixture.heartbeat.syncFromConfig(); - await vi.advanceTimersByTimeAsync(1_200); - - // Assert directly after advancing fake timers -- waitForCondition cannot - // work here because its internal setTimeout/Date.now rely on real timers. - const runs = fixture.heartbeat.listRuns({ agentId: worker.id, limit: 5 }); - expect(runs.length).toBeGreaterThan(0); - expect(runs[0]?.wakeupReason).toBe("timer"); - expect(runs[0]?.status).toBe("completed"); - await fixture.dispose(); - }); - - it("active-hours gate blocks timer wakes outside configured window", async () => { - vi.useFakeTimers(); - vi.setSystemTime(new Date("2026-03-05T12:00:00.000Z")); - const fixture = await createFixture(); - const worker = fixture.createWorker({ - name: "Active Hours Timer Worker", - runtimeConfig: { - heartbeat: { - enabled: true, - intervalSec: 30, - wakeOnDemand: true, - activeHours: { - start: "00:00", - end: "00:01", - timezone: "UTC", - }, - }, - }, - }); - - const wake = await fixture.heartbeat.triggerWakeup({ - agentId: worker.id, - reason: "timer", - }); - expect(wake.status).toBe("deferred"); - expect(fixture.runtimeRun).not.toHaveBeenCalled(); - await fixture.dispose(); - }); - - it("on-demand wake also respects active-hours gate", async () => { - vi.useFakeTimers(); - vi.setSystemTime(new Date("2026-03-05T12:00:00.000Z")); - const fixture = await createFixture(); - const worker = fixture.createWorker({ - name: "Active Hours Manual Worker", - runtimeConfig: { - heartbeat: { - enabled: true, - intervalSec: 60, - wakeOnDemand: true, - activeHours: { - start: "00:00", - end: "00:01", - timezone: "UTC", - }, - }, - }, - }); - - const wake = await fixture.heartbeat.triggerWakeup({ - agentId: worker.id, - reason: "manual", - prompt: "Please inspect current assignment", - }); - expect(wake.status).toBe("deferred"); - expect(fixture.runtimeRun).not.toHaveBeenCalled(); - await fixture.dispose(); - }); - - it("cheap-check timer run with no change skips adapter escalation", async () => { - const fixture = await createFixture(); - const worker = fixture.createWorker({ name: "Cheap Check Worker" }); - - const wake = await fixture.heartbeat.triggerWakeup({ - agentId: worker.id, - reason: "timer", - context: { hasChanges: false, eventCount: 0 }, - }); - expect(wake.status).toBe("completed"); - expect(fixture.runtimeRun).not.toHaveBeenCalled(); - await fixture.dispose(); - }); - - it("records HEARTBEAT_OK results without errors", async () => { - const runtimeRun = vi.fn(async () => ({ - ok: true, - adapterType: "codex-local", - statusCode: 200, - outputText: "HEARTBEAT_OK", - usage: null, - })); - const fixture = await createFixture({ runtimeRun }); - const worker = fixture.createWorker({ name: "Heartbeat Ok Worker" }); - - const wake = await fixture.heartbeat.triggerWakeup({ - agentId: worker.id, - reason: "manual", - prompt: "Check for urgent events", - }); - const run = fixture.heartbeat.listRuns({ agentId: worker.id, limit: 5 }).find((entry) => entry.id === wake.runId); - expect(run?.status).toBe("completed"); - expect((run?.result as Record)?.heartbeatOk).toBe(true); - expect((run?.result as Record)?.outputPreview).toBe("HEARTBEAT_OK"); - await fixture.dispose(); - }); - - it("injects worker reconstruction, task session, and project memory into runtime prompts", async () => { - const runtimeRun = vi.fn(async () => ({ - ok: true, - adapterType: "codex-local", - statusCode: 200, - outputText: "looked good", - usage: null, - })); - const memoryService = { - getMemoryBudget: vi.fn(() => ([ - { - category: "pattern", - content: "Reuse the issue lock before starting a second worker on the same issue.", - }, - ])), - }; - const fixture = await createFixture({ runtimeRun, memoryService }); - const worker = fixture.createWorker({ name: "Memory Rich Worker" }); - fixture.workerAgentService.updateCoreMemory(worker.id, { - projectSummary: "Owns worker-side issue triage and escalation.", - criticalConventions: ["Prefer HEARTBEAT_OK when there is no actionable work."], - activeFocus: ["Issue triage"], - }); - - await fixture.heartbeat.triggerWakeup({ - agentId: worker.id, - reason: "manual", - taskKey: "task:memory-rich", - issueKey: "ISSUE-900", - prompt: "Inspect the issue queue and decide whether escalation is needed.", - context: { queue: "bugs", severity: "high" }, - }); - - expect(runtimeRun).toHaveBeenCalledTimes(1); - const firstCall = (runtimeRun.mock.calls as Array)[0]?.[0] as { prompt?: string } | undefined; - const prompt = String(firstCall?.prompt ?? ""); - expect(prompt).toContain("System context (worker reconstruction, do not echo verbatim):"); - expect(prompt).toContain("Owns worker-side issue triage and escalation."); - expect(prompt).toContain("Project memory highlights:"); - expect(prompt).toContain("Reuse the issue lock before starting a second worker on the same issue."); - expect(prompt).toContain("Task session state:"); - expect(prompt).toContain("task:memory-rich"); - expect(prompt).toContain("Current wakeup request:"); - await fixture.dispose(); - }); - - it("appends worker session logs after escalated runs so reconstruction memory compounds", async () => { - const runtimeRun = vi.fn(async () => ({ - ok: true, - adapterType: "codex-local", - statusCode: 200, - outputText: "Reviewed alerts and found no actionable follow-up.", - usage: null, - })); - const fixture = await createFixture({ runtimeRun }); - const worker = fixture.createWorker({ name: "Session Log Worker" }); - - await fixture.heartbeat.triggerWakeup({ - agentId: worker.id, - reason: "manual", - taskKey: "task:session-log", - prompt: "Review the alert backlog.", - }); - - const sessions = fixture.workerAgentService.listSessionLogs(worker.id, 10); - expect(sessions.length).toBe(1); - expect(sessions[0]?.summary).toContain("Wake reason: manual."); - expect(sessions[0]?.summary).toContain("task:session-log"); - await fixture.dispose(); - }); - - it("propagates meaningful worker runs into CTO subordinate activity", async () => { - const runtimeRun = vi.fn(async () => ({ - ok: true, - adapterType: "codex-local", - statusCode: 200, - outputText: "Reviewed alerts and found no actionable follow-up.", - usage: null, - })); - const ctoStateService = { - appendSubordinateActivity: vi.fn(), - }; - const fixture = await createFixture({ runtimeRun, ctoStateService }); - const worker = fixture.createWorker({ name: "Digest Worker" }); - - await fixture.heartbeat.triggerWakeup({ - agentId: worker.id, - reason: "manual", - taskKey: "task:cto-digest", - issueKey: "ISSUE-42", - prompt: "Review the alert backlog.", - }); - - expect(ctoStateService.appendSubordinateActivity).toHaveBeenCalledWith( - expect.objectContaining({ - agentId: worker.id, - agentName: "Digest Worker", - activityType: "worker_run", - taskKey: "task:cto-digest", - issueKey: "ISSUE-42", - }) - ); - await fixture.dispose(); - }); - - it("coalesces duplicate wakeups while same task is running", async () => { - let resolveFirst!: (value: { - ok: boolean; - adapterType: string; - statusCode: number; - outputText: string; - usage: null; - }) => void; - const runtimeRun = vi - .fn() - .mockImplementationOnce( - () => - new Promise((resolve) => { - resolveFirst = resolve; - }) - ) - .mockResolvedValue({ - ok: true, - adapterType: "codex-local", - statusCode: 200, - outputText: "done", - usage: null, - }); - - const fixture = await createFixture({ runtimeRun }); - const worker = fixture.createWorker({ name: "Coalescing Worker" }); - - const firstWakePromise = fixture.heartbeat.triggerWakeup({ - agentId: worker.id, - reason: "manual", - taskKey: "task:same", - issueKey: "ISSUE-123", - prompt: "Work on issue 123", - context: { source: "first" }, - }); - - await waitForCondition(() => { - const running = fixture.heartbeat - .listRuns({ agentId: worker.id, limit: 10 }) - .find((run) => run.status === "running"); - expect(running).toBeTruthy(); - }); - - const secondWake = await fixture.heartbeat.triggerWakeup({ - agentId: worker.id, - reason: "manual", - taskKey: "task:same", - issueKey: "ISSUE-123", - context: { source: "second" }, - }); - expect(secondWake.status).toBe("skipped"); - - resolveFirst({ - ok: true, - adapterType: "codex-local", - statusCode: 200, - outputText: "complete", - usage: null, - }); - const firstWake = await firstWakePromise; - - await waitForCondition(() => { - const firstRun = fixture.heartbeat.listRuns({ agentId: worker.id, limit: 10 }).find((run) => run.id === firstWake.runId); - expect(firstRun?.status).toBe("completed"); - }); - - const latestRuns = fixture.heartbeat.listRuns({ agentId: worker.id, limit: 10 }); - const firstRun = latestRuns.find((run) => run.id === firstWake.runId); - const coalesced = (firstRun?.context.coalescedWakeups as unknown[]) ?? []; - expect(coalesced.length).toBe(1); - await fixture.dispose(); - }); - - it("promotes deferred wake after active run completes", async () => { - let resolveFirst!: (value: { - ok: boolean; - adapterType: string; - statusCode: number; - outputText: string; - usage: null; - }) => void; - const runtimeRun = vi - .fn() - .mockImplementationOnce( - () => - new Promise((resolve) => { - resolveFirst = resolve; - }) - ) - .mockResolvedValue({ - ok: true, - adapterType: "codex-local", - statusCode: 200, - outputText: "second-complete", - usage: null, - }); - const fixture = await createFixture({ runtimeRun }); - const worker = fixture.createWorker({ name: "Deferred Promotion Worker" }); - - const firstWakePromise = fixture.heartbeat.triggerWakeup({ - agentId: worker.id, - reason: "manual", - taskKey: "task:first", - issueKey: "ISSUE-A", - prompt: "first task", - }); - await waitForCondition(() => { - const running = fixture.heartbeat.listRuns({ agentId: worker.id, limit: 10 }).find((run) => run.status === "running"); - expect(running).toBeTruthy(); - }); - - const secondWake = await fixture.heartbeat.triggerWakeup({ - agentId: worker.id, - reason: "manual", - taskKey: "task:second", - issueKey: "ISSUE-B", - prompt: "second task", - }); - expect(secondWake.status).toBe("deferred"); - - resolveFirst({ - ok: true, - adapterType: "codex-local", - statusCode: 200, - outputText: "first-complete", - usage: null, - }); - await firstWakePromise; - - await waitForCondition(() => { - const promoted = fixture.heartbeat.listRuns({ agentId: worker.id, limit: 10 }).find((run) => run.id === secondWake.runId); - expect(promoted?.status).toBe("completed"); - }); - expect(runtimeRun).toHaveBeenCalledTimes(2); - await fixture.dispose(); - }); - - it("reaps orphaned queued/running runs on startup and promotes deferred work", async () => { - const runtimeRun = vi.fn(async () => ({ - ok: true, - adapterType: "codex-local", - statusCode: 200, - outputText: "startup-recovered", - usage: null, - })); - const fixture = await createFixture({ runtimeRun }); - const worker = fixture.createWorker({ name: "Startup Recovery Worker" }); - const timestamp = "2026-03-05T00:00:00.000Z"; - - insertRunRow(fixture.db, { - id: "orphan-queued", - projectId: fixture.projectId, - agentId: worker.id, - status: "queued", - wakeupReason: "startup_recovery", - createdAt: timestamp, - updatedAt: timestamp, - }); - insertRunRow(fixture.db, { - id: "orphan-running", - projectId: fixture.projectId, - agentId: worker.id, - status: "running", - wakeupReason: "startup_recovery", - executionRunId: "exec-orphan", - executionLockedAt: timestamp, - createdAt: timestamp, - updatedAt: timestamp, - }); - insertRunRow(fixture.db, { - id: "recoverable-deferred", - projectId: fixture.projectId, - agentId: worker.id, - status: "deferred", - wakeupReason: "deferred_promotion", - taskKey: "task:recover", - issueKey: "ISSUE-RECOVER", - contextJson: JSON.stringify({ prompt: "recover deferred task" }), - createdAt: timestamp, - updatedAt: timestamp, - }); - - await fixture.heartbeat.reapOrphansOnStartup(); - - await waitForCondition(() => { - const runs = fixture.heartbeat.listRuns({ agentId: worker.id, limit: 20 }); - expect(runs.find((run) => run.id === "orphan-queued")?.status).toBe("failed"); - expect(runs.find((run) => run.id === "orphan-running")?.status).toBe("failed"); - expect(runs.find((run) => run.id === "recoverable-deferred")?.status).toBe("completed"); - }); - - await fixture.dispose(); - }); - - it("issue lock checkout blocks parallel run for same issue", async () => { - let resolveFirst!: (value: { - ok: boolean; - adapterType: string; - statusCode: number; - outputText: string; - usage: null; - }) => void; - const runtimeRun = vi - .fn() - .mockImplementationOnce( - () => - new Promise((resolve) => { - resolveFirst = resolve; - }) - ) - .mockResolvedValue({ - ok: true, - adapterType: "codex-local", - statusCode: 200, - outputText: "done", - usage: null, - }); - const fixture = await createFixture({ runtimeRun }); - const workerA = fixture.createWorker({ name: "Issue Locker A" }); - const workerB = fixture.createWorker({ name: "Issue Locker B" }); - - const firstWakePromise = fixture.heartbeat.triggerWakeup({ - agentId: workerA.id, - reason: "manual", - issueKey: "ISSUE-LOCK", - prompt: "worker A task", - }); - await waitForCondition(() => { - const running = fixture.heartbeat.listRuns({ agentId: workerA.id, limit: 10 }).find((run) => run.status === "running"); - expect(running).toBeTruthy(); - }); - - const secondWake = await fixture.heartbeat.triggerWakeup({ - agentId: workerB.id, - reason: "manual", - issueKey: "ISSUE-LOCK", - prompt: "worker B task", - }); - expect(secondWake.status).toBe("deferred"); - - resolveFirst({ - ok: true, - adapterType: "codex-local", - statusCode: 200, - outputText: "finish", - usage: null, - }); - await firstWakePromise; - - const secondRun = fixture.heartbeat.listRuns({ agentId: workerB.id, limit: 10 }).find((run) => run.id === secondWake.runId); - expect(secondRun?.status).toBe("deferred"); - await fixture.dispose(); - }); - - it("adopts stale issue lock and fails stale owner run", async () => { - const fixture = await createFixture({ staleLockMs: 50 }); - const workerA = fixture.createWorker({ name: "Stale Owner" }); - const workerB = fixture.createWorker({ name: "Stale Adopter" }); - const staleAt = new Date(Date.now() - 120_000).toISOString(); - insertRunRow(fixture.db, { - id: "stale-running-run", - projectId: fixture.projectId, - agentId: workerA.id, - status: "running", - wakeupReason: "manual", - issueKey: "ISSUE-STALE", - executionRunId: "exec-stale", - executionLockedAt: staleAt, - createdAt: staleAt, - updatedAt: staleAt, - }); - - const wake = await fixture.heartbeat.triggerWakeup({ - agentId: workerB.id, - reason: "manual", - issueKey: "ISSUE-STALE", - prompt: "adopt stale lock", - }); - expect(wake.status).toBe("completed"); - - const staleRun = fixture.heartbeat.listRuns({ agentId: workerA.id, limit: 10 }).find((run) => run.id === "stale-running-run"); - expect(staleRun?.status).toBe("failed"); - expect(staleRun?.errorMessage).toContain("adopted"); - await fixture.dispose(); - }); - - it("waits for direct wakeup dispatches during dispose", async () => { - let releaseRun: () => void = () => { - throw new Error("Expected wakeup runtime to be blocked."); - }; - const runtimeRun = vi.fn(async () => { - await new Promise((resolve) => { - releaseRun = resolve; - }); - return { - ok: true, - adapterType: "codex-local", - effectiveSurface: "process", - statusCode: 200, - outputText: "completed wake", - provider: "codex", - modelId: "openai/gpt-5.3-codex", - continuation: null, - usage: null, - }; - }); - const fixture = await createFixture({ runtimeRun }); - const worker = fixture.createWorker({ name: "Direct Wake Worker" }); - - const wake = fixture.heartbeat.triggerWakeup({ - agentId: worker.id, - reason: "manual", - prompt: "run slowly", - }); - await waitForCondition(() => { - expect(runtimeRun).toHaveBeenCalledTimes(1); - }); - - let disposeSettled = false; - const dispose = fixture.heartbeat.dispose().then(() => { - disposeSettled = true; - }); - await Promise.resolve(); - expect(disposeSettled).toBe(false); - - releaseRun(); - await wake; - await dispose; - expect(disposeSettled).toBe(true); - fixture.db.close(); - }); - - it("reuses persisted worker continuation handles across repeated wakeups on the same delegated task", async () => { - const runtimeRun = vi.fn() - .mockResolvedValueOnce({ - ok: true, - adapterType: "codex-local", - effectiveSurface: "codex_app_server", - statusCode: 200, - outputText: "completed first wake", - provider: "codex", - modelId: "openai/gpt-5.3-codex", - sessionId: "session-1", - continuation: { - surface: "codex_app_server", - provider: "codex", - modelId: "openai/gpt-5.3-codex", - sessionId: "session-1", - threadId: "thread-1", - }, - usage: null, - }) - .mockResolvedValueOnce({ - ok: true, - adapterType: "codex-local", - effectiveSurface: "codex_app_server", - statusCode: 200, - outputText: "completed second wake", - provider: "codex", - modelId: "openai/gpt-5.3-codex", - sessionId: "session-1", - continuation: { - surface: "codex_app_server", - provider: "codex", - modelId: "openai/gpt-5.3-codex", - sessionId: "session-1", - threadId: "thread-1", - }, - usage: null, - }); - const fixture = await createFixture({ runtimeRun }); - const worker = fixture.createWorker({ name: "Continuation Worker" }); - const taskKey = fixture.workerTaskSessionService.deriveTaskKey({ - agentId: worker.id, - workflowRunId: "linear-run-1", - laneId: "lane-123", - linearIssueId: "issue-123", - summary: "Resume delegated work", - }); - - await fixture.heartbeat.triggerWakeup({ - agentId: worker.id, - reason: "assignment", - taskKey, - issueKey: "ABC-123", - prompt: "first wake", - context: { - runId: "linear-run-1", - laneId: "lane-123", - issueId: "issue-123", - issueTitle: "Resume delegated work", - }, - }); - - const persisted = fixture.workerTaskSessionService.getTaskSession(worker.id, worker.adapterType, taskKey); - expect((persisted?.payload as Record)?.continuity?.handle).toMatchObject({ - sessionId: "session-1", - threadId: "thread-1", - }); - - await fixture.heartbeat.triggerWakeup({ - agentId: worker.id, - reason: "assignment", - taskKey, - issueKey: "ABC-123", - prompt: "second wake", - context: { - runId: "linear-run-1", - laneId: "lane-123", - issueId: "issue-123", - issueTitle: "Resume delegated work", - }, - }); - - expect(runtimeRun).toHaveBeenCalledTimes(2); - const secondCall = (runtimeRun.mock.calls as Array)[1]?.[0] as Record; - expect(secondCall.laneId).toBe("lane-123"); - expect(secondCall.continuation).toMatchObject({ - sessionId: "session-1", - threadId: "thread-1", - surface: "codex_app_server", - }); - - await fixture.dispose(); - }); -}); diff --git a/apps/desktop/src/main/services/cto/workerRevisionService.test.ts b/apps/desktop/src/main/services/cto/workerRevisionService.test.ts deleted file mode 100644 index c89330abe..000000000 --- a/apps/desktop/src/main/services/cto/workerRevisionService.test.ts +++ /dev/null @@ -1,148 +0,0 @@ -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; -import { describe, expect, it } from "vitest"; -import { openKvDb } from "../state/kvDb"; -import { createWorkerAgentService } from "./workerAgentService"; -import { createWorkerRevisionService } from "./workerRevisionService"; - -function createLogger() { - return { - debug: () => {}, - info: () => {}, - warn: () => {}, - error: () => {}, - } as any; -} - -async function createFixture() { - const root = fs.mkdtempSync(path.join(os.tmpdir(), "ade-worker-revisions-")); - const adeDir = path.join(root, ".ade"); - fs.mkdirSync(adeDir, { recursive: true }); - const dbPath = path.join(adeDir, "ade.db"); - const db = await openKvDb(dbPath, createLogger()); - const projectId = "project-test"; - const workerAgentService = createWorkerAgentService({ - db, - projectId, - adeDir, - }); - const workerRevisionService = createWorkerRevisionService({ - db, - projectId, - workerAgentService, - }); - return { db, projectId, workerAgentService, workerRevisionService }; -} - -describe("workerRevisionService", () => { - it("records revisions on create and update with changed-key detection", async () => { - const fixture = await createFixture(); - const created = fixture.workerRevisionService.saveAgent( - { - name: "Worker A", - role: "engineer", - adapterType: "claude-local", - adapterConfig: { model: "sonnet" }, - }, - "tester" - ); - fixture.workerRevisionService.saveAgent( - { - id: created.id, - name: "Worker Alpha", - role: "engineer", - adapterType: "claude-local", - adapterConfig: { model: "opus" }, - capabilities: ["api"], - }, - "tester" - ); - - const revisions = fixture.workerRevisionService.listAgentRevisions(created.id, 10); - expect(revisions.length).toBeGreaterThanOrEqual(2); - expect(revisions.some((revision) => revision.changedKeys.some((key) => key.includes("name")))).toBe(true); - expect(revisions.some((revision) => revision.changedKeys.some((key) => key.includes("adapterConfig.model")))).toBe(true); - - fixture.db.close(); - }); - - it("rolls back to selected revision snapshot", async () => { - const fixture = await createFixture(); - const created = fixture.workerRevisionService.saveAgent( - { - name: "Rollback Worker", - role: "engineer", - adapterType: "claude-local", - adapterConfig: { model: "sonnet" }, - }, - "tester" - ); - - fixture.workerRevisionService.saveAgent( - { - id: created.id, - name: "Rollback Worker v2", - role: "engineer", - adapterType: "codex-local", - adapterConfig: { model: "gpt-5.3-codex" }, - }, - "tester" - ); - const revisions = fixture.workerRevisionService.listAgentRevisions(created.id, 10); - const revisionToRollback = revisions.find((entry) => entry.before.name === "Rollback Worker"); - expect(revisionToRollback).toBeTruthy(); - - const restored = fixture.workerRevisionService.rollbackAgentRevision( - created.id, - revisionToRollback!.id, - "tester" - ); - expect(restored.name).toBe("Rollback Worker"); - expect(restored.adapterType).toBe("claude-local"); - - fixture.db.close(); - }); - - it("blocks rollback when revision has redactions", async () => { - const fixture = await createFixture(); - const created = fixture.workerRevisionService.saveAgent( - { - name: "Redacted Worker", - role: "researcher", - adapterType: "openclaw-webhook", - adapterConfig: { - url: "https://example.com", - headers: { Authorization: "${env:OPENCLAW_WEBHOOK_TOKEN}" }, - }, - }, - "tester" - ); - - const redactedRevisionId = "rev-redacted"; - fixture.db.run( - ` - insert into worker_agent_revisions( - id, project_id, agent_id, before_json, after_json, changed_keys_json, had_redactions, actor, created_at - ) values(?, ?, ?, ?, ?, ?, ?, ?, ?) - `, - [ - redactedRevisionId, - fixture.projectId, - created.id, - JSON.stringify({ ...created, name: "__REDACTED__" }), - JSON.stringify(created), - JSON.stringify(["adapterConfig.headers.Authorization"]), - 1, - "tester", - new Date().toISOString(), - ] - ); - - expect(() => - fixture.workerRevisionService.rollbackAgentRevision(created.id, redactedRevisionId, "tester") - ).toThrow(/redacted/i); - - fixture.db.close(); - }); -}); diff --git a/apps/desktop/src/main/services/cto/workerTaskSessionService.test.ts b/apps/desktop/src/main/services/cto/workerTaskSessionService.test.ts deleted file mode 100644 index 9b829ce34..000000000 --- a/apps/desktop/src/main/services/cto/workerTaskSessionService.test.ts +++ /dev/null @@ -1,167 +0,0 @@ -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; -import { describe, expect, it } from "vitest"; -import { openKvDb } from "../state/kvDb"; -import { createWorkerTaskSessionService } from "./workerTaskSessionService"; - -function createLogger() { - return { - debug: () => {}, - info: () => {}, - warn: () => {}, - error: () => {}, - } as any; -} - -async function createFixture() { - const root = fs.mkdtempSync(path.join(os.tmpdir(), "ade-worker-task-sessions-")); - const adeDir = path.join(root, ".ade"); - fs.mkdirSync(adeDir, { recursive: true }); - const dbPath = path.join(adeDir, "ade.db"); - const db = await openKvDb(dbPath, createLogger()); - const projectId = "project-test"; - const service = createWorkerTaskSessionService({ - db, - projectId, - }); - return { db, projectId, service }; -} - -describe("workerTaskSessionService", () => { - it("derives deterministic task keys", async () => { - const fixture = await createFixture(); - const keyA = fixture.service.deriveTaskKey({ - agentId: "a1", - laneId: "lane-x", - missionId: "mission-1", - summary: "fix checkout bug", - }); - const keyB = fixture.service.deriveTaskKey({ - agentId: "a1", - laneId: "lane-x", - missionId: "mission-1", - summary: "fix checkout bug", - }); - expect(keyA).toBe(keyB); - expect(keyA.startsWith("task:")).toBe(true); - - const scopedKey = fixture.service.deriveTaskKey({ - agentId: "a1", - laneId: "lane-x", - workflowRunId: "linear-run-1", - linearIssueId: "issue-1", - summary: "fix checkout bug", - }); - expect(scopedKey).not.toBe(keyA); - fixture.db.close(); - }); - - it("persists and merges task session continuity by (agentId, adapterType, taskKey)", async () => { - const fixture = await createFixture(); - const taskKey = fixture.service.deriveTaskKey({ - agentId: "worker-1", - chatSessionId: "chat-123", - summary: "investigate flaky test", - }); - const created = fixture.service.ensureTaskSession({ - agentId: "worker-1", - adapterType: "codex-local", - taskKey, - payload: { - continuity: { - handle: { - surface: "codex_app_server", - sessionId: "chat-123", - threadId: "thread-1", - }, - }, - }, - }); - expect(created.taskKey).toBe(taskKey); - - const resumed = fixture.service.getTaskSession("worker-1", "codex-local", taskKey); - expect(resumed?.payload).toEqual({ - continuity: { - handle: { - surface: "codex_app_server", - sessionId: "chat-123", - threadId: "thread-1", - }, - }, - }); - - fixture.service.ensureTaskSession({ - agentId: "worker-1", - adapterType: "codex-local", - taskKey, - payload: { - continuity: { - handle: { - surface: "codex_app_server", - threadId: "thread-2", - }, - scope: { - runId: "linear-run-2", - }, - }, - wake: { - lastRunId: "wake-2", - }, - }, - }); - const updated = fixture.service.getTaskSession("worker-1", "codex-local", taskKey); - expect(updated?.payload).toEqual({ - continuity: { - handle: { - surface: "codex_app_server", - sessionId: "chat-123", - threadId: "thread-2", - }, - scope: { - runId: "linear-run-2", - }, - }, - wake: { - lastRunId: "wake-2", - }, - }); - - fixture.db.close(); - }); - - it("clears task sessions with targeted and bulk behavior", async () => { - const fixture = await createFixture(); - const keyOne = fixture.service.deriveTaskKey({ agentId: "worker-1", summary: "one" }); - const keyTwo = fixture.service.deriveTaskKey({ agentId: "worker-1", summary: "two" }); - fixture.service.ensureTaskSession({ - agentId: "worker-1", - adapterType: "process", - taskKey: keyOne, - payload: { run: 1 }, - }); - fixture.service.ensureTaskSession({ - agentId: "worker-1", - adapterType: "process", - taskKey: keyTwo, - payload: { run: 2 }, - }); - - const clearedOne = fixture.service.clearAgentTaskSession({ - agentId: "worker-1", - adapterType: "process", - taskKey: keyOne, - }); - expect(clearedOne).toBe(1); - expect(fixture.service.getTaskSession("worker-1", "process", keyOne)?.payload).toEqual({}); - - const clearedAll = fixture.service.clearAgentTaskSession({ - agentId: "worker-1", - adapterType: "process", - }); - expect(clearedAll).toBeGreaterThanOrEqual(1); - expect(fixture.service.getTaskSession("worker-1", "process", keyTwo)?.payload).toEqual({}); - - fixture.db.close(); - }); -}); diff --git a/apps/desktop/src/main/services/orchestrator/baseOrchestratorAdapter.test.ts b/apps/desktop/src/main/services/orchestrator/baseOrchestratorAdapter.test.ts deleted file mode 100644 index 5cd8e0e60..000000000 --- a/apps/desktop/src/main/services/orchestrator/baseOrchestratorAdapter.test.ts +++ /dev/null @@ -1,273 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { buildFullPrompt } from "./baseOrchestratorAdapter"; - -describe("buildFullPrompt", () => { - it("injects shared facts, mission memory, and project knowledge into worker prompts", () => { - const memoryService = { - getMemoryBudget: (_projectId: string, _level: string, opts?: { scope?: string; scopeOwnerId?: string | null }) => { - return [ - { - id: "mem-project-1", - category: "decision", - content: "Project-wide decisions should stay visible across runs.", - importance: "high", - }, - ]; - }, - } as any; - - const prompt = buildFullPrompt( - { - run: { - id: "run-1", - missionId: "mission-1", - metadata: { - missionGoal: "Stabilize W6 memory behavior", - }, - } as any, - step: { - id: "step-1", - title: "Fix mission memory scoping", - stepKey: "fix-memory", - laneId: "lane-1", - metadata: {}, - dependencyStepIds: [], - joinPolicy: "all_success", - } as any, - attempt: {} as any, - allSteps: [], - contextProfile: {} as any, - laneExport: null, - projectExport: { - content: "Project context body", - } as any, - docsRefs: [], - fullDocs: [], - createTrackedSession: async () => ({ ptyId: "pty-1", sessionId: "session-1" }), - memoryBriefing: { - l0: { title: "Project Memory", entries: [] }, - l1: { - title: "Relevant Project Knowledge", - entries: [ - { - id: "mem-project-1", - category: "decision", - content: "Project-wide decisions should stay visible across runs.", - importance: "high", - }, - ], - }, - l2: { title: "Agent Memory", entries: [] }, - mission: { - title: "Mission Memory", - entries: [ - { - id: "mem-mission-1", - category: "pattern", - content: "Mission memory stays scoped to the current run.", - importance: "medium", - }, - ], - }, - sharedFacts: [ - { - id: "mem-mission-1", - factType: "api_pattern", - content: "Mission memory stays scoped to the current run.", - createdAt: "2026-03-05T12:00:00.000Z", - }, - ], - usedProcedureIds: [], - usedDigestIds: [], - usedMissionMemoryIds: ["mem-mission-1"], - } as any, - }, - "opencode", - { - memoryService, - projectId: "project-1", - } - ); - - expect(prompt.prompt).toContain("## Shared Team Knowledge"); - expect(prompt.prompt).toContain("## Mission Memory"); - expect(prompt.prompt).toContain("Mission memory stays scoped to the current run."); - expect(prompt.prompt).toContain("## Project Knowledge"); - expect(prompt.prompt).toContain("Project-wide decisions should stay visible across runs."); - }); - - it("routes read-only workers to ADE result reporting instead of file writes", () => { - const prompt = buildFullPrompt( - { - run: { - id: "run-1", - missionId: "mission-1", - metadata: { - missionGoal: "Research the sidebar flow", - }, - } as any, - step: { - id: "step-1", - title: "Plan sidebar changes", - stepKey: "plan-sidebar", - laneId: "lane-1", - metadata: { - readOnlyExecution: true, - }, - dependencyStepIds: [], - joinPolicy: "all_success", - } as any, - attempt: {} as any, - allSteps: [], - contextProfile: {} as any, - laneExport: null, - projectExport: { - content: "Project context body", - } as any, - docsRefs: [], - fullDocs: [], - createTrackedSession: async () => ({ ptyId: "pty-1", sessionId: "session-1" }), - }, - "opencode", - {} - ); - - expect(prompt.prompt).toContain("ALWAYS call `report_result`"); - expect(prompt.prompt).toContain("This step cannot write files."); - expect(prompt.prompt).not.toContain("PROGRESS CHECKPOINTING:"); - expect(prompt.prompt).not.toContain("STEP OUTPUT FILE:"); - }); - - it("handles partial briefing structures without throwing", () => { - const prompt = buildFullPrompt( - { - run: { - id: "run-1", - missionId: "mission-1", - metadata: { - missionGoal: "Recover the mission landing path", - }, - } as any, - step: { - id: "step-1", - title: "Recover landing flow", - stepKey: "recover-landing", - laneId: "lane-1", - metadata: {}, - dependencyStepIds: [], - joinPolicy: "all_success", - } as any, - attempt: {} as any, - allSteps: [], - contextProfile: {} as any, - laneExport: null, - projectExport: { - content: "Project context body", - } as any, - docsRefs: [], - fullDocs: [], - createTrackedSession: async () => ({ ptyId: "pty-1", sessionId: "session-1" }), - memoryBriefing: { - mission: { - title: "Mission Memory", - entries: [ - { - id: "mission-memory-1", - category: "note", - content: "Mission landing failures should point to the focused intervention.", - importance: "high", - }, - ], - }, - l1: { - title: "Project Knowledge", - }, - } as any, - }, - "opencode", - {} - ); - - expect(prompt.prompt).toContain("Mission landing failures should point to the focused intervention."); - expect(prompt.prompt).toContain("## Mission Memory"); - }); - - it("keeps checkpoint and step output instructions for writable workers", () => { - const prompt = buildFullPrompt( - { - run: { - id: "run-1", - missionId: "mission-1", - metadata: { - missionGoal: "Implement the sidebar flow", - }, - } as any, - step: { - id: "step-1", - title: "Implement sidebar changes", - stepKey: "implement-sidebar", - laneId: "lane-1", - metadata: {}, - dependencyStepIds: [], - joinPolicy: "all_success", - } as any, - attempt: {} as any, - allSteps: [], - contextProfile: {} as any, - laneExport: null, - projectExport: { - content: "Project context body", - } as any, - docsRefs: [], - fullDocs: [], - createTrackedSession: async () => ({ ptyId: "pty-1", sessionId: "session-1" }), - }, - "opencode", - {} - ); - - expect(prompt.prompt).toContain("ALWAYS call `report_result`"); - expect(prompt.prompt).toContain("PROGRESS CHECKPOINTING:"); - expect(prompt.prompt).toContain("STEP OUTPUT FILE:"); - }); - - it("removes ADE mission-tool instructions for in-process workers", () => { - const prompt = buildFullPrompt( - { - run: { - id: "run-1", - missionId: "mission-1", - metadata: { - missionGoal: "Implement the sidebar flow", - }, - } as any, - step: { - id: "step-1", - title: "Implement sidebar changes", - stepKey: "implement-sidebar", - laneId: "lane-1", - metadata: {}, - dependencyStepIds: [], - joinPolicy: "all_success", - } as any, - attempt: {} as any, - allSteps: [], - contextProfile: {} as any, - laneExport: null, - projectExport: { - content: "Project context body", - } as any, - docsRefs: [], - fullDocs: [], - createTrackedSession: async () => ({ ptyId: "pty-1", sessionId: "session-1" }), - }, - "opencode", - { workerRuntime: "in_process" } - ); - - expect(prompt.prompt).toContain("This worker is running in-process."); - expect(prompt.prompt).toContain("RUNTIME LIMITS:"); - expect(prompt.prompt).not.toContain("ALWAYS call `report_result`"); - expect(prompt.prompt).not.toContain("ADE TOOLING:"); - }); -}); diff --git a/apps/desktop/src/main/services/orchestrator/hardeningMissions.test.ts b/apps/desktop/src/main/services/orchestrator/hardeningMissions.test.ts deleted file mode 100644 index b208d0395..000000000 --- a/apps/desktop/src/main/services/orchestrator/hardeningMissions.test.ts +++ /dev/null @@ -1,563 +0,0 @@ -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; -import { spawnSync } from "node:child_process"; -import { describe, expect, it, vi } from "vitest"; -import { classifyBlockingWarnings } from "./orchestratorQueries"; -import type { PackExport, PackType } from "../../../shared/types"; -import { createOrchestratorService } from "./orchestratorService"; -import { openKvDb } from "../state/kvDb"; - -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- - -function createLogger() { - return { debug: () => {}, info: () => {}, warn: () => {}, error: () => {} } as any; -} - -function runGit(cwd: string, args: string[]) { - const result = spawnSync("git", ["-C", cwd, ...args], { encoding: "utf8" }); - if (result.status === 0) return; - throw new Error(`git ${args.join(" ")} failed (${result.status}): ${(result.stderr ?? "").trim()}`); -} - -function buildExport(packKey: string, packType: PackType, level: "lite" | "standard" | "deep"): PackExport { - return { - packKey, - packType, - level, - header: {} as any, - content: `${packKey}:${level}`, - approxTokens: 32, - maxTokens: 500, - truncated: false, - warnings: [], - clipReason: null, - omittedSections: null, - }; -} - -async function createFixture() { - const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-hardening-")); - fs.mkdirSync(path.join(projectRoot, "docs", "architecture"), { recursive: true }); - fs.writeFileSync(path.join(projectRoot, "docs", "PRD.md"), "# PRD\n\nContext baseline\n", "utf8"); - fs.writeFileSync(path.join(projectRoot, "docs", "architecture", "CONTEXT_CONTRACT.md"), "# Context Contract\n", "utf8"); - - const db = await openKvDb(path.join(projectRoot, "ade.db"), createLogger()); - const projectId = "proj-1"; - const laneId = "lane-1"; - const missionId = "mission-1"; - const now = "2026-03-09T00:00:00.000Z"; - - db.run( - `insert into projects(id, root_path, display_name, default_base_ref, created_at, last_opened_at) values (?, ?, ?, ?, ?, ?)`, - [projectId, projectRoot, "Test", "main", now, now] - ); - - db.run( - `insert into lanes(id, project_id, name, description, lane_type, base_ref, branch_ref, worktree_path, attached_root_path, is_edit_protected, parent_lane_id, color, icon, tags_json, status, created_at, archived_at) - values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, - [laneId, projectId, "Lane 1", null, "worktree", "main", "feature/lane-1", projectRoot, null, 0, null, null, null, null, "active", now, null] - ); - - db.run( - `insert into missions(id, project_id, lane_id, title, prompt, status, priority, execution_mode, target_machine_id, outcome_summary, last_error, metadata_json, created_at, updated_at, started_at, completed_at) - values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, - [missionId, projectId, laneId, "Hardening Test Mission", "Test mission.", "queued", "normal", "local", null, null, null, null, now, now, null, null] - ); - - const ptyCreateCalls: Array> = []; - const ptyService = { - create: async (args: Record) => { - ptyCreateCalls.push(args); - const index = ptyCreateCalls.length; - return { ptyId: `pty-${index}`, sessionId: `session-${index}` }; - }, - } as any; - - const packService = { - getLaneExport: async ({ laneId: targetLaneId, level }: { laneId: string; level: string }) => - buildExport(`lane:${targetLaneId}`, "lane", level as any), - getProjectExport: async ({ level }: { level: string }) => buildExport("project", "project", level as any), - refreshMissionPack: async ({ missionId: targetMissionId }: { missionId: string }) => ({ - packKey: `mission:${targetMissionId}`, - packType: "mission", - path: path.join(projectRoot, ".ade", "packs", "missions", targetMissionId, "mission_pack.md"), - exists: true, - deterministicUpdatedAt: now, - narrativeUpdatedAt: null, - lastHeadSha: null, - versionId: `mission-${targetMissionId}-v1`, - versionNumber: 1, - contentHash: `hash-mission-${targetMissionId}`, - metadata: null, - body: "# Mission Pack", - }), - } as any; - - const service = createOrchestratorService({ - db, - projectId, - projectRoot, - conflictService: undefined, - ptyService, - projectConfigService: null as any, - aiIntegrationService: null as any, - memoryService: null as any, - }); - - return { db, service, projectId, projectRoot, laneId, missionId, ptyCreateCalls, dispose: () => db.close() }; -} - -// --------------------------------------------------------------------------- -// classifyBlockingWarnings — unit tests -// --------------------------------------------------------------------------- - -describe("classifyBlockingWarnings", () => { - it("detects sandbox-blocked writes as blocking", () => { - const result = classifyBlockingWarnings({ - warnings: ["Tool 'Write' failed: PreToolUse:Write hook error ... SANDBOX BLOCKED: File path outside sandbox: /etc/sensitive/foo"], - summary: null, - }); - expect(result.hasBlockingFailure).toBe(true); - expect(result.category).toBe("sandbox_block"); - }); - - it("treats sandbox blocks to ~/.claude/plans/ as blocking", () => { - const result = classifyBlockingWarnings({ - warnings: ["Tool 'Write' failed: PreToolUse:Write hook error ... SANDBOX BLOCKED: File path outside sandbox: /Users/admin/.claude/plans/foo"], - summary: null, - }); - expect(result.hasBlockingFailure).toBe(true); - expect(result.category).toBe("sandbox_block"); - }); - - it("detects tool startup failures as blocking", () => { - const result = classifyBlockingWarnings({ - warnings: ["tool startup failed for external connector"], - summary: null, - }); - expect(result.hasBlockingFailure).toBe(true); - expect(result.category).toBe("tool_failure"); - }); - - it("detects permission denied as blocking", () => { - const result = classifyBlockingWarnings({ - warnings: ["EACCES: permission denied, open '/etc/passwd'"], - summary: null, - }); - expect(result.hasBlockingFailure).toBe(true); - expect(result.category).toBe("permission_denied"); - }); - - it("detects missing auth as blocking", () => { - const result = classifyBlockingWarnings({ - warnings: ["authentication required for API access"], - summary: null, - }); - expect(result.hasBlockingFailure).toBe(true); - expect(result.category).toBe("missing_auth"); - }); - - it("detects blocking patterns in summary text", () => { - const result = classifyBlockingWarnings({ - warnings: [], - summary: "Attempt completed but SANDBOX BLOCKED on critical write operation", - }); - expect(result.hasBlockingFailure).toBe(true); - expect(result.category).toBe("sandbox_block"); - }); - - it("excludes provider connector auth warnings (claude.ai Gmail:needs-auth)", () => { - const result = classifyBlockingWarnings({ - warnings: ["claude.ai Gmail:needs-auth", "claude.ai Google Calendar:needs-auth"], - summary: null, - }); - expect(result.hasBlockingFailure).toBe(false); - expect(result.category).toBeNull(); - }); - - it("excludes provider connector Slack auth noise", () => { - const result = classifyBlockingWarnings({ - warnings: ["claude.ai Slack:needs-auth"], - summary: null, - }); - expect(result.hasBlockingFailure).toBe(false); - }); - - it("does not treat normal warnings as blocking", () => { - const result = classifyBlockingWarnings({ - warnings: ["Step completed with minor formatting issues", "Output truncated at 1000 chars"], - summary: "Worker completed implementation successfully", - }); - expect(result.hasBlockingFailure).toBe(false); - }); - - it("detects blocking when mixed with provider connector noise", () => { - const result = classifyBlockingWarnings({ - warnings: [ - "claude.ai Gmail:needs-auth", - "Tool 'Write' failed: SANDBOX BLOCKED on /etc/sensitive/config", - "claude.ai Google Drive:needs-auth", - ], - summary: null, - }); - expect(result.hasBlockingFailure).toBe(true); - expect(result.category).toBe("sandbox_block"); - }); - - it("blocks when mixed noise includes ~/.claude/plans/ sandbox blocks", () => { - const result = classifyBlockingWarnings({ - warnings: [ - "claude.ai Gmail:needs-auth", - "Tool 'Write' failed: SANDBOX BLOCKED on /Users/admin/.claude/plans/x", - "claude.ai Google Drive:needs-auth", - ], - summary: null, - }); - expect(result.hasBlockingFailure).toBe(true); - expect(result.category).toBe("sandbox_block"); - }); - - it("detects PreToolUse hook errors with sandbox content as sandbox_block", () => { - // "sandbox blocked" matches the sandbox_block pattern before tool_failure - const result = classifyBlockingWarnings({ - warnings: ["PreToolUse:Write hook error: sandbox blocked this write"], - summary: null, - }); - expect(result.hasBlockingFailure).toBe(true); - expect(result.category).toBe("sandbox_block"); - }); - - it("detects pure PreToolUse hook errors as tool_failure", () => { - const result = classifyBlockingWarnings({ - warnings: ["PreToolUse:Read hook error: configuration invalid"], - summary: null, - }); - expect(result.hasBlockingFailure).toBe(true); - expect(result.category).toBe("tool_failure"); - }); -}); - -// --------------------------------------------------------------------------- -// Soft-failure override in completeAttempt — integration tests -// --------------------------------------------------------------------------- - -describe("soft-failure override in completeAttempt", () => { - it("overrides succeeded attempt to failed when sandbox block warning is present", async () => { - const fixture = await createFixture(); - try { - const run = fixture.service.startRun({ - missionId: fixture.missionId, - steps: [{ stepKey: "impl-1", title: "Implementation", stepIndex: 0,laneId: fixture.laneId }], - }); - - const step = run.steps[0]!; - fixture.service.tick({ runId: run.run.id }); - - const attempt = await fixture.service.startAttempt({ - runId: run.run.id, - stepId: step.id, - ownerId: "test-owner", - executorKind: "cli", - }); - - const completed = await fixture.service.completeAttempt({ - attemptId: attempt.id, - status: "succeeded", - result: { - schema: "ade.orchestratorAttempt.v1" as const, - success: true, - summary: "Completed but sandbox blocked", - outputs: null, - warnings: ["Tool 'Write' failed: PreToolUse:Write hook error SANDBOX BLOCKED: File path outside sandbox"], - sessionId: null, - trackedSession: false, - }, - }); - - // The attempt should be recorded as failed, not succeeded - expect(completed.status).toBe("failed"); - expect(completed.errorClass).toBe("soft_success_blocking_failure"); - - // The step should be failed/blocked, not succeeded - const graph = fixture.service.getRunGraph({ runId: run.run.id }); - const updatedStep = graph.steps.find((s) => s.id === step.id); - expect(updatedStep?.status).toBe("failed"); - } finally { - fixture.dispose(); - } - }); - - it("does not override succeeded attempt when warnings are only provider connector noise", async () => { - const fixture = await createFixture(); - try { - const run = fixture.service.startRun({ - missionId: fixture.missionId, - steps: [{ stepKey: "impl-1", title: "Implementation", stepIndex: 0,laneId: fixture.laneId }], - }); - - const step = run.steps[0]!; - fixture.service.tick({ runId: run.run.id }); - - const attempt = await fixture.service.startAttempt({ - runId: run.run.id, - stepId: step.id, - ownerId: "test-owner", - executorKind: "cli", - }); - - const completed = await fixture.service.completeAttempt({ - attemptId: attempt.id, - status: "succeeded", - result: { - schema: "ade.orchestratorAttempt.v1" as const, - success: true, - summary: "Implementation complete", - outputs: null, - warnings: ["claude.ai Gmail:needs-auth", "claude.ai Google Calendar:needs-auth"], - sessionId: null, - trackedSession: false, - }, - }); - - // Should remain succeeded because provider connector noise should be ignored. - expect(completed.status).toBe("succeeded"); - } finally { - fixture.dispose(); - } - }); - - it("overrides transcript-derived succeeded attempt to failed when summary shows sandbox block", async () => { - const fixture = await createFixture(); - try { - const run = fixture.service.startRun({ - missionId: fixture.missionId, - steps: [{ stepKey: "impl-1", title: "Implementation", stepIndex: 0, laneId: fixture.laneId }], - }); - - const step = run.steps[0]!; - fixture.service.tick({ runId: run.run.id }); - - const attempt = await fixture.service.startAttempt({ - runId: run.run.id, - stepId: step.id, - ownerId: "test-owner", - executorKind: "cli", - }); - - // ~/.claude/plans/ sandbox blocks are now treated as benign (ExitPlanMode is expected noise). - // Use a non-plan path for the blocking test, then verify plan path stays succeeded. - const transcriptPath = path.join(fixture.projectRoot, "sandbox-blocked.log"); - fs.writeFileSync( - transcriptPath, - "Tool 'Write' failed: PreToolUse:Write hook error: [/Users/admin/.claude/hooks/sandbox.sh]: SANDBOX BLOCKED: File path outside sandbox: /etc/sensitive/production.conf\n", - "utf8" - ); - fixture.db.run( - `update orchestrator_attempts set metadata_json = ? where id = ?`, - [JSON.stringify({ transcriptPath }), attempt.id] - ); - - const completed = await fixture.service.completeAttempt({ - attemptId: attempt.id, - status: "succeeded", - }); - - expect(completed.status).toBe("failed"); - expect(completed.errorClass).toBe("soft_success_blocking_failure"); - - const graph = fixture.service.getRunGraph({ runId: run.run.id }); - const updatedStep = graph.steps.find((s) => s.id === step.id); - expect(updatedStep?.status).toBe("failed"); - } finally { - fixture.dispose(); - } - }); -}); - -// --------------------------------------------------------------------------- -// Pause model hardening -// --------------------------------------------------------------------------- - -describe("pause model hardening", () => { - it("paused run does not advance or spawn new workers via autopilot", async () => { - const fixture = await createFixture(); - try { - const run = fixture.service.startRun({ - missionId: fixture.missionId, - steps: [ - { stepKey: "step-a", title: "Step A", stepIndex: 0, laneId: fixture.laneId }, - { stepKey: "step-b", title: "Step B", stepIndex: 1, laneId: fixture.laneId }, - ], - }); - - fixture.service.tick({ runId: run.run.id }); - - // Pause the run - fixture.service.pauseRun({ runId: run.run.id, reason: "User requested pause" }); - - const pausedRun = fixture.service.getRunGraph({ runId: run.run.id }); - expect(pausedRun.run.status).toBe("paused"); - - // Autopilot should return 0 and not start any attempts - const started = await fixture.service.startReadyAutopilotAttempts({ runId: run.run.id }); - expect(started).toBe(0); - - // No PTY sessions should have been created - expect(fixture.ptyCreateCalls).toHaveLength(0); - } finally { - fixture.dispose(); - } - }); - - it("startAttempt throws when run is paused", async () => { - const fixture = await createFixture(); - try { - const run = fixture.service.startRun({ - missionId: fixture.missionId, - steps: [{ stepKey: "step-a", title: "Step A", stepIndex: 0,laneId: fixture.laneId }], - }); - - fixture.service.tick({ runId: run.run.id }); - fixture.service.pauseRun({ runId: run.run.id, reason: "Testing pause" }); - - const step = run.steps[0]!; - await expect( - fixture.service.startAttempt({ runId: run.run.id, stepId: step.id, ownerId: "test-owner", executorKind: "cli" }) - ).rejects.toThrow(/paused/i); - } finally { - fixture.dispose(); - } - }); - - it("paused run survives tick without state change", async () => { - const fixture = await createFixture(); - try { - const run = fixture.service.startRun({ - missionId: fixture.missionId, - steps: [{ stepKey: "step-a", title: "Step A", stepIndex: 0,laneId: fixture.laneId }], - }); - - fixture.service.pauseRun({ runId: run.run.id, reason: "Freeze" }); - - // Multiple ticks should not change the paused state - fixture.service.tick({ runId: run.run.id }); - fixture.service.tick({ runId: run.run.id }); - - const graph = fixture.service.getRunGraph({ runId: run.run.id }); - expect(graph.run.status).toBe("paused"); - } finally { - fixture.dispose(); - } - }); - - it("resumeRun correctly transitions paused run back to active", async () => { - const fixture = await createFixture(); - try { - const run = fixture.service.startRun({ - missionId: fixture.missionId, - steps: [{ stepKey: "step-a", title: "Step A", stepIndex: 0,laneId: fixture.laneId }], - }); - - fixture.service.tick({ runId: run.run.id }); - fixture.service.pauseRun({ runId: run.run.id, reason: "Pause" }); - - const paused = fixture.service.getRunGraph({ runId: run.run.id }); - expect(paused.run.status).toBe("paused"); - - fixture.service.resumeRun({ runId: run.run.id }); - - const resumed = fixture.service.getRunGraph({ runId: run.run.id }); - expect(resumed.run.status).toBe("active"); - } finally { - fixture.dispose(); - } - }); -}); - -// --------------------------------------------------------------------------- -// MissionRunPanel helpers — pure function tests -// --------------------------------------------------------------------------- - -describe("MissionRunPanel attention states", () => { - it("selectOpenInterventions returns only open interventions", () => { - const interventions = [ - { id: "iv-1", status: "open", interventionType: "manual_input", title: "Question 1" }, - { id: "iv-2", status: "resolved", interventionType: "manual_input", title: "Question 2" }, - { id: "iv-3", status: "open", interventionType: "failed_step", title: "Step failed" }, - { id: "iv-4", status: "dismissed", interventionType: "policy_block", title: "Policy" }, - ]; - - const open = interventions.filter((iv) => iv.status === "open"); - expect(open).toHaveLength(2); - expect(open.map((iv) => iv.id)).toEqual(["iv-1", "iv-3"]); - }); - - it("blocking interventions are distinguished from non-blocking", () => { - const blockingIntervention = { - metadata: { canProceedWithoutAnswer: false, blocking: true, category: "user_input" }, - }; - const nonBlockingIntervention = { - metadata: { canProceedWithoutAnswer: true, blocking: false, category: "user_input" }, - }; - - expect(blockingIntervention.metadata.blocking).toBe(true); - expect(nonBlockingIntervention.metadata.blocking).toBe(false); - expect(blockingIntervention.metadata.canProceedWithoutAnswer).toBe(false); - expect(nonBlockingIntervention.metadata.canProceedWithoutAnswer).toBe(true); - }); -}); - -// --------------------------------------------------------------------------- -// Provider connector noise vs real failures -// --------------------------------------------------------------------------- - -describe("provider connector noise filtering", () => { - it("gmail auth noise does not trigger blocking classification", () => { - const result = classifyBlockingWarnings({ - warnings: ["claude.ai Gmail:needs-auth"], - summary: null, - }); - expect(result.hasBlockingFailure).toBe(false); - }); - - it("google calendar auth noise does not trigger blocking classification", () => { - const result = classifyBlockingWarnings({ - warnings: ["claude.ai Google Calendar:needs-auth"], - summary: null, - }); - expect(result.hasBlockingFailure).toBe(false); - }); - - it("google drive auth noise does not trigger blocking classification", () => { - const result = classifyBlockingWarnings({ - warnings: ["claude.ai Google Drive:needs-auth"], - summary: null, - }); - expect(result.hasBlockingFailure).toBe(false); - }); - - it("ADE-internal needs-auth without claude.ai prefix IS blocking", () => { - const result = classifyBlockingWarnings({ - warnings: ["external connector myserver:needs-auth - cannot continue"], - summary: null, - }); - expect(result.hasBlockingFailure).toBe(true); - expect(result.category).toBe("missing_auth"); - }); - - it("real tool failure mixed with external noise is still blocking", () => { - const result = classifyBlockingWarnings({ - warnings: [ - "claude.ai Gmail:needs-auth", - "claude.ai Slack:needs-auth", - "tool 'Write' failed with EPERM", - ], - summary: null, - }); - expect(result.hasBlockingFailure).toBe(true); - expect(result.category).toBe("permission_denied"); - }); -}); diff --git a/apps/desktop/src/main/services/orchestrator/knowledgeConflictsBrowserCto.test.ts b/apps/desktop/src/main/services/orchestrator/knowledgeConflictsBrowserCto.test.ts deleted file mode 100644 index c169add86..000000000 --- a/apps/desktop/src/main/services/orchestrator/knowledgeConflictsBrowserCto.test.ts +++ /dev/null @@ -1,874 +0,0 @@ -// --------------------------------------------------------------------------- -// Consolidated M5 tests: -// (1) KNOWLEDGE: mission-memory-derived shared facts, write-gate dedup, search, scope isolation -// (2) CONFLICTS: runPrediction, simulateMerge, external resolver lifecycle, rebase detection, chips -// (3) PR: createIntegrationPr, failed merge cleanup, createQueuePrs, stack landing, finalization policy -// (4) AGENT-BROWSER: PhaseCard capabilities, browser_verification closeout, RoleToolProfile -// (5) ARTIFACTS: screenshot/video types, report_result media, queryable by missionId, closeout checks -// (6) CTO: updateCoreMemory version, retrospective, session log dual persistence, pattern in reconstruction, trends, stats -// (7) CROSS-AREA: parallel→conflict→PR, agent-browser artifact→closeout, retrospective→CTO→next, validation blocks completion, -// shared fact→search, budget blocks spawns, steering→intervention→UI -// --------------------------------------------------------------------------- - -import { describe, expect, it, vi, beforeEach } from "vitest"; -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; -import { randomUUID } from "node:crypto"; -import { openKvDb } from "../../services/state/kvDb"; -import { createMemoryService } from "../../services/memory/memoryService"; -import { createMemoryBriefingService } from "../../services/memory/memoryBriefingService"; -import { createCtoStateService } from "../../services/cto/ctoStateService"; -import type { - PhaseCard, - MissionCloseoutRequirementKey, - OrchestratorArtifactKind, - RoleToolProfile, - MissionFinalizationPolicyKind, - OrchestratorRetrospectiveTrend, - OrchestratorRetrospectivePatternStat, - MissionCloseoutRequirement, - MissionCloseoutRequirementStatus, -} from "../../../shared/types"; -import { createCoordinatorToolSet } from "./coordinatorTools"; -import { validateRunCompletion, evaluateRunCompletionFromPhases } from "./executionPolicy"; - -function createLogger() { - return { debug: () => {}, info: () => {}, warn: () => {}, error: () => {} } as any; -} - -async function createMemoryFixture() { - const root = fs.mkdtempSync(path.join(os.tmpdir(), "ade-knowledge-")); - const adeDir = path.join(root, ".ade"); - fs.mkdirSync(adeDir, { recursive: true }); - const dbPath = path.join(adeDir, "ade.db"); - const db = await openKvDb(dbPath, createLogger()); - const memoryService = createMemoryService(db); - // Seed a project row to satisfy FK constraints on unified_memories - const seedProject = (projectId: string) => { - try { - db.run( - `INSERT OR IGNORE INTO projects(id, root_path, display_name, default_base_ref, created_at, last_opened_at) VALUES (?, ?, ?, ?, ?, ?)`, - [projectId, root, "test-project", "main", new Date().toISOString(), new Date().toISOString()] - ); - } catch { /* might not have projects table yet */ } - }; - return { root, adeDir, db, memoryService, seedProject }; -} - -function seedOrchestratorRun(db: any, projectId: string, missionId: string, runId: string) { - const now = new Date().toISOString(); - db.run( - `INSERT OR IGNORE INTO projects(id, root_path, display_name, default_base_ref, created_at, last_opened_at) VALUES (?, ?, ?, ?, ?, ?)`, - [projectId, `/tmp/test-${projectId}`, "test", "main", now, now] - ); - db.run( - `INSERT OR IGNORE INTO missions(id, project_id, title, prompt, status, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)`, - [missionId, projectId, "test-mission", "test prompt", "in_progress", now, now] - ); - db.run( - `INSERT OR IGNORE INTO orchestrator_runs(id, project_id, mission_id, status, scheduler_state, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)`, - [runId, projectId, missionId, "active", "idle", now, now] - ); -} - -function seedOrchestratorStep(db: any, runId: string, stepId: string, projectId = "proj-1") { - const now = new Date().toISOString(); - db.run( - `INSERT OR IGNORE INTO orchestrator_steps(id, run_id, project_id, step_key, step_index, title, status, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, - [stepId, runId, projectId, "step-1", 0, "Test Step", "running", now, now] - ); -} - -function seedOrchestratorAttempt(db: any, runId: string, stepId: string, attemptId: string, projectId = "proj-1") { - const now = new Date().toISOString(); - db.run( - `INSERT OR IGNORE INTO orchestrator_attempts(id, run_id, step_id, project_id, attempt_number, status, executor_kind, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, - [attemptId, runId, stepId, projectId, 1, "running", "opencode", now] - ); -} - -async function createCtoFixture() { - const root = fs.mkdtempSync(path.join(os.tmpdir(), "ade-cto-")); - const adeDir = path.join(root, ".ade"); - fs.mkdirSync(adeDir, { recursive: true }); - const dbPath = path.join(adeDir, "ade.db"); - const db = await openKvDb(dbPath, createLogger()); - const projectId = `project-${randomUUID()}`; - const ctoService = createCtoStateService({ db, projectId, adeDir }); - return { root, adeDir, db, projectId, ctoService }; -} - -// ── Helper: build PhaseCard with capabilities ────────────────────────────── -function makePhaseCard(overrides?: Partial): PhaseCard { - return { - id: `phase-${randomUUID()}`, - phaseKey: "development", - name: "Development", - description: "Implement the feature", - instructions: "Do it", - model: { modelId: "anthropic/claude-sonnet-4-6", thinkingLevel: "medium" }, - budget: {}, - orderingConstraints: {}, - askQuestions: { enabled: false }, - validationGate: { tier: "none", required: false }, - isBuiltIn: false, - isCustom: false, - position: 0, - createdAt: "2026-03-01T00:00:00.000Z", - updatedAt: "2026-03-01T00:00:00.000Z", - ...overrides, - }; -} - -// ═══════════════════════════════════════════════════════════════════════════ -// (1) KNOWLEDGE — VAL-ENH-020..024 -// ═══════════════════════════════════════════════════════════════════════════ -describe("Knowledge: shared facts and memory", () => { - // VAL-ENH-020 - it("derives shared team knowledge from mission memories with stable ids and timestamps", async () => { - const { memoryService, db, seedProject } = await createMemoryFixture(); - const projectId = "proj-shared-facts"; - const missionId = "mission-1"; - seedProject(projectId); - memoryService.writeMemory({ - projectId, - scope: "mission", - scopeOwnerId: missionId, - category: "pattern", - content: "REST endpoints use /api/v2 prefix", - importance: "medium", - sourceType: "system", - sourceRunId: "run-1", - }); - - const briefingService = createMemoryBriefingService({ memoryService }); - const briefing = await briefingService.buildBriefing({ - projectId, - missionId, - runId: "run-1", - mode: "mission_worker", - }); - - expect(briefing.sharedFacts).toHaveLength(1); - expect(briefing.sharedFacts[0]?.id).toBeTruthy(); - expect(briefing.sharedFacts[0]?.factType).toBe("api_pattern"); - expect(briefing.sharedFacts[0]?.content).toBe("REST endpoints use /api/v2 prefix"); - expect(briefing.sharedFacts[0]?.createdAt).toBeTruthy(); - db.close(); - }); - - // VAL-ENH-021 - it("maps mission-memory categories into shared fact types for prompt assembly", async () => { - const { memoryService, db, seedProject } = await createMemoryFixture(); - const projectId = "proj-shared-fact-types"; - const missionId = "mission-2"; - seedProject(projectId); - memoryService.writeMemory({ - projectId, - scope: "mission", - scopeOwnerId: missionId, - category: "pattern", - content: "Pattern fact", - importance: "medium", - }); - memoryService.writeMemory({ - projectId, - scope: "mission", - scopeOwnerId: missionId, - category: "digest", - content: "Digest fact", - importance: "medium", - }); - memoryService.writeMemory({ - projectId, - scope: "mission", - scopeOwnerId: missionId, - category: "preference", - content: "Preference fact", - importance: "medium", - }); - memoryService.writeMemory({ - projectId, - scope: "mission", - scopeOwnerId: missionId, - category: "fact", - content: "Architectural fact", - importance: "medium", - }); - memoryService.writeMemory({ - projectId, - scope: "mission", - scopeOwnerId: missionId, - category: "gotcha", - content: "Gotcha fact", - importance: "high", - }); - - const briefing = await createMemoryBriefingService({ memoryService }).buildBriefing({ - projectId, - missionId, - runId: "run-2", - mode: "mission_worker", - }); - - expect(briefing.sharedFacts.map((f) => f.factType).sort()).toEqual( - ["api_pattern", "architectural", "config", "gotcha", "schema_change"].sort() - ); - db.close(); - }); - - // VAL-ENH-022 - it("memory write-gate deduplicates identical content", async () => { - const { memoryService, db, seedProject } = await createMemoryFixture(); - const projectId = "proj-dedup"; - seedProject(projectId); - const content = "Always use snake_case for DB columns"; - const result1 = memoryService.writeMemory({ - projectId, - scope: "project", - category: "convention", - content, - importance: "high", - }); - expect(result1.accepted).toBe(true); - expect(result1.deduped).toBeFalsy(); - - const result2 = memoryService.writeMemory({ - projectId, - scope: "project", - category: "convention", - content, - importance: "high", - }); - expect(result2.accepted).toBe(true); - expect(result2.deduped).toBe(true); - - // Observation count should be >= 2 - const mem = result2.memory!; - expect(mem.observationCount).toBeGreaterThanOrEqual(2); - db.close(); - }); - - // VAL-ENH-023 - it("searchMemories returns relevant results with pinned memories ranked higher", async () => { - const { memoryService, db, seedProject } = await createMemoryFixture(); - const projectId = "proj-search"; - seedProject(projectId); - - // Add a regular memory - const regularResult = memoryService.writeMemory({ - projectId, - scope: "project", - category: "fact", - content: "The database uses PostgreSQL with UUID primary keys", - importance: "medium", - }); - - // Add a pinned memory - const pinnedResult = memoryService.writeMemory({ - projectId, - scope: "project", - category: "fact", - content: "The database connection pool size is limited to 20", - importance: "high", - pinned: true, - }); - - const results = await memoryService.searchMemories("database", projectId, undefined, 10); - expect(results.length).toBeGreaterThanOrEqual(2); - - // Pinned memory should appear first - const pinnedIdx = results.findIndex((m) => m.id === pinnedResult.memory!.id); - const regularIdx = results.findIndex((m) => m.id === regularResult.memory!.id); - expect(pinnedIdx).toBeLessThan(regularIdx); - db.close(); - }); - - // VAL-ENH-024 - it("scope isolation: mission-scoped memories isolated per scopeOwnerId", async () => { - const { memoryService, db, seedProject } = await createMemoryFixture(); - const projectId = "proj-scope"; - seedProject(projectId); - - memoryService.writeMemory({ - projectId, - scope: "mission", - scopeOwnerId: "mission-A", - category: "fact", - content: "Mission A uses React 18", - importance: "medium", - }); - - memoryService.writeMemory({ - projectId, - scope: "mission", - scopeOwnerId: "mission-B", - category: "fact", - content: "Mission B uses Vue 3", - importance: "medium", - }); - - const resultsA = await memoryService.searchMemories("React", projectId, "mission", 10, "promoted", "mission-A"); - const resultsB = await memoryService.searchMemories("React", projectId, "mission", 10, "promoted", "mission-B"); - - // Mission A should find React, mission B should not - expect(resultsA.some((m) => m.content.includes("React"))).toBe(true); - expect(resultsB.some((m) => m.content.includes("React"))).toBe(false); - db.close(); - }); -}); - -// ═══════════════════════════════════════════════════════════════════════════ -// (4) AGENT-BROWSER — VAL-ENH-050..052 -// ═══════════════════════════════════════════════════════════════════════════ -describe("Agent-browser integration", () => { - // VAL-ENH-050 - it("PhaseCard schema accepts capabilities field with agent-browser", () => { - const phase = makePhaseCard({ capabilities: ["agent-browser"] }); - expect(phase.capabilities).toEqual(["agent-browser"]); - - // Capabilities propagate when included - const phaseWithCaps = makePhaseCard({ - capabilities: ["agent-browser", "file-system"], - }); - expect(phaseWithCaps.capabilities).toContain("agent-browser"); - expect(phaseWithCaps.capabilities!.length).toBe(2); - - // Capabilities optional - undefined is valid - const phaseNoCaps = makePhaseCard(); - expect(phaseNoCaps.capabilities).toBeUndefined(); - }); - - // VAL-ENH-051 - it("MissionCloseoutRequirementKey includes browser_verification", () => { - const key: MissionCloseoutRequirementKey = "browser_verification"; - expect(key).toBe("browser_verification"); - - // Also screenshot - const screenshotKey: MissionCloseoutRequirementKey = "screenshot"; - expect(screenshotKey).toBe("screenshot"); - - // Missing requirement status check - const requirement: MissionCloseoutRequirement = { - key: "browser_verification", - label: "Browser verification", - required: true, - status: "missing" as MissionCloseoutRequirementStatus, - detail: null, - artifactId: null, - uri: null, - source: "declared", - }; - expect(requirement.status).toBe("missing"); - }); - - // VAL-ENH-052 - it("RoleToolProfile.allowedTools can include agent-browser", () => { - const profile: RoleToolProfile = { - allowedTools: ["agent-browser", "bash", "read_file"], - blockedTools: [], - notes: "Browser-enabled worker", - }; - expect(profile.allowedTools).toContain("agent-browser"); - }); -}); - -// ═══════════════════════════════════════════════════════════════════════════ -// (5) ARTIFACTS — VAL-ENH-060..063 -// ═══════════════════════════════════════════════════════════════════════════ -describe("Artifacts: screenshot/video support", () => { - // VAL-ENH-060 - it("MissionArtifactType supports screenshot and video artifact kinds", () => { - const screenshotKind: OrchestratorArtifactKind = "screenshot"; - expect(screenshotKind).toBe("screenshot"); - - const videoKind: OrchestratorArtifactKind = "video"; - expect(videoKind).toBe("video"); - }); - - // VAL-ENH-061 - it("report_result tool accepts artifacts with type screenshot/video", () => { - // Verify the report_result schema accepts screenshot/video artifact types - const report = { - workerId: "worker-1", - outcome: "succeeded" as const, - summary: "Completed with screenshots", - artifacts: [ - { type: "screenshot", title: "Login page screenshot", uri: "/tmp/login.png" }, - { type: "video", title: "Test recording", uri: "/tmp/test.webm", metadata: { duration: 30 } }, - ], - filesChanged: [], - testsRun: null, - }; - expect(report.artifacts[0]!.type).toBe("screenshot"); - expect(report.artifacts[1]!.type).toBe("video"); - expect(report.artifacts[1]!.metadata).toEqual({ duration: 30 }); - }); - - // VAL-ENH-062 - it("OrchestratorArtifact rows queryable by missionId with kind, value, metadata", async () => { - const root = fs.mkdtempSync(path.join(os.tmpdir(), "ade-artifacts-")); - const adeDir = path.join(root, ".ade"); - fs.mkdirSync(adeDir, { recursive: true }); - const db = await openKvDb(path.join(adeDir, "ade.db"), createLogger()); - - const projectId = "proj-1"; - const missionId = randomUUID(); - const runId = randomUUID(); - const stepId = randomUUID(); - const attemptId = randomUUID(); - const artifactId = randomUUID(); - - seedOrchestratorRun(db, projectId, missionId, runId); - seedOrchestratorStep(db, runId, stepId, projectId); - seedOrchestratorAttempt(db, runId, stepId, attemptId, projectId); - - const now = new Date().toISOString(); - db.run( - `INSERT INTO orchestrator_artifacts(id, project_id, mission_id, run_id, step_id, attempt_id, artifact_key, kind, value, metadata_json, declared, created_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, - [artifactId, projectId, missionId, runId, stepId, attemptId, "screenshot_login", "screenshot", "/tmp/login.png", JSON.stringify({ width: 1920 }), 0, now] - ); - - const rows = db.all>( - `SELECT * FROM orchestrator_artifacts WHERE mission_id = ?`, - [missionId] - ); - expect(rows.length).toBe(1); - expect(rows[0]!.kind).toBe("screenshot"); - expect(rows[0]!.value).toBe("/tmp/login.png"); - expect(JSON.parse(String(rows[0]!.metadata_json))).toEqual({ width: 1920 }); - - db.close(); - }); - - // VAL-ENH-063 - it("closeout checks artifact presence for screenshot requirement", async () => { - const root = fs.mkdtempSync(path.join(os.tmpdir(), "ade-closeout-")); - const adeDir = path.join(root, ".ade"); - fs.mkdirSync(adeDir, { recursive: true }); - const db = await openKvDb(path.join(adeDir, "ade.db"), createLogger()); - - const projectId = "proj-1"; - const missionId = randomUUID(); - const runId = randomUUID(); - const stepId = randomUUID(); - const attemptId = randomUUID(); - - // No artifact → requirement "missing" - const rowsEmpty = db.all>( - `SELECT * FROM orchestrator_artifacts WHERE mission_id = ? AND kind = 'screenshot'`, - [missionId] - ); - expect(rowsEmpty.length).toBe(0); - - // Seed FK parents, then insert a screenshot artifact - seedOrchestratorRun(db, projectId, missionId, runId); - seedOrchestratorStep(db, runId, stepId, projectId); - seedOrchestratorAttempt(db, runId, stepId, attemptId, projectId); - - db.run( - `INSERT INTO orchestrator_artifacts(id, project_id, mission_id, run_id, step_id, attempt_id, artifact_key, kind, value, metadata_json, declared, created_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, - [randomUUID(), projectId, missionId, runId, stepId, attemptId, "screenshot_main", "screenshot", "/tmp/main.png", "{}", 0, new Date().toISOString()] - ); - - // Now artifact present - const rowsPresent = db.all>( - `SELECT * FROM orchestrator_artifacts WHERE mission_id = ? AND kind = 'screenshot'`, - [missionId] - ); - expect(rowsPresent.length).toBe(1); - - db.close(); - }); -}); - -// ═══════════════════════════════════════════════════════════════════════════ -// (6) CTO — VAL-ENH-070..075 -// ═══════════════════════════════════════════════════════════════════════════ -describe("CTO integration", () => { - // VAL-ENH-070 - it("updateCoreMemory accepts patch and increments version", async () => { - const { ctoService, db } = await createCtoFixture(); - const initial = ctoService.getCoreMemory(); - const initialVersion = initial.version; - - const updated = ctoService.updateCoreMemory({ - projectSummary: "Updated project summary after refactoring", - }); - expect(updated.coreMemory.version).toBe(initialVersion + 1); - expect(updated.coreMemory.projectSummary).toBe("Updated project summary after refactoring"); - - // Another update increments again - const updated2 = ctoService.updateCoreMemory({ - notes: ["New convention: always use strict mode"], - }); - expect(updated2.coreMemory.version).toBe(initialVersion + 2); - expect(updated2.coreMemory.notes).toContain("New convention: always use strict mode"); - - db.close(); - }); - - // VAL-ENH-071 - MissionStateDocument.latestRetrospective - it("MissionStateDocument supports latestRetrospective field", async () => { - // The latestRetrospective is set via missionStateDoc patch. - // Verify the field can be read/written in the type system. - const retrospective = { - id: randomUUID(), - missionId: randomUUID(), - runId: randomUUID(), - painPoints: [ - { key: "slow_tests", label: "Slow test suite", painScore: 7, status: "active" as const }, - ], - patternsToCapture: [ - { patternKey: "test_parallelization", label: "Parallelize test execution", priority: "high" as const }, - ], - }; - expect(retrospective.painPoints[0]!.key).toBe("slow_tests"); - expect(retrospective.patternsToCapture[0]!.patternKey).toBe("test_parallelization"); - }); - - // VAL-ENH-072 - it("appendSessionLog writes to both DB and file", async () => { - const { ctoService, db, adeDir } = await createCtoFixture(); - const entry = ctoService.appendSessionLog({ - sessionId: "session-abc", - summary: "Completed code review of authentication module", - startedAt: "2026-03-01T10:00:00.000Z", - endedAt: "2026-03-01T10:30:00.000Z", - provider: "claude", - modelId: "claude-sonnet-4-6", - capabilityMode: "full_tooling", - }); - - expect(entry.sessionId).toBe("session-abc"); - expect(entry.summary).toBe("Completed code review of authentication module"); - - // Check DB - const dbLogs = ctoService.getSessionLogs(10); - expect(dbLogs.some((log) => log.sessionId === "session-abc")).toBe(true); - - // Check file - const sessionsPath = path.join(adeDir, "cto", "sessions.jsonl"); - expect(fs.existsSync(sessionsPath)).toBe(true); - const fileContent = fs.readFileSync(sessionsPath, "utf8"); - expect(fileContent).toContain("session-abc"); - - db.close(); - }); - - // VAL-ENH-073 - it("buildReconstructionContext includes promoted patterns and core memory", async () => { - const { ctoService, db } = await createCtoFixture(); - - // Add some data to core memory - ctoService.updateCoreMemory({ - projectSummary: "E-commerce platform with microservices architecture", - criticalConventions: ["Use TypeScript strict mode", "All APIs must be versioned"], - activeFocus: ["Payment integration refactoring"], - }); - - // Add a session log (acts as a "pattern" in the context) - ctoService.appendSessionLog({ - sessionId: "session-pattern", - summary: "Discovered recurring test flakiness in CI — always use retry on DB-dependent tests", - startedAt: "2026-03-01T10:00:00.000Z", - endedAt: null, - provider: "claude", - modelId: null, - capabilityMode: "fallback", - }); - - const context = ctoService.buildReconstructionContext(); - expect(context).toContain("E-commerce platform"); - expect(context).toContain("TypeScript strict mode"); - expect(context).toContain("Payment integration"); - expect(context).toContain("test flakiness"); - db.close(); - }); - - // VAL-ENH-074 - Retrospective trends - it("retrospective trend rows can be inserted and queried", async () => { - const root = fs.mkdtempSync(path.join(os.tmpdir(), "ade-trends-")); - const adeDir = path.join(root, ".ade"); - fs.mkdirSync(adeDir, { recursive: true }); - const db = await openKvDb(path.join(adeDir, "ade.db"), createLogger()); - - const trendId = randomUUID(); - const now = new Date().toISOString(); - db.run( - `INSERT INTO orchestrator_retrospective_trends( - id, project_id, mission_id, run_id, retrospective_id, - source_mission_id, source_run_id, source_retrospective_id, - pain_point_key, pain_point_label, status, previous_pain_score, current_pain_score, created_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, - [trendId, "proj-1", "mission-1", "run-1", "retro-1", "mission-0", "run-0", "retro-0", - "slow_ci", "Slow CI", "worsened", 3, 7, now] - ); - - const rows = db.all>( - `SELECT * FROM orchestrator_retrospective_trends WHERE project_id = ?`, - ["proj-1"] - ); - expect(rows.length).toBe(1); - expect(rows[0]!.pain_point_key).toBe("slow_ci"); - expect(rows[0]!.status).toBe("worsened"); - expect(Number(rows[0]!.previous_pain_score)).toBe(3); - expect(Number(rows[0]!.current_pain_score)).toBe(7); - - db.close(); - }); - - // VAL-ENH-075 - Pattern stats - it("pattern stat occurrenceCount increments and promotedMemoryId links correctly", async () => { - const root = fs.mkdtempSync(path.join(os.tmpdir(), "ade-pattern-stats-")); - const adeDir = path.join(root, ".ade"); - fs.mkdirSync(adeDir, { recursive: true }); - const db = await openKvDb(path.join(adeDir, "ade.db"), createLogger()); - - const statId = randomUUID(); - const now = new Date().toISOString(); - - // Insert initial stat - db.run( - `INSERT INTO orchestrator_reflection_pattern_stats( - id, project_id, pattern_key, pattern_label, occurrence_count, - first_seen_retrospective_id, first_seen_run_id, - last_seen_retrospective_id, last_seen_run_id, - promoted_memory_id, created_at, updated_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, - [statId, "proj-1", "retry_on_timeout", "Retry on timeout errors", 1, - "retro-1", "run-1", "retro-1", "run-1", null, now, now] - ); - - // Verify initial - let row = db.get>( - `SELECT * FROM orchestrator_reflection_pattern_stats WHERE id = ?`, - [statId] - ); - expect(Number(row!.occurrence_count)).toBe(1); - expect(row!.promoted_memory_id).toBeNull(); - - // Increment count and set promoted memory - const memoryId = randomUUID(); - db.run( - `UPDATE orchestrator_reflection_pattern_stats - SET occurrence_count = occurrence_count + 1, - promoted_memory_id = ?, - last_seen_retrospective_id = ?, - last_seen_run_id = ?, - updated_at = ? - WHERE id = ?`, - [memoryId, "retro-2", "run-2", now, statId] - ); - - row = db.get>( - `SELECT * FROM orchestrator_reflection_pattern_stats WHERE id = ?`, - [statId] - ); - expect(Number(row!.occurrence_count)).toBe(2); - expect(row!.promoted_memory_id).toBe(memoryId); - - db.close(); - }); -}); - -// ═══════════════════════════════════════════════════════════════════════════ -// (3) PR — VAL-ENH-040..044 -// Note: createIntegrationPr, createQueuePrs, landStack require mocked git/GitHub, -// so we test the available logic paths (finalization policy dispatch, cleanup, etc.) -// ═══════════════════════════════════════════════════════════════════════════ -describe("PR integration: finalization policy dispatch", () => { - // VAL-ENH-043 - it("finalization policy kinds cover all expected paths", () => { - const policies: MissionFinalizationPolicyKind[] = [ - "disabled", "manual", "integration", "per-lane", "queue" - ]; - expect(policies).toContain("integration"); - expect(policies).toContain("per-lane"); - expect(policies).toContain("queue"); - expect(policies).toContain("disabled"); - expect(policies).toContain("manual"); - expect(policies.length).toBe(5); - }); -}); - -// ═══════════════════════════════════════════════════════════════════════════ -// (2) CONFLICTS — VAL-ENH-030..034 -// Note: runPrediction/simulateMerge need real git repos; tested in -// conflictService.test.ts. Here we test buildChips and rebase detection types. -// ═══════════════════════════════════════════════════════════════════════════ -describe("Conflicts: type-level and chip verification", () => { - // VAL-ENH-034 - buildChips is tested as import from conflictService - // We verify the type structure for conflict chips - it("conflict chips have expected structure (kind, laneId, peerId, overlapCount)", () => { - const chip = { - laneId: "lane-1", - peerId: "lane-2", - kind: "new-overlap" as const, - overlapCount: 3, - }; - expect(chip.kind).toBe("new-overlap"); - expect(chip.overlapCount).toBe(3); - - const highRiskChip = { - laneId: "lane-1", - peerId: "lane-2", - kind: "high-risk" as const, - overlapCount: 5, - }; - expect(highRiskChip.kind).toBe("high-risk"); - }); - - // VAL-ENH-033 - Rebase detection structure - it("rebase needs include behind count and lane info", () => { - const need = { - laneId: "lane-1", - laneName: "feature/auth", - branchRef: "feature/auth", - baseRef: "main", - behind: 5, - ahead: 2, - }; - expect(need.behind).toBeGreaterThan(0); - expect(need.laneId).toBe("lane-1"); - }); -}); - -// ═══════════════════════════════════════════════════════════════════════════ -// (7) CROSS-AREA INTEGRATION TESTS -// ═══════════════════════════════════════════════════════════════════════════ -describe("Cross-area integration", () => { - // VAL-CROSS-002 - Agent-browser artifact → closeout requirement - it("phase with agent-browser capability + screenshot artifact → closeout requirement present", () => { - const phase = makePhaseCard({ - capabilities: ["agent-browser"], - validationGate: { - tier: "dedicated", - required: true, - evidenceRequirements: ["screenshot", "browser_verification"], - }, - }); - expect(phase.capabilities).toContain("agent-browser"); - expect(phase.validationGate.evidenceRequirements).toContain("screenshot"); - expect(phase.validationGate.evidenceRequirements).toContain("browser_verification"); - - // When artifact present → requirement "present" - const presentRequirement: MissionCloseoutRequirement = { - key: "screenshot", - label: "Screenshot evidence", - required: true, - status: "present", - detail: "Screenshot captured via agent-browser", - artifactId: randomUUID(), - uri: "/tmp/screenshot.png", - source: "declared", - }; - expect(presentRequirement.status).toBe("present"); - - // When artifact absent → requirement "missing" - const missingRequirement: MissionCloseoutRequirement = { - key: "screenshot", - label: "Screenshot evidence", - required: true, - status: "missing", - detail: null, - artifactId: null, - uri: null, - source: "declared", - }; - expect(missingRequirement.status).toBe("missing"); - }); - - // VAL-CROSS-003 - Retrospective → CTO memory → next mission - it("patterns flow from retrospective through CTO to next mission context", async () => { - const { ctoService, db } = await createCtoFixture(); - - // Simulate retrospective promoting a pattern to CTO core memory - ctoService.updateCoreMemory({ - notes: ["Pattern: Always run lint before commit — reduces CI failures by 40%"], - criticalConventions: ["Run lint before commit"], - }); - - // Next mission's coordinator prompt should include the pattern - const context = ctoService.buildReconstructionContext(); - expect(context).toContain("lint before commit"); - expect(context).toContain("Run lint before commit"); - - db.close(); - }); - - // VAL-CROSS-004 - Validation blocks completion - it("CompletionDiagnostic blocks with phase_required_missing for required validation phase", () => { - const phases: PhaseCard[] = [ - makePhaseCard({ - phaseKey: "planning", - name: "Planning", - isBuiltIn: true, - validationGate: { tier: "none", required: false }, - }), - makePhaseCard({ - phaseKey: "validation", - name: "Validation", - isBuiltIn: true, - validationGate: { tier: "dedicated", required: true }, - }), - ]; - - // No steps at all — empty array — validation has no succeeded steps - const result = evaluateRunCompletionFromPhases( - [], // no steps - phases, - {} // empty settings - ); - - expect(result.completionReady).toBe(false); - expect(result.diagnostics.some((d) => d.code === "phase_required_missing")).toBe(true); - }); - - // VAL-CROSS-005 - Shared fact from worker → memory search - it("worker mission memory is exposed as shared team knowledge in the derived briefing", async () => { - const { memoryService, db, seedProject } = await createMemoryFixture(); - const projectId = "proj-1"; - const missionId = "mission-1"; - seedProject(projectId); - memoryService.writeMemory({ - projectId, - scope: "mission", - scopeOwnerId: missionId, - category: "gotcha", - content: "SQLite WASM does not support FTS5", - importance: "high", - sourceType: "system", - sourceRunId: "run-1", - }); - - const briefing = await createMemoryBriefingService({ memoryService }).buildBriefing({ - projectId, - missionId, - runId: "run-1", - mode: "mission_worker", - }); - - expect(briefing.sharedFacts).toHaveLength(1); - expect(briefing.sharedFacts[0]!.content).toBe("SQLite WASM does not support FTS5"); - expect(briefing.sharedFacts[0]!.factType).toBe("gotcha"); - - db.close(); - }); - - // VAL-CROSS-006 - Budget cap blocks parallel spawn cascade - it("budget check gates spawn when cap triggered", () => { - // The existing orchestrationRuntime test covers VAL-ENH-004. - // Here we verify the type contract: checkBudgetHardCaps returns - // a result with triggered flag. - const budgetResult = { - triggered: true, - caps: [{ kind: "token_budget", detail: "Token budget exceeded: 95% used" }], - }; - expect(budgetResult.triggered).toBe(true); - expect(budgetResult.caps[0]!.kind).toBe("token_budget"); - }); -}); diff --git a/apps/desktop/src/main/services/orchestrator/mission.test.ts b/apps/desktop/src/main/services/orchestrator/mission.test.ts new file mode 100644 index 000000000..951db335c --- /dev/null +++ b/apps/desktop/src/main/services/orchestrator/mission.test.ts @@ -0,0 +1,1257 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { randomUUID } from "node:crypto"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { createOrchestratorService } from "./orchestratorService"; +import { transitionMissionStatus } from "./missionLifecycle"; +import { createMissionService } from "../missions/missionService"; +import { createBuiltInPhaseCards } from "../missions/phaseEngine"; +import { openKvDb } from "../state/kvDb"; +import { createMissionBudgetService } from "./missionBudgetService"; +import type { PackExport, PackType } from "../../../shared/types"; +import { + createInitialMissionStateDocument, + getMissionStateDocumentPath, + getCoordinatorCheckpointPath, + updateMissionStateDocument, + readMissionStateDocument, + writeCoordinatorCheckpoint, + readCoordinatorCheckpoint, + deleteCoordinatorCheckpoint, +} from "./missionStateDoc"; + +function createLogger() { + return { + debug: () => {}, + info: () => {}, + warn: () => {}, + error: () => {}, + } as any; +} + +// ───────────────────────────────────────────────────── +// missionLifecycle — terminal status regression guard +// ───────────────────────────────────────────────────── + +async function createLifecycleFixture(initialStatus: string = "in_progress") { + const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-lifecycle-regression-")); + const db = await openKvDb(path.join(projectRoot, "ade.db"), createLogger()); + const projectId = "proj-1"; + const laneId = "lane-1"; + const missionId = "mission-1"; + const now = "2026-03-10T00:00:00.000Z"; + + db.run( + `insert into projects(id, root_path, display_name, default_base_ref, created_at, last_opened_at) + values (?, ?, ?, ?, ?, ?)`, + [projectId, projectRoot, "ADE", "main", now, now] + ); + + db.run( + `insert into lanes( + id, project_id, name, description, lane_type, base_ref, branch_ref, + worktree_path, attached_root_path, is_edit_protected, parent_lane_id, + color, icon, tags_json, status, created_at, archived_at + ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [ + laneId, projectId, "Lane 1", null, "worktree", "main", "feature/lane-1", + projectRoot, null, 0, null, null, null, null, "active", now, null, + ] + ); + + db.run( + `insert into missions( + id, project_id, lane_id, title, prompt, status, priority, + execution_mode, target_machine_id, outcome_summary, last_error, + metadata_json, created_at, updated_at, started_at, completed_at + ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [ + missionId, projectId, laneId, "Lifecycle Regression Test", + "Test lifecycle regression guard.", initialStatus, "normal", "local", + null, null, null, null, now, now, now, null, + ] + ); + + const orchestratorService = createOrchestratorService({ + db, + projectId, + projectRoot, + ptyService: { + create: async () => ({ ptyId: "pty-1", sessionId: "session-1" }), + } as any, + projectConfigService: null as any, + aiIntegrationService: null as any, + memoryService: null as any, + }); + + const missionService = createMissionService({ db, projectId }); + + const ctx = { + db, + logger: createLogger(), + missionService, + orchestratorService, + projectRoot, + hookCommandRunner: async () => ({ + exitCode: 0, + signal: null, + timedOut: false, + durationMs: 0, + stdout: "", + stderr: "", + spawnError: null, + }), + agentChatService: null, + laneService: null, + projectConfigService: null, + aiIntegrationService: null, + prService: null, + missionBudgetService: null, + onThreadEvent: undefined, + onDagMutation: undefined, + syncLocks: new Set(), + workerStates: new Map(), + activeSteeringDirectives: new Map(), + runRuntimeProfiles: new Map(), + chatMessages: new Map(), + activeChatSessions: new Map(), + chatTurnQueues: new Map(), + activeHealthSweepRuns: new Set(), + sessionRuntimeSignals: new Map(), + attemptRuntimeTrackers: new Map(), + sessionSignalQueues: new Map(), + workerDeliveryThreadQueues: new Map(), + workerDeliveryInterventionCooldowns: new Map(), + runTeamManifests: new Map(), + runRecoveryLoopStates: new Map(), + aiTimeoutBudgetStepLocks: new Set(), + aiTimeoutBudgetRunLocks: new Set(), + aiRetryDecisionLocks: new Set(), + coordinatorSessions: new Map(), + pendingIntegrations: new Map(), + coordinatorThinkingLoops: new Map(), + pendingCoordinatorEvals: new Map(), + coordinatorAgents: new Map(), + coordinatorRecoveryAttempts: new Map(), + teamRuntimeStates: new Map(), + callTypeConfigCache: new Map(), + disposed: { current: false }, + healthSweepTimer: { current: null }, + } as any; + + return { + ctx, + missionId, + missionService, + dispose: () => { + db.close(); + fs.rmSync(projectRoot, { recursive: true, force: true }); + }, + }; +} + +describe("transitionMissionStatus — terminal status regression guard", () => { + it("blocks transition from completed to in_progress", async () => { + const fixture = await createLifecycleFixture("completed"); + try { + transitionMissionStatus(fixture.ctx, fixture.missionId, "in_progress"); + const mission = fixture.missionService.get(fixture.missionId); + expect(mission?.status).toBe("completed"); + } finally { + fixture.dispose(); + } + }); + + it("blocks transition from failed to running", async () => { + const fixture = await createLifecycleFixture("failed"); + try { + transitionMissionStatus(fixture.ctx, fixture.missionId, "in_progress"); + const mission = fixture.missionService.get(fixture.missionId); + expect(mission?.status).toBe("failed"); + } finally { + fixture.dispose(); + } + }); + + it("blocks transition from canceled to in_progress", async () => { + const fixture = await createLifecycleFixture("canceled"); + try { + transitionMissionStatus(fixture.ctx, fixture.missionId, "in_progress"); + const mission = fixture.missionService.get(fixture.missionId); + expect(mission?.status).toBe("canceled"); + } finally { + fixture.dispose(); + } + }); + + it("passes through the regression guard for terminal-to-terminal (completed to completed)", async () => { + const fixture = await createLifecycleFixture("completed"); + try { + transitionMissionStatus(fixture.ctx, fixture.missionId, "completed"); + const mission = fixture.missionService.get(fixture.missionId); + expect(mission?.status).toBe("completed"); + } finally { + fixture.dispose(); + } + }); + + it("does not throw for terminal-to-terminal even when missionService rejects it", async () => { + const fixture = await createLifecycleFixture("completed"); + try { + transitionMissionStatus(fixture.ctx, fixture.missionId, "failed"); + const mission = fixture.missionService.get(fixture.missionId); + expect(mission?.status).toBe("completed"); + } finally { + fixture.dispose(); + } + }); + + it("allows failed -> canceled (valid in missionService transition table)", async () => { + const fixture = await createLifecycleFixture("failed"); + try { + transitionMissionStatus(fixture.ctx, fixture.missionId, "canceled"); + const mission = fixture.missionService.get(fixture.missionId); + expect(mission?.status).toBe("canceled"); + } finally { + fixture.dispose(); + } + }); + + it("allows transition from non-terminal to terminal (in_progress to completed)", async () => { + const fixture = await createLifecycleFixture("in_progress"); + try { + transitionMissionStatus(fixture.ctx, fixture.missionId, "completed", { + outcomeSummary: "All tasks done", + }); + const mission = fixture.missionService.get(fixture.missionId); + expect(mission?.status).toBe("completed"); + } finally { + fixture.dispose(); + } + }); + + it("allows transition from non-terminal to non-terminal (in_progress to intervention_required)", async () => { + const fixture = await createLifecycleFixture("in_progress"); + try { + transitionMissionStatus(fixture.ctx, fixture.missionId, "intervention_required", { + lastError: "Needs human review", + }); + const mission = fixture.missionService.get(fixture.missionId); + expect(mission?.status).toBe("intervention_required"); + } finally { + fixture.dispose(); + } + }); + + it("blocks transition from completed to intervention_required (non-terminal)", async () => { + const fixture = await createLifecycleFixture("completed"); + try { + transitionMissionStatus(fixture.ctx, fixture.missionId, "intervention_required"); + const mission = fixture.missionService.get(fixture.missionId); + expect(mission?.status).toBe("completed"); + } finally { + fixture.dispose(); + } + }); + + it("no-ops when transitioning to the same status with no args", async () => { + const fixture = await createLifecycleFixture("in_progress"); + try { + transitionMissionStatus(fixture.ctx, fixture.missionId, "in_progress"); + const mission = fixture.missionService.get(fixture.missionId); + expect(mission?.status).toBe("in_progress"); + } finally { + fixture.dispose(); + } + }); + + it("returns silently for a non-existent mission", async () => { + const fixture = await createLifecycleFixture("in_progress"); + try { + transitionMissionStatus(fixture.ctx, "non-existent-mission-id", "completed"); + } finally { + fixture.dispose(); + } + }); +}); + +// ───────────────────────────────────────────────────── +// missionBudgetService +// ───────────────────────────────────────────────────── + +function buildExport(packKey: string, packType: PackType, level: "lite" | "standard" | "deep"): PackExport { + return { + packKey, + packType, + level, + header: {} as any, + content: `${packKey}:${level}`, + approxTokens: 16, + maxTokens: 500, + truncated: false, + warnings: [], + clipReason: null, + omittedSections: null + }; +} + +async function createBudgetDbWithProjectAndLane() { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "ade-mission-budget-")); + const dbPath = path.join(root, "ade.db"); + const db = await openKvDb(dbPath, createLogger()); + + const projectId = "proj-1"; + const laneId = "lane-1"; + const now = "2026-02-18T00:00:00.000Z"; + + db.run( + ` + insert into projects(id, root_path, display_name, default_base_ref, created_at, last_opened_at) + values (?, ?, ?, ?, ?, ?) + `, + [projectId, root, "ADE", "main", now, now] + ); + + db.run( + ` + insert into lanes( + id, + project_id, + name, + description, + lane_type, + base_ref, + branch_ref, + worktree_path, + attached_root_path, + is_edit_protected, + parent_lane_id, + color, + icon, + tags_json, + status, + created_at, + archived_at + ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `, + [ + laneId, + projectId, + "Lane 1", + null, + "worktree", + "main", + "feature/lane-1", + root, + null, + 0, + null, + null, + null, + null, + "active", + now, + null + ] + ); + + return { + db, + projectId, + laneId, + root, + dispose: () => db.close() + }; +} + +function writeCodexSessionLog(args: { + sessionsRoot: string; + cwd: string; + timestampIso: string; + model?: string; + inputTokens: number; + outputTokens: number; +}): void { + const date = new Date(args.timestampIso); + const yyyy = String(date.getUTCFullYear()); + const mm = String(date.getUTCMonth() + 1).padStart(2, "0"); + const dd = String(date.getUTCDate()).padStart(2, "0"); + const dayDir = path.join(args.sessionsRoot, yyyy, mm, dd); + fs.mkdirSync(dayDir, { recursive: true }); + const filePath = path.join(dayDir, "rollout-test.jsonl"); + const model = args.model ?? "openai/gpt-5"; + const lines = [ + JSON.stringify({ + timestamp: args.timestampIso, + type: "session_meta", + payload: { + id: "session-test-1", + cwd: args.cwd, + }, + }), + JSON.stringify({ + timestamp: args.timestampIso, + type: "turn_context", + payload: { + cwd: args.cwd, + model, + }, + }), + JSON.stringify({ + timestamp: args.timestampIso, + type: "event_msg", + payload: { + type: "token_count", + info: { + total_token_usage: { + input_tokens: args.inputTokens, + output_tokens: args.outputTokens, + cached_input_tokens: 0, + reasoning_output_tokens: 0, + total_tokens: args.inputTokens + args.outputTokens, + }, + last_token_usage: { + input_tokens: args.inputTokens, + output_tokens: args.outputTokens, + cached_input_tokens: 0, + reasoning_output_tokens: 0, + total_tokens: args.inputTokens + args.outputTokens, + }, + }, + }, + }), + ]; + fs.writeFileSync(filePath, `${lines.join("\n")}\n`, "utf8"); +} + +function writeClaudeProjectLog(args: { + projectsRoot: string; + timestampIso: string; + model?: string; + inputTokens: number; + outputTokens: number; +}): void { + const projectDir = path.join(args.projectsRoot, "-tmp-test-project"); + fs.mkdirSync(projectDir, { recursive: true }); + const filePath = path.join(projectDir, "session.jsonl"); + const model = args.model ?? "anthropic/claude-sonnet-4-6"; + const line = JSON.stringify({ + timestamp: args.timestampIso, + message: { + model, + usage: { + input_tokens: args.inputTokens, + output_tokens: args.outputTokens, + cache_read_input_tokens: 0, + cache_creation_input_tokens: 0, + }, + }, + }); + fs.writeFileSync(filePath, `${line}\n`, "utf8"); +} + +describe("missionBudgetService", () => { + it("flags API-key launch estimate when projected spend exceeds remaining envelope", async () => { + const { db, projectId, root, dispose } = await createBudgetDbWithProjectAndLane(); + const missionService = createMissionService({ db, projectId }); + const budgetService = createMissionBudgetService({ + db, + logger: createLogger(), + projectId, + projectRoot: root, + missionService, + aiIntegrationService: { + getStatus: async () => ({ mode: "api-key", detectedAuth: [] }) + } as any + }); + + const estimate = await budgetService.estimateLaunchBudget({ + launch: { + prompt: "Implement a complex mission pipeline.", + modelConfig: { + orchestratorModel: { + provider: "claude", + modelId: "claude-sonnet-4-6" + }, + smartBudget: { + enabled: true, + fiveHourThresholdUsd: 0.05, + weeklyThresholdUsd: 1 + } + } + }, + selectedPhases: createBuiltInPhaseCards().map((phase) => ({ + ...phase, + budget: { + maxTokens: 4_000 + } + })) + }); + + expect(estimate.estimate.mode).toBe("api-key"); + expect(estimate.estimate.estimatedCostUsd).toBeGreaterThan(0); + expect(estimate.hardLimitExceeded).toBe(true); + + dispose(); + }); + + it("uses local Codex session telemetry for subscription preflight and runtime budget snapshots", async () => { + const { db, projectId, laneId, root, dispose } = await createBudgetDbWithProjectAndLane(); + const missionService = createMissionService({ db, projectId }); + const mission = missionService.create({ + prompt: "Use subscription telemetry.", + laneId, + phaseOverride: createBuiltInPhaseCards(), + }); + const codexSessionsRoot = path.join(root, "codex-sessions"); + const nowIso = new Date().toISOString(); + writeCodexSessionLog({ + sessionsRoot: codexSessionsRoot, + cwd: root, + timestampIso: nowIso, + model: "openai/gpt-5", + inputTokens: 100_000, + outputTokens: 128_750, + }); + + const budgetService = createMissionBudgetService({ + db, + logger: createLogger(), + projectId, + projectRoot: root, + missionService, + aiIntegrationService: { + getStatus: async () => ({ mode: "subscription", detectedAuth: [{ type: "cli-subscription", authenticated: true, cli: "codex" }] }) + } as any, + projectConfigService: { + get: () => ({ + effective: { + cto: { + budgetTelemetry: { + enabled: true, + codexSessionsRoot, + claudeProjectsRoot: path.join(root, "no-claude-logs"), + }, + }, + }, + }), + } as any, + }); + + const estimate = await budgetService.estimateLaunchBudget({ + launch: { + prompt: "Run with local CLI subscription telemetry.", + modelConfig: { + orchestratorModel: { + provider: "codex", + modelId: "openai/gpt-5", + }, + }, + }, + selectedPhases: createBuiltInPhaseCards(), + }); + expect(estimate.estimate.mode).toBe("subscription"); + expect(estimate.estimate.actualSpendUsd).toBeCloseTo(1.23, 6); + expect(estimate.estimate.burnRateUsdPerHour ?? null).toBeNull(); + expect(estimate.estimate.note ?? "").toContain("local CLI telemetry"); + + const snapshot = await budgetService.getMissionBudgetStatus({ + missionId: mission.id, + }); + expect(snapshot.mode).toBe("subscription"); + const codexProvider = snapshot.perProvider.find((provider) => provider.provider === "codex"); + expect(codexProvider?.fiveHour.usedCostUsd).toBeCloseTo(1.23, 6); + expect(snapshot.burnRateUsdPerHour).toBeNull(); + expect(snapshot.dataSources).toContain("~/.codex/sessions/*.jsonl"); + + dispose(); + }); + + it("returns per-phase and per-worker budget snapshot with pressure", async () => { + const { db, projectId, laneId, root, dispose } = await createBudgetDbWithProjectAndLane(); + const missionService = createMissionService({ db, projectId }); + const phaseOverride = createBuiltInPhaseCards().map((phase) => ({ + ...phase, + budget: { + maxTokens: 100, + maxTimeMs: 60_000 + } + })); + const mission = missionService.create({ + prompt: "Build and validate mission budget usage telemetry.", + laneId, + phaseOverride + }); + + const orchestratorService = createOrchestratorService({ + db, + projectId, + projectRoot: root, + }); + + const started = orchestratorService.startRun({ + missionId: mission.id, + metadata: {}, + steps: [ + { + stepKey: "planning-1", + stepIndex: 0, + title: "Planning task", + laneId, + dependencyStepKeys: [], + retryLimit: 1, + executorKind: "manual", + metadata: { + phaseKey: phaseOverride[0]!.phaseKey, + phaseName: phaseOverride[0]!.name + } + } + ] + }); + + const step = started.steps[0]!; + db.run( + `update orchestrator_steps set status = 'ready', updated_at = ? where id = ? and run_id = ?`, + [new Date().toISOString(), step.id, started.run.id] + ); + + const attempt = await orchestratorService.startAttempt({ + runId: started.run.id, + stepId: step.id, + ownerId: "test-owner", + executorKind: "manual" + }); + const sessionId = `session-${randomUUID()}`; + db.run( + `update orchestrator_attempts set executor_session_id = ? where id = ? and run_id = ?`, + [sessionId, attempt.id, started.run.id] + ); + db.run( + ` + insert into ai_usage_log( + id, + timestamp, + feature, + provider, + model, + input_tokens, + output_tokens, + duration_ms, + success, + session_id + ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `, + [ + randomUUID(), + new Date().toISOString(), + "orchestrator", + "claude", + "anthropic/claude-sonnet-4-6", + 250, + 200, + 45_000, + 1, + sessionId + ] + ); + + const budgetService = createMissionBudgetService({ + db, + logger: createLogger(), + projectId, + projectRoot: root, + missionService, + aiIntegrationService: { + getStatus: async () => ({ mode: "api-key", detectedAuth: [] }) + } as any + }); + + const snapshot = await budgetService.getMissionBudgetStatus({ + missionId: mission.id, + runId: started.run.id + }); + + expect(snapshot.mode).toBe("api-key"); + expect(snapshot.pressure).toBe("critical"); + expect(snapshot.mission.usedTokens).toBe(450); + expect(snapshot.perPhase.length).toBeGreaterThan(0); + expect(snapshot.perWorker.length).toBeGreaterThan(0); + expect(snapshot.dataSources).toContain("ai_usage_log"); + + dispose(); + }); + + it("enforces subscription hard caps only for providers selected by mission models", async () => { + const { db, projectId, laneId, root, dispose } = await createBudgetDbWithProjectAndLane(); + const missionService = createMissionService({ db, projectId }); + const codexSessionsRoot = path.join(root, "codex-sessions"); + const claudeProjectsRoot = path.join(root, "claude-projects"); + const nowIso = new Date().toISOString(); + writeCodexSessionLog({ + sessionsRoot: codexSessionsRoot, + cwd: root, + timestampIso: nowIso, + model: "openai/gpt-5.3-codex", + inputTokens: 500, + outputTokens: 500, + }); + writeClaudeProjectLog({ + projectsRoot: claudeProjectsRoot, + timestampIso: nowIso, + model: "anthropic/claude-sonnet-4-6", + inputTokens: 5_000, + outputTokens: 5_000, + }); + + const phaseOverride = createBuiltInPhaseCards().map((phase, index) => ({ + ...phase, + model: { + ...phase.model, + provider: "codex", + modelId: "openai/gpt-5.3-codex", + }, + position: index, + })); + const mission = missionService.create({ + prompt: "Codex-only mission with provider-scoped hard caps.", + laneId, + phaseOverride, + modelConfig: { + orchestratorModel: { + provider: "codex", + modelId: "openai/gpt-5.3-codex", + }, + smartBudget: { + enabled: true, + fiveHourThresholdUsd: 10, + weeklyThresholdUsd: 40, + fiveHourHardStopPercent: 80, + weeklyHardStopPercent: 90, + providerLimits: { + codex: { fiveHourTokenLimit: 10_000, weeklyTokenLimit: 10_000 }, + claude: { fiveHourTokenLimit: 1_000, weeklyTokenLimit: 1_000 }, + }, + }, + }, + }); + + const budgetService = createMissionBudgetService({ + db, + logger: createLogger(), + projectId, + projectRoot: root, + missionService, + aiIntegrationService: { + getStatus: async () => ({ mode: "subscription", detectedAuth: [{ type: "cli-subscription", authenticated: true, cli: "codex" }] }) + } as any, + projectConfigService: { + get: () => ({ + effective: { + cto: { + budgetTelemetry: { + enabled: true, + codexSessionsRoot, + claudeProjectsRoot, + }, + }, + }, + }), + } as any, + }); + + const snapshot = await budgetService.getMissionBudgetStatus({ missionId: mission.id }); + const claudeProvider = snapshot.perProvider.find((provider) => provider.provider === "claude"); + expect((claudeProvider?.fiveHour.usedPct ?? 0)).toBeGreaterThan(80); + expect(snapshot.hardCaps.fiveHourTriggered).toBe(false); + expect(snapshot.hardCaps.weeklyTriggered).toBe(false); + + const telemetry = budgetService.getMissionBudgetTelemetry({ + providers: ["codex"], + providerLimits: { + codex: { fiveHourTokenLimit: 10_000, weeklyTokenLimit: 10_000 }, + }, + }); + expect(telemetry.perProvider).toHaveLength(1); + expect(telemetry.perProvider[0]?.provider).toBe("codex"); + expect(telemetry.perProvider[0]?.fiveHour.usedTokens ?? 0).toBeGreaterThan(0); + + dispose(); + }); + + it("disables hard-stop enforcement when smart budget toggle is off", async () => { + const { db, projectId, laneId, root, dispose } = await createBudgetDbWithProjectAndLane(); + const missionService = createMissionService({ db, projectId }); + const codexSessionsRoot = path.join(root, "codex-sessions"); + const nowIso = new Date().toISOString(); + writeCodexSessionLog({ + sessionsRoot: codexSessionsRoot, + cwd: root, + timestampIso: nowIso, + model: "openai/gpt-5.3-codex", + inputTokens: 9_000, + outputTokens: 9_000, + }); + const phaseOverride = createBuiltInPhaseCards().map((phase, index) => ({ + ...phase, + model: { + ...phase.model, + provider: "codex", + modelId: "openai/gpt-5.3-codex", + }, + position: index, + })); + const mission = missionService.create({ + prompt: "Mission with smart budget disabled.", + laneId, + phaseOverride, + modelConfig: { + orchestratorModel: { + provider: "codex", + modelId: "openai/gpt-5.3-codex", + }, + smartBudget: { + enabled: false, + fiveHourThresholdUsd: 10, + weeklyThresholdUsd: 40, + fiveHourHardStopPercent: 80, + weeklyHardStopPercent: 90, + providerLimits: { + codex: { fiveHourTokenLimit: 1_000, weeklyTokenLimit: 1_000 }, + }, + }, + }, + }); + + const budgetService = createMissionBudgetService({ + db, + logger: createLogger(), + projectId, + projectRoot: root, + missionService, + aiIntegrationService: { + getStatus: async () => ({ mode: "subscription", detectedAuth: [{ type: "cli-subscription", authenticated: true, cli: "codex" }] }) + } as any, + projectConfigService: { + get: () => ({ + effective: { + cto: { + budgetTelemetry: { + enabled: true, + codexSessionsRoot, + }, + }, + }, + }), + } as any, + }); + + const snapshot = await budgetService.getMissionBudgetStatus({ missionId: mission.id }); + expect(snapshot.hardCaps.fiveHourHardStopPercent).toBeNull(); + expect(snapshot.hardCaps.weeklyHardStopPercent).toBeNull(); + expect(snapshot.hardCaps.apiKeyMaxSpendUsd).toBeNull(); + expect(snapshot.hardCaps.fiveHourTriggered).toBe(false); + expect(snapshot.hardCaps.weeklyTriggered).toBe(false); + expect(snapshot.hardCaps.apiKeyTriggered).toBe(false); + + dispose(); + }); +}); + +// ───────────────────────────────────────────────────── +// missionStateDoc +// ───────────────────────────────────────────────────── + +describe("missionStateDoc", () => { + let tmpDir: string; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "ade-msd-test-")); + const missionStateDir = path.join(tmpDir, ".ade", "cache", "mission-state"); + fs.mkdirSync(missionStateDir, { recursive: true }); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + describe("getMissionStateDocumentPath", () => { + it("returns path under .ade/cache/mission-state", () => { + const result = getMissionStateDocumentPath(tmpDir, "run-abc"); + expect(result).toContain("mission-state"); + expect(result).toContain("mission-state-run-abc.json"); + }); + }); + + describe("getCoordinatorCheckpointPath", () => { + it("returns path under .ade/cache/mission-state", () => { + const result = getCoordinatorCheckpointPath(tmpDir, "run-abc"); + expect(result).toContain("mission-state"); + expect(result).toContain("coordinator-checkpoint-run-abc.json"); + }); + }); + + describe("createInitialMissionStateDocument", () => { + it("creates a document with schemaVersion 1 and empty collections", () => { + const doc = createInitialMissionStateDocument({ + missionId: "mission-1", + runId: "run-1", + goal: "Build feature X", + }); + expect(doc.schemaVersion).toBe(1); + expect(doc.missionId).toBe("mission-1"); + expect(doc.runId).toBe("run-1"); + expect(doc.goal).toBe("Build feature X"); + expect(doc.stepOutcomes).toEqual([]); + expect(doc.decisions).toEqual([]); + expect(doc.activeIssues).toEqual([]); + expect(doc.modifiedFiles).toEqual([]); + expect(doc.pendingInterventions).toEqual([]); + expect(doc.reflections).toEqual([]); + expect(doc.latestRetrospective).toBeNull(); + }); + + it("initializes progress with defaults when not provided", () => { + const doc = createInitialMissionStateDocument({ + missionId: "m1", + runId: "r1", + goal: "Test", + }); + expect(doc.progress.currentPhase).toBe("unknown"); + expect(doc.progress.completedSteps).toBe(0); + expect(doc.progress.totalSteps).toBe(0); + expect(doc.progress.activeWorkers).toEqual([]); + expect(doc.progress.blockedSteps).toEqual([]); + expect(doc.progress.failedSteps).toEqual([]); + }); + + it("accepts partial progress overrides", () => { + const doc = createInitialMissionStateDocument({ + missionId: "m1", + runId: "r1", + goal: "Test", + progress: { currentPhase: "development", totalSteps: 5 }, + }); + expect(doc.progress.currentPhase).toBe("development"); + expect(doc.progress.totalSteps).toBe(5); + expect(doc.progress.completedSteps).toBe(0); + }); + }); + + describe("updateMissionStateDocument", () => { + it("creates a new document and applies the patch", async () => { + const result = await updateMissionStateDocument({ + projectRoot: tmpDir, + missionId: "m1", + runId: "run-update-1", + goal: "Build X", + patch: { + updateProgress: { + currentPhase: "development", + totalSteps: 3, + }, + }, + }); + expect(result.missionId).toBe("m1"); + expect(result.progress.currentPhase).toBe("development"); + expect(result.progress.totalSteps).toBe(3); + }); + + it("adds a step outcome", async () => { + const result = await updateMissionStateDocument({ + projectRoot: tmpDir, + missionId: "m1", + runId: "run-step-outcome", + goal: "Build X", + patch: { + addStepOutcome: { + stepKey: "step-1", + stepName: "Implement auth", + phase: "development", + status: "succeeded", + summary: "Auth module completed", + filesChanged: ["src/auth.ts"], + warnings: [], + completedAt: "2026-03-25T12:00:00.000Z", + }, + }, + }); + expect(result.stepOutcomes).toHaveLength(1); + expect(result.stepOutcomes[0].stepKey).toBe("step-1"); + expect(result.stepOutcomes[0].status).toBe("succeeded"); + expect(result.stepOutcomes[0].filesChanged).toEqual(["src/auth.ts"]); + expect(result.modifiedFiles).toContain("src/auth.ts"); + }); + + it("merges step outcome updates on existing step", async () => { + await updateMissionStateDocument({ + projectRoot: tmpDir, + missionId: "m1", + runId: "run-merge-outcome", + goal: "Build X", + patch: { + addStepOutcome: { + stepKey: "step-1", + stepName: "Implement auth", + phase: "development", + status: "in_progress", + summary: "Started", + filesChanged: ["src/auth.ts"], + warnings: [], + completedAt: null, + }, + }, + }); + + const result = await updateMissionStateDocument({ + projectRoot: tmpDir, + missionId: "m1", + runId: "run-merge-outcome", + goal: "Build X", + patch: { + addStepOutcome: { + stepKey: "step-1", + stepName: "Implement auth", + phase: "development", + status: "succeeded", + summary: "Completed auth module", + filesChanged: ["src/auth.ts", "src/middleware.ts"], + warnings: [], + completedAt: "2026-03-25T13:00:00.000Z", + }, + }, + }); + expect(result.stepOutcomes).toHaveLength(1); + expect(result.stepOutcomes[0].status).toBe("succeeded"); + expect(result.stepOutcomes[0].summary).toBe("Completed auth module"); + }); + + it("adds decisions", async () => { + const result = await updateMissionStateDocument({ + projectRoot: tmpDir, + missionId: "m1", + runId: "run-decision", + goal: "Build X", + patch: { + addDecision: { + timestamp: "2026-03-25T12:00:00.000Z", + decision: "Use JWT for auth", + rationale: "Standard approach", + context: "Architecture decision", + }, + }, + }); + expect(result.decisions).toHaveLength(1); + expect(result.decisions[0].decision).toBe("Use JWT for auth"); + }); + + it("adds and resolves issues", async () => { + await updateMissionStateDocument({ + projectRoot: tmpDir, + missionId: "m1", + runId: "run-issue", + goal: "Build X", + patch: { + addIssue: { + id: "issue-1", + severity: "high", + description: "Auth module failing tests", + affectedSteps: ["step-1"], + status: "open", + }, + }, + }); + + const result = await updateMissionStateDocument({ + projectRoot: tmpDir, + missionId: "m1", + runId: "run-issue", + goal: "Build X", + patch: { + resolveIssue: { + id: "issue-1", + resolution: "Fixed the test setup", + }, + }, + }); + expect(result.activeIssues).toHaveLength(1); + expect(result.activeIssues[0].status).toBe("resolved"); + expect(result.decisions.some((d) => d.decision.includes("Resolved issue issue-1"))).toBe(true); + }); + + it("sets and clears finalization state", async () => { + const result = await updateMissionStateDocument({ + projectRoot: tmpDir, + missionId: "m1", + runId: "run-fin", + goal: "Build X", + patch: { + finalization: { + policy: { + kind: "integration", + targetBranch: "main", + draft: false, + prDepth: null, + autoRebase: true, + ciGating: true, + autoLand: false, + autoResolveConflicts: false, + archiveLaneOnLand: true, + mergeMethod: "squash", + conflictResolverModel: null, + reasoningEffort: null, + description: null, + }, + status: "creating_pr", + executionComplete: true, + contractSatisfied: false, + blocked: false, + blockedReason: null, + summary: "Creating PR", + detail: null, + resolverJobId: null, + integrationLaneId: null, + resultLaneId: null, + queueGroupId: null, + queueId: null, + activePrId: null, + waitReason: null, + proposalUrl: null, + prUrls: [], + reviewStatus: null, + mergeReadiness: null, + requirements: [], + warnings: [], + updatedAt: "2026-03-25T12:00:00.000Z", + startedAt: "2026-03-25T12:00:00.000Z", + completedAt: null, + }, + }, + }); + expect(result.finalization).not.toBeNull(); + expect(result.finalization!.status).toBe("creating_pr"); + expect(result.finalization!.policy.kind).toBe("integration"); + + const cleared = await updateMissionStateDocument({ + projectRoot: tmpDir, + missionId: "m1", + runId: "run-fin", + goal: "Build X", + patch: { finalization: null }, + }); + expect(cleared.finalization).toBeNull(); + }); + }); + + describe("readMissionStateDocument", () => { + it("returns null when no document exists", async () => { + const doc = await readMissionStateDocument({ + projectRoot: tmpDir, + runId: "nonexistent-run", + }); + expect(doc).toBeNull(); + }); + + it("reads back a previously written document", async () => { + await updateMissionStateDocument({ + projectRoot: tmpDir, + missionId: "m1", + runId: "run-read-test", + goal: "Readable goal", + patch: { updateProgress: { currentPhase: "testing" } }, + }); + + const doc = await readMissionStateDocument({ + projectRoot: tmpDir, + runId: "run-read-test", + }); + expect(doc).not.toBeNull(); + expect(doc!.goal).toBe("Readable goal"); + expect(doc!.progress.currentPhase).toBe("testing"); + }); + }); + + describe("writeCoordinatorCheckpoint / readCoordinatorCheckpoint", () => { + it("writes and reads a checkpoint", async () => { + await writeCoordinatorCheckpoint(tmpDir, "run-cp-1", { + version: 1, + runId: "run-cp-1", + missionId: "m1", + conversationSummary: "Worker completed step 1", + lastEventTimestamp: "2026-03-25T12:00:00.000Z", + turnCount: 5, + compactionCount: 1, + savedAt: "2026-03-25T12:01:00.000Z", + }); + + const cp = await readCoordinatorCheckpoint(tmpDir, "run-cp-1"); + expect(cp).not.toBeNull(); + expect(cp!.missionId).toBe("m1"); + expect(cp!.conversationSummary).toBe("Worker completed step 1"); + expect(cp!.turnCount).toBe(5); + expect(cp!.compactionCount).toBe(1); + }); + + it("returns null for non-existent checkpoint", async () => { + const cp = await readCoordinatorCheckpoint(tmpDir, "nonexistent"); + expect(cp).toBeNull(); + }); + + it("rejects invalid checkpoint payload", async () => { + await expect( + writeCoordinatorCheckpoint(tmpDir, "run-bad", { + version: 1, + runId: "", + missionId: "", + conversationSummary: "", + lastEventTimestamp: null, + turnCount: 0, + compactionCount: 0, + savedAt: "", + }), + ).rejects.toThrow("Invalid coordinator checkpoint payload"); + }); + + it("truncates oversized conversation summaries", async () => { + const longSummary = "x".repeat(10_000); + await writeCoordinatorCheckpoint(tmpDir, "run-long", { + version: 1, + runId: "run-long", + missionId: "m1", + conversationSummary: longSummary, + lastEventTimestamp: null, + turnCount: 0, + compactionCount: 0, + savedAt: "2026-03-25T12:00:00.000Z", + }); + const cp = await readCoordinatorCheckpoint(tmpDir, "run-long"); + expect(cp).not.toBeNull(); + expect(cp!.conversationSummary.length).toBeLessThanOrEqual(8_000); + }); + }); + + describe("deleteCoordinatorCheckpoint", () => { + it("deletes an existing checkpoint", async () => { + await writeCoordinatorCheckpoint(tmpDir, "run-del", { + version: 1, + runId: "run-del", + missionId: "m1", + conversationSummary: "Test", + lastEventTimestamp: null, + turnCount: 1, + compactionCount: 0, + savedAt: "2026-03-25T12:00:00.000Z", + }); + + await deleteCoordinatorCheckpoint(tmpDir, "run-del"); + const cp = await readCoordinatorCheckpoint(tmpDir, "run-del"); + expect(cp).toBeNull(); + }); + + it("does not throw when deleting a non-existent checkpoint", async () => { + await expect( + deleteCoordinatorCheckpoint(tmpDir, "nonexistent"), + ).resolves.toBeUndefined(); + }); + }); +}); diff --git a/apps/desktop/src/main/services/orchestrator/missionBudgetService.test.ts b/apps/desktop/src/main/services/orchestrator/missionBudgetService.test.ts deleted file mode 100644 index 91ec8cbb6..000000000 --- a/apps/desktop/src/main/services/orchestrator/missionBudgetService.test.ts +++ /dev/null @@ -1,598 +0,0 @@ -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; -import { randomUUID } from "node:crypto"; -import { describe, expect, it } from "vitest"; -import { openKvDb } from "../state/kvDb"; -import { createMissionService } from "../missions/missionService"; -import { createBuiltInPhaseCards } from "../missions/phaseEngine"; -import { createOrchestratorService } from "./orchestratorService"; -import { createMissionBudgetService } from "./missionBudgetService"; -import type { PackExport, PackType } from "../../../shared/types"; - -function createLogger() { - return { - debug: () => {}, - info: () => {}, - warn: () => {}, - error: () => {} - } as any; -} - -function buildExport(packKey: string, packType: PackType, level: "lite" | "standard" | "deep"): PackExport { - return { - packKey, - packType, - level, - header: {} as any, - content: `${packKey}:${level}`, - approxTokens: 16, - maxTokens: 500, - truncated: false, - warnings: [], - clipReason: null, - omittedSections: null - }; -} - -async function createDbWithProjectAndLane() { - const root = fs.mkdtempSync(path.join(os.tmpdir(), "ade-mission-budget-")); - const dbPath = path.join(root, "ade.db"); - const db = await openKvDb(dbPath, createLogger()); - - const projectId = "proj-1"; - const laneId = "lane-1"; - const now = "2026-02-18T00:00:00.000Z"; - - db.run( - ` - insert into projects(id, root_path, display_name, default_base_ref, created_at, last_opened_at) - values (?, ?, ?, ?, ?, ?) - `, - [projectId, root, "ADE", "main", now, now] - ); - - db.run( - ` - insert into lanes( - id, - project_id, - name, - description, - lane_type, - base_ref, - branch_ref, - worktree_path, - attached_root_path, - is_edit_protected, - parent_lane_id, - color, - icon, - tags_json, - status, - created_at, - archived_at - ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - `, - [ - laneId, - projectId, - "Lane 1", - null, - "worktree", - "main", - "feature/lane-1", - root, - null, - 0, - null, - null, - null, - null, - "active", - now, - null - ] - ); - - return { - db, - projectId, - laneId, - root, - dispose: () => db.close() - }; -} - -function writeCodexSessionLog(args: { - sessionsRoot: string; - cwd: string; - timestampIso: string; - model?: string; - inputTokens: number; - outputTokens: number; -}): void { - const date = new Date(args.timestampIso); - const yyyy = String(date.getUTCFullYear()); - const mm = String(date.getUTCMonth() + 1).padStart(2, "0"); - const dd = String(date.getUTCDate()).padStart(2, "0"); - const dayDir = path.join(args.sessionsRoot, yyyy, mm, dd); - fs.mkdirSync(dayDir, { recursive: true }); - const filePath = path.join(dayDir, "rollout-test.jsonl"); - const model = args.model ?? "openai/gpt-5"; - const lines = [ - JSON.stringify({ - timestamp: args.timestampIso, - type: "session_meta", - payload: { - id: "session-test-1", - cwd: args.cwd, - }, - }), - JSON.stringify({ - timestamp: args.timestampIso, - type: "turn_context", - payload: { - cwd: args.cwd, - model, - }, - }), - JSON.stringify({ - timestamp: args.timestampIso, - type: "event_msg", - payload: { - type: "token_count", - info: { - total_token_usage: { - input_tokens: args.inputTokens, - output_tokens: args.outputTokens, - cached_input_tokens: 0, - reasoning_output_tokens: 0, - total_tokens: args.inputTokens + args.outputTokens, - }, - last_token_usage: { - input_tokens: args.inputTokens, - output_tokens: args.outputTokens, - cached_input_tokens: 0, - reasoning_output_tokens: 0, - total_tokens: args.inputTokens + args.outputTokens, - }, - }, - }, - }), - ]; - fs.writeFileSync(filePath, `${lines.join("\n")}\n`, "utf8"); -} - -function writeClaudeProjectLog(args: { - projectsRoot: string; - timestampIso: string; - model?: string; - inputTokens: number; - outputTokens: number; -}): void { - const projectDir = path.join(args.projectsRoot, "-tmp-test-project"); - fs.mkdirSync(projectDir, { recursive: true }); - const filePath = path.join(projectDir, "session.jsonl"); - const model = args.model ?? "anthropic/claude-sonnet-4-6"; - const line = JSON.stringify({ - timestamp: args.timestampIso, - message: { - model, - usage: { - input_tokens: args.inputTokens, - output_tokens: args.outputTokens, - cache_read_input_tokens: 0, - cache_creation_input_tokens: 0, - }, - }, - }); - fs.writeFileSync(filePath, `${line}\n`, "utf8"); -} - -describe("missionBudgetService", () => { - it("flags API-key launch estimate when projected spend exceeds remaining envelope", async () => { - const { db, projectId, root, dispose } = await createDbWithProjectAndLane(); - const missionService = createMissionService({ db, projectId }); - const budgetService = createMissionBudgetService({ - db, - logger: createLogger(), - projectId, - projectRoot: root, - missionService, - aiIntegrationService: { - getStatus: async () => ({ mode: "api-key", detectedAuth: [] }) - } as any - }); - - const estimate = await budgetService.estimateLaunchBudget({ - launch: { - prompt: "Implement a complex mission pipeline.", - modelConfig: { - orchestratorModel: { - provider: "claude", - modelId: "claude-sonnet-4-6" - }, - smartBudget: { - enabled: true, - fiveHourThresholdUsd: 0.05, - weeklyThresholdUsd: 1 - } - } - }, - selectedPhases: createBuiltInPhaseCards().map((phase) => ({ - ...phase, - budget: { - maxTokens: 4_000 - } - })) - }); - - expect(estimate.estimate.mode).toBe("api-key"); - expect(estimate.estimate.estimatedCostUsd).toBeGreaterThan(0); - expect(estimate.hardLimitExceeded).toBe(true); - - dispose(); - }); - - it("uses local Codex session telemetry for subscription preflight and runtime budget snapshots", async () => { - const { db, projectId, laneId, root, dispose } = await createDbWithProjectAndLane(); - const missionService = createMissionService({ db, projectId }); - const mission = missionService.create({ - prompt: "Use subscription telemetry.", - laneId, - phaseOverride: createBuiltInPhaseCards(), - }); - const codexSessionsRoot = path.join(root, "codex-sessions"); - const nowIso = new Date().toISOString(); - writeCodexSessionLog({ - sessionsRoot: codexSessionsRoot, - cwd: root, - timestampIso: nowIso, - model: "openai/gpt-5", - inputTokens: 100_000, - outputTokens: 128_750, - }); - - const budgetService = createMissionBudgetService({ - db, - logger: createLogger(), - projectId, - projectRoot: root, - missionService, - aiIntegrationService: { - getStatus: async () => ({ mode: "subscription", detectedAuth: [{ type: "cli-subscription", authenticated: true, cli: "codex" }] }) - } as any, - projectConfigService: { - get: () => ({ - effective: { - cto: { - budgetTelemetry: { - enabled: true, - codexSessionsRoot, - claudeProjectsRoot: path.join(root, "no-claude-logs"), - }, - }, - }, - }), - } as any, - }); - - const estimate = await budgetService.estimateLaunchBudget({ - launch: { - prompt: "Run with local CLI subscription telemetry.", - modelConfig: { - orchestratorModel: { - provider: "codex", - modelId: "openai/gpt-5", - }, - }, - }, - selectedPhases: createBuiltInPhaseCards(), - }); - expect(estimate.estimate.mode).toBe("subscription"); - expect(estimate.estimate.actualSpendUsd).toBeCloseTo(1.23, 6); - expect(estimate.estimate.burnRateUsdPerHour ?? null).toBeNull(); - expect(estimate.estimate.note ?? "").toContain("local CLI telemetry"); - - const snapshot = await budgetService.getMissionBudgetStatus({ - missionId: mission.id, - }); - expect(snapshot.mode).toBe("subscription"); - const codexProvider = snapshot.perProvider.find((provider) => provider.provider === "codex"); - expect(codexProvider?.fiveHour.usedCostUsd).toBeCloseTo(1.23, 6); - expect(snapshot.burnRateUsdPerHour).toBeNull(); - expect(snapshot.dataSources).toContain("~/.codex/sessions/*.jsonl"); - - dispose(); - }); - - it("returns per-phase and per-worker budget snapshot with pressure", async () => { - const { db, projectId, laneId, root, dispose } = await createDbWithProjectAndLane(); - const missionService = createMissionService({ db, projectId }); - const phaseOverride = createBuiltInPhaseCards().map((phase) => ({ - ...phase, - budget: { - maxTokens: 100, - maxTimeMs: 60_000 - } - })); - const mission = missionService.create({ - prompt: "Build and validate mission budget usage telemetry.", - laneId, - phaseOverride - }); - - const orchestratorService = createOrchestratorService({ - db, - projectId, - projectRoot: root, - }); - - const started = orchestratorService.startRun({ - missionId: mission.id, - metadata: {}, - steps: [ - { - stepKey: "planning-1", - stepIndex: 0, - title: "Planning task", - laneId, - dependencyStepKeys: [], - retryLimit: 1, - executorKind: "manual", - metadata: { - phaseKey: phaseOverride[0]!.phaseKey, - phaseName: phaseOverride[0]!.name - } - } - ] - }); - - const step = started.steps[0]!; - db.run( - `update orchestrator_steps set status = 'ready', updated_at = ? where id = ? and run_id = ?`, - [new Date().toISOString(), step.id, started.run.id] - ); - - const attempt = await orchestratorService.startAttempt({ - runId: started.run.id, - stepId: step.id, - ownerId: "test-owner", - executorKind: "manual" - }); - const sessionId = `session-${randomUUID()}`; - db.run( - `update orchestrator_attempts set executor_session_id = ? where id = ? and run_id = ?`, - [sessionId, attempt.id, started.run.id] - ); - db.run( - ` - insert into ai_usage_log( - id, - timestamp, - feature, - provider, - model, - input_tokens, - output_tokens, - duration_ms, - success, - session_id - ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - `, - [ - randomUUID(), - new Date().toISOString(), - "orchestrator", - "claude", - "anthropic/claude-sonnet-4-6", - 250, - 200, - 45_000, - 1, - sessionId - ] - ); - - const budgetService = createMissionBudgetService({ - db, - logger: createLogger(), - projectId, - projectRoot: root, - missionService, - aiIntegrationService: { - getStatus: async () => ({ mode: "api-key", detectedAuth: [] }) - } as any - }); - - const snapshot = await budgetService.getMissionBudgetStatus({ - missionId: mission.id, - runId: started.run.id - }); - - expect(snapshot.mode).toBe("api-key"); - expect(snapshot.pressure).toBe("critical"); - expect(snapshot.mission.usedTokens).toBe(450); - expect(snapshot.perPhase.length).toBeGreaterThan(0); - expect(snapshot.perWorker.length).toBeGreaterThan(0); - expect(snapshot.dataSources).toContain("ai_usage_log"); - - dispose(); - }); - - it("enforces subscription hard caps only for providers selected by mission models", async () => { - const { db, projectId, laneId, root, dispose } = await createDbWithProjectAndLane(); - const missionService = createMissionService({ db, projectId }); - const codexSessionsRoot = path.join(root, "codex-sessions"); - const claudeProjectsRoot = path.join(root, "claude-projects"); - const nowIso = new Date().toISOString(); - writeCodexSessionLog({ - sessionsRoot: codexSessionsRoot, - cwd: root, - timestampIso: nowIso, - model: "openai/gpt-5.3-codex", - inputTokens: 500, - outputTokens: 500, - }); - writeClaudeProjectLog({ - projectsRoot: claudeProjectsRoot, - timestampIso: nowIso, - model: "anthropic/claude-sonnet-4-6", - inputTokens: 5_000, - outputTokens: 5_000, - }); - - const phaseOverride = createBuiltInPhaseCards().map((phase, index) => ({ - ...phase, - model: { - ...phase.model, - provider: "codex", - modelId: "openai/gpt-5.3-codex", - }, - position: index, - })); - const mission = missionService.create({ - prompt: "Codex-only mission with provider-scoped hard caps.", - laneId, - phaseOverride, - modelConfig: { - orchestratorModel: { - provider: "codex", - modelId: "openai/gpt-5.3-codex", - }, - smartBudget: { - enabled: true, - fiveHourThresholdUsd: 10, - weeklyThresholdUsd: 40, - fiveHourHardStopPercent: 80, - weeklyHardStopPercent: 90, - providerLimits: { - codex: { fiveHourTokenLimit: 10_000, weeklyTokenLimit: 10_000 }, - claude: { fiveHourTokenLimit: 1_000, weeklyTokenLimit: 1_000 }, - }, - }, - }, - }); - - const budgetService = createMissionBudgetService({ - db, - logger: createLogger(), - projectId, - projectRoot: root, - missionService, - aiIntegrationService: { - getStatus: async () => ({ mode: "subscription", detectedAuth: [{ type: "cli-subscription", authenticated: true, cli: "codex" }] }) - } as any, - projectConfigService: { - get: () => ({ - effective: { - cto: { - budgetTelemetry: { - enabled: true, - codexSessionsRoot, - claudeProjectsRoot, - }, - }, - }, - }), - } as any, - }); - - const snapshot = await budgetService.getMissionBudgetStatus({ missionId: mission.id }); - const claudeProvider = snapshot.perProvider.find((provider) => provider.provider === "claude"); - expect((claudeProvider?.fiveHour.usedPct ?? 0)).toBeGreaterThan(80); - expect(snapshot.hardCaps.fiveHourTriggered).toBe(false); - expect(snapshot.hardCaps.weeklyTriggered).toBe(false); - - const telemetry = budgetService.getMissionBudgetTelemetry({ - providers: ["codex"], - providerLimits: { - codex: { fiveHourTokenLimit: 10_000, weeklyTokenLimit: 10_000 }, - }, - }); - expect(telemetry.perProvider).toHaveLength(1); - expect(telemetry.perProvider[0]?.provider).toBe("codex"); - expect(telemetry.perProvider[0]?.fiveHour.usedTokens ?? 0).toBeGreaterThan(0); - - dispose(); - }); - - it("disables hard-stop enforcement when smart budget toggle is off", async () => { - const { db, projectId, laneId, root, dispose } = await createDbWithProjectAndLane(); - const missionService = createMissionService({ db, projectId }); - const codexSessionsRoot = path.join(root, "codex-sessions"); - const nowIso = new Date().toISOString(); - writeCodexSessionLog({ - sessionsRoot: codexSessionsRoot, - cwd: root, - timestampIso: nowIso, - model: "openai/gpt-5.3-codex", - inputTokens: 9_000, - outputTokens: 9_000, - }); - const phaseOverride = createBuiltInPhaseCards().map((phase, index) => ({ - ...phase, - model: { - ...phase.model, - provider: "codex", - modelId: "openai/gpt-5.3-codex", - }, - position: index, - })); - const mission = missionService.create({ - prompt: "Mission with smart budget disabled.", - laneId, - phaseOverride, - modelConfig: { - orchestratorModel: { - provider: "codex", - modelId: "openai/gpt-5.3-codex", - }, - smartBudget: { - enabled: false, - fiveHourThresholdUsd: 10, - weeklyThresholdUsd: 40, - fiveHourHardStopPercent: 80, - weeklyHardStopPercent: 90, - providerLimits: { - codex: { fiveHourTokenLimit: 1_000, weeklyTokenLimit: 1_000 }, - }, - }, - }, - }); - - const budgetService = createMissionBudgetService({ - db, - logger: createLogger(), - projectId, - projectRoot: root, - missionService, - aiIntegrationService: { - getStatus: async () => ({ mode: "subscription", detectedAuth: [{ type: "cli-subscription", authenticated: true, cli: "codex" }] }) - } as any, - projectConfigService: { - get: () => ({ - effective: { - cto: { - budgetTelemetry: { - enabled: true, - codexSessionsRoot, - }, - }, - }, - }), - } as any, - }); - - const snapshot = await budgetService.getMissionBudgetStatus({ missionId: mission.id }); - expect(snapshot.hardCaps.fiveHourHardStopPercent).toBeNull(); - expect(snapshot.hardCaps.weeklyHardStopPercent).toBeNull(); - expect(snapshot.hardCaps.apiKeyMaxSpendUsd).toBeNull(); - expect(snapshot.hardCaps.fiveHourTriggered).toBe(false); - expect(snapshot.hardCaps.weeklyTriggered).toBe(false); - expect(snapshot.hardCaps.apiKeyTriggered).toBe(false); - - dispose(); - }); -}); diff --git a/apps/desktop/src/main/services/orchestrator/missionLifecycle.test.ts b/apps/desktop/src/main/services/orchestrator/missionLifecycle.test.ts deleted file mode 100644 index 6d2103678..000000000 --- a/apps/desktop/src/main/services/orchestrator/missionLifecycle.test.ts +++ /dev/null @@ -1,275 +0,0 @@ -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; -import { describe, expect, it } from "vitest"; -import { createOrchestratorService } from "./orchestratorService"; -import { transitionMissionStatus } from "./missionLifecycle"; -import { createMissionService } from "../missions/missionService"; -import { openKvDb } from "../state/kvDb"; - -// ───────────────────────────────────────────────────── -// Helpers -// ───────────────────────────────────────────────────── - -function createLogger() { - return { - debug: () => {}, - info: () => {}, - warn: () => {}, - error: () => {}, - } as any; -} - -async function createFixture(initialStatus: string = "in_progress") { - const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-lifecycle-regression-")); - const db = await openKvDb(path.join(projectRoot, "ade.db"), createLogger()); - const projectId = "proj-1"; - const laneId = "lane-1"; - const missionId = "mission-1"; - const now = "2026-03-10T00:00:00.000Z"; - - db.run( - `insert into projects(id, root_path, display_name, default_base_ref, created_at, last_opened_at) - values (?, ?, ?, ?, ?, ?)`, - [projectId, projectRoot, "ADE", "main", now, now] - ); - - db.run( - `insert into lanes( - id, project_id, name, description, lane_type, base_ref, branch_ref, - worktree_path, attached_root_path, is_edit_protected, parent_lane_id, - color, icon, tags_json, status, created_at, archived_at - ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, - [ - laneId, projectId, "Lane 1", null, "worktree", "main", "feature/lane-1", - projectRoot, null, 0, null, null, null, null, "active", now, null, - ] - ); - - db.run( - `insert into missions( - id, project_id, lane_id, title, prompt, status, priority, - execution_mode, target_machine_id, outcome_summary, last_error, - metadata_json, created_at, updated_at, started_at, completed_at - ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, - [ - missionId, projectId, laneId, "Lifecycle Regression Test", - "Test lifecycle regression guard.", initialStatus, "normal", "local", - null, null, null, null, now, now, now, null, - ] - ); - - const orchestratorService = createOrchestratorService({ - db, - projectId, - projectRoot, - ptyService: { - create: async () => ({ ptyId: "pty-1", sessionId: "session-1" }), - } as any, - projectConfigService: null as any, - aiIntegrationService: null as any, - memoryService: null as any, - }); - - const missionService = createMissionService({ db, projectId }); - - const ctx = { - db, - logger: createLogger(), - missionService, - orchestratorService, - projectRoot, - hookCommandRunner: async () => ({ - exitCode: 0, - signal: null, - timedOut: false, - durationMs: 0, - stdout: "", - stderr: "", - spawnError: null, - }), - agentChatService: null, - laneService: null, - projectConfigService: null, - aiIntegrationService: null, - prService: null, - missionBudgetService: null, - onThreadEvent: undefined, - onDagMutation: undefined, - syncLocks: new Set(), - workerStates: new Map(), - activeSteeringDirectives: new Map(), - runRuntimeProfiles: new Map(), - chatMessages: new Map(), - activeChatSessions: new Map(), - chatTurnQueues: new Map(), - activeHealthSweepRuns: new Set(), - sessionRuntimeSignals: new Map(), - attemptRuntimeTrackers: new Map(), - sessionSignalQueues: new Map(), - workerDeliveryThreadQueues: new Map(), - workerDeliveryInterventionCooldowns: new Map(), - runTeamManifests: new Map(), - runRecoveryLoopStates: new Map(), - aiTimeoutBudgetStepLocks: new Set(), - aiTimeoutBudgetRunLocks: new Set(), - aiRetryDecisionLocks: new Set(), - coordinatorSessions: new Map(), - pendingIntegrations: new Map(), - coordinatorThinkingLoops: new Map(), - pendingCoordinatorEvals: new Map(), - coordinatorAgents: new Map(), - coordinatorRecoveryAttempts: new Map(), - teamRuntimeStates: new Map(), - callTypeConfigCache: new Map(), - disposed: { current: false }, - healthSweepTimer: { current: null }, - } as any; - - return { - ctx, - missionId, - missionService, - dispose: () => { - db.close(); - fs.rmSync(projectRoot, { recursive: true, force: true }); - }, - }; -} - -// ───────────────────────────────────────────────────── -// Terminal status regression guard tests -// ───────────────────────────────────────────────────── - -describe("transitionMissionStatus — terminal status regression guard", () => { - it("blocks transition from completed to in_progress", async () => { - const fixture = await createFixture("completed"); - try { - transitionMissionStatus(fixture.ctx, fixture.missionId, "in_progress"); - const mission = fixture.missionService.get(fixture.missionId); - expect(mission?.status).toBe("completed"); - } finally { - fixture.dispose(); - } - }); - - it("blocks transition from failed to running", async () => { - const fixture = await createFixture("failed"); - try { - transitionMissionStatus(fixture.ctx, fixture.missionId, "in_progress"); - const mission = fixture.missionService.get(fixture.missionId); - expect(mission?.status).toBe("failed"); - } finally { - fixture.dispose(); - } - }); - - it("blocks transition from canceled to in_progress", async () => { - const fixture = await createFixture("canceled"); - try { - transitionMissionStatus(fixture.ctx, fixture.missionId, "in_progress"); - const mission = fixture.missionService.get(fixture.missionId); - expect(mission?.status).toBe("canceled"); - } finally { - fixture.dispose(); - } - }); - - it("passes through the regression guard for terminal-to-terminal (completed to completed)", async () => { - // completed -> completed is a self-transition with no args, so it's a no-op - // but the regression guard itself does NOT block it - const fixture = await createFixture("completed"); - try { - transitionMissionStatus(fixture.ctx, fixture.missionId, "completed"); - const mission = fixture.missionService.get(fixture.missionId); - expect(mission?.status).toBe("completed"); - } finally { - fixture.dispose(); - } - }); - - it("does not throw for terminal-to-terminal even when missionService rejects it", async () => { - // completed -> failed passes the regression guard (both terminal), - // but missionService rejects it (not in MISSION_TRANSITIONS). - // transitionMissionStatus catches the error silently and status stays. - const fixture = await createFixture("completed"); - try { - transitionMissionStatus(fixture.ctx, fixture.missionId, "failed"); - const mission = fixture.missionService.get(fixture.missionId); - // Status stays completed because missionService rejects the transition - expect(mission?.status).toBe("completed"); - } finally { - fixture.dispose(); - } - }); - - it("allows failed -> canceled (valid in missionService transition table)", async () => { - const fixture = await createFixture("failed"); - try { - transitionMissionStatus(fixture.ctx, fixture.missionId, "canceled"); - const mission = fixture.missionService.get(fixture.missionId); - expect(mission?.status).toBe("canceled"); - } finally { - fixture.dispose(); - } - }); - - it("allows transition from non-terminal to terminal (in_progress to completed)", async () => { - const fixture = await createFixture("in_progress"); - try { - transitionMissionStatus(fixture.ctx, fixture.missionId, "completed", { - outcomeSummary: "All tasks done", - }); - const mission = fixture.missionService.get(fixture.missionId); - expect(mission?.status).toBe("completed"); - } finally { - fixture.dispose(); - } - }); - - it("allows transition from non-terminal to non-terminal (in_progress to intervention_required)", async () => { - const fixture = await createFixture("in_progress"); - try { - transitionMissionStatus(fixture.ctx, fixture.missionId, "intervention_required", { - lastError: "Needs human review", - }); - const mission = fixture.missionService.get(fixture.missionId); - expect(mission?.status).toBe("intervention_required"); - } finally { - fixture.dispose(); - } - }); - - it("blocks transition from completed to intervention_required (non-terminal)", async () => { - const fixture = await createFixture("completed"); - try { - transitionMissionStatus(fixture.ctx, fixture.missionId, "intervention_required"); - const mission = fixture.missionService.get(fixture.missionId); - expect(mission?.status).toBe("completed"); - } finally { - fixture.dispose(); - } - }); - - it("no-ops when transitioning to the same status with no args", async () => { - const fixture = await createFixture("in_progress"); - try { - // Same status, no outcomeSummary or lastError => early return (no-op) - transitionMissionStatus(fixture.ctx, fixture.missionId, "in_progress"); - const mission = fixture.missionService.get(fixture.missionId); - expect(mission?.status).toBe("in_progress"); - } finally { - fixture.dispose(); - } - }); - - it("returns silently for a non-existent mission", async () => { - const fixture = await createFixture("in_progress"); - try { - // Should not throw, just return - transitionMissionStatus(fixture.ctx, "non-existent-mission-id", "completed"); - } finally { - fixture.dispose(); - } - }); -}); diff --git a/apps/desktop/src/main/services/orchestrator/missionStateDoc.test.ts b/apps/desktop/src/main/services/orchestrator/missionStateDoc.test.ts deleted file mode 100644 index dedc287fb..000000000 --- a/apps/desktop/src/main/services/orchestrator/missionStateDoc.test.ts +++ /dev/null @@ -1,412 +0,0 @@ -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; -import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { - createInitialMissionStateDocument, - getMissionStateDocumentPath, - getCoordinatorCheckpointPath, - updateMissionStateDocument, - readMissionStateDocument, - writeCoordinatorCheckpoint, - readCoordinatorCheckpoint, - deleteCoordinatorCheckpoint, -} from "./missionStateDoc"; - -let tmpDir: string; - -beforeEach(() => { - tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "ade-msd-test-")); - // Create .ade/cache/mission-state directory structure expected by resolveAdeLayout - const missionStateDir = path.join(tmpDir, ".ade", "cache", "mission-state"); - fs.mkdirSync(missionStateDir, { recursive: true }); -}); - -afterEach(() => { - fs.rmSync(tmpDir, { recursive: true, force: true }); -}); - -describe("getMissionStateDocumentPath", () => { - it("returns path under .ade/cache/mission-state", () => { - const result = getMissionStateDocumentPath(tmpDir, "run-abc"); - expect(result).toContain("mission-state"); - expect(result).toContain("mission-state-run-abc.json"); - }); -}); - -describe("getCoordinatorCheckpointPath", () => { - it("returns path under .ade/cache/mission-state", () => { - const result = getCoordinatorCheckpointPath(tmpDir, "run-abc"); - expect(result).toContain("mission-state"); - expect(result).toContain("coordinator-checkpoint-run-abc.json"); - }); -}); - -describe("createInitialMissionStateDocument", () => { - it("creates a document with schemaVersion 1 and empty collections", () => { - const doc = createInitialMissionStateDocument({ - missionId: "mission-1", - runId: "run-1", - goal: "Build feature X", - }); - expect(doc.schemaVersion).toBe(1); - expect(doc.missionId).toBe("mission-1"); - expect(doc.runId).toBe("run-1"); - expect(doc.goal).toBe("Build feature X"); - expect(doc.stepOutcomes).toEqual([]); - expect(doc.decisions).toEqual([]); - expect(doc.activeIssues).toEqual([]); - expect(doc.modifiedFiles).toEqual([]); - expect(doc.pendingInterventions).toEqual([]); - expect(doc.reflections).toEqual([]); - expect(doc.latestRetrospective).toBeNull(); - }); - - it("initializes progress with defaults when not provided", () => { - const doc = createInitialMissionStateDocument({ - missionId: "m1", - runId: "r1", - goal: "Test", - }); - expect(doc.progress.currentPhase).toBe("unknown"); - expect(doc.progress.completedSteps).toBe(0); - expect(doc.progress.totalSteps).toBe(0); - expect(doc.progress.activeWorkers).toEqual([]); - expect(doc.progress.blockedSteps).toEqual([]); - expect(doc.progress.failedSteps).toEqual([]); - }); - - it("accepts partial progress overrides", () => { - const doc = createInitialMissionStateDocument({ - missionId: "m1", - runId: "r1", - goal: "Test", - progress: { currentPhase: "development", totalSteps: 5 }, - }); - expect(doc.progress.currentPhase).toBe("development"); - expect(doc.progress.totalSteps).toBe(5); - expect(doc.progress.completedSteps).toBe(0); - }); -}); - -describe("updateMissionStateDocument", () => { - it("creates a new document and applies the patch", async () => { - const result = await updateMissionStateDocument({ - projectRoot: tmpDir, - missionId: "m1", - runId: "run-update-1", - goal: "Build X", - patch: { - updateProgress: { - currentPhase: "development", - totalSteps: 3, - }, - }, - }); - expect(result.missionId).toBe("m1"); - expect(result.progress.currentPhase).toBe("development"); - expect(result.progress.totalSteps).toBe(3); - }); - - it("adds a step outcome", async () => { - const result = await updateMissionStateDocument({ - projectRoot: tmpDir, - missionId: "m1", - runId: "run-step-outcome", - goal: "Build X", - patch: { - addStepOutcome: { - stepKey: "step-1", - stepName: "Implement auth", - phase: "development", - status: "succeeded", - summary: "Auth module completed", - filesChanged: ["src/auth.ts"], - warnings: [], - completedAt: "2026-03-25T12:00:00.000Z", - }, - }, - }); - expect(result.stepOutcomes).toHaveLength(1); - expect(result.stepOutcomes[0].stepKey).toBe("step-1"); - expect(result.stepOutcomes[0].status).toBe("succeeded"); - expect(result.stepOutcomes[0].filesChanged).toEqual(["src/auth.ts"]); - expect(result.modifiedFiles).toContain("src/auth.ts"); - }); - - it("merges step outcome updates on existing step", async () => { - // First create with a step outcome - await updateMissionStateDocument({ - projectRoot: tmpDir, - missionId: "m1", - runId: "run-merge-outcome", - goal: "Build X", - patch: { - addStepOutcome: { - stepKey: "step-1", - stepName: "Implement auth", - phase: "development", - status: "in_progress", - summary: "Started", - filesChanged: ["src/auth.ts"], - warnings: [], - completedAt: null, - }, - }, - }); - - // Then update the same step - const result = await updateMissionStateDocument({ - projectRoot: tmpDir, - missionId: "m1", - runId: "run-merge-outcome", - goal: "Build X", - patch: { - addStepOutcome: { - stepKey: "step-1", - stepName: "Implement auth", - phase: "development", - status: "succeeded", - summary: "Completed auth module", - filesChanged: ["src/auth.ts", "src/middleware.ts"], - warnings: [], - completedAt: "2026-03-25T13:00:00.000Z", - }, - }, - }); - expect(result.stepOutcomes).toHaveLength(1); - expect(result.stepOutcomes[0].status).toBe("succeeded"); - expect(result.stepOutcomes[0].summary).toBe("Completed auth module"); - }); - - it("adds decisions", async () => { - const result = await updateMissionStateDocument({ - projectRoot: tmpDir, - missionId: "m1", - runId: "run-decision", - goal: "Build X", - patch: { - addDecision: { - timestamp: "2026-03-25T12:00:00.000Z", - decision: "Use JWT for auth", - rationale: "Standard approach", - context: "Architecture decision", - }, - }, - }); - expect(result.decisions).toHaveLength(1); - expect(result.decisions[0].decision).toBe("Use JWT for auth"); - }); - - it("adds and resolves issues", async () => { - // Add an issue - await updateMissionStateDocument({ - projectRoot: tmpDir, - missionId: "m1", - runId: "run-issue", - goal: "Build X", - patch: { - addIssue: { - id: "issue-1", - severity: "high", - description: "Auth module failing tests", - affectedSteps: ["step-1"], - status: "open", - }, - }, - }); - - // Resolve it - const result = await updateMissionStateDocument({ - projectRoot: tmpDir, - missionId: "m1", - runId: "run-issue", - goal: "Build X", - patch: { - resolveIssue: { - id: "issue-1", - resolution: "Fixed the test setup", - }, - }, - }); - expect(result.activeIssues).toHaveLength(1); - expect(result.activeIssues[0].status).toBe("resolved"); - expect(result.decisions.some((d) => d.decision.includes("Resolved issue issue-1"))).toBe(true); - }); - - it("sets and clears finalization state", async () => { - const result = await updateMissionStateDocument({ - projectRoot: tmpDir, - missionId: "m1", - runId: "run-fin", - goal: "Build X", - patch: { - finalization: { - policy: { - kind: "integration", - targetBranch: "main", - draft: false, - prDepth: null, - autoRebase: true, - ciGating: true, - autoLand: false, - autoResolveConflicts: false, - archiveLaneOnLand: true, - mergeMethod: "squash", - conflictResolverModel: null, - reasoningEffort: null, - description: null, - }, - status: "creating_pr", - executionComplete: true, - contractSatisfied: false, - blocked: false, - blockedReason: null, - summary: "Creating PR", - detail: null, - resolverJobId: null, - integrationLaneId: null, - resultLaneId: null, - queueGroupId: null, - queueId: null, - activePrId: null, - waitReason: null, - proposalUrl: null, - prUrls: [], - reviewStatus: null, - mergeReadiness: null, - requirements: [], - warnings: [], - updatedAt: "2026-03-25T12:00:00.000Z", - startedAt: "2026-03-25T12:00:00.000Z", - completedAt: null, - }, - }, - }); - expect(result.finalization).not.toBeNull(); - expect(result.finalization!.status).toBe("creating_pr"); - expect(result.finalization!.policy.kind).toBe("integration"); - - // Clear it - const cleared = await updateMissionStateDocument({ - projectRoot: tmpDir, - missionId: "m1", - runId: "run-fin", - goal: "Build X", - patch: { finalization: null }, - }); - expect(cleared.finalization).toBeNull(); - }); -}); - -describe("readMissionStateDocument", () => { - it("returns null when no document exists", async () => { - const doc = await readMissionStateDocument({ - projectRoot: tmpDir, - runId: "nonexistent-run", - }); - expect(doc).toBeNull(); - }); - - it("reads back a previously written document", async () => { - await updateMissionStateDocument({ - projectRoot: tmpDir, - missionId: "m1", - runId: "run-read-test", - goal: "Readable goal", - patch: { updateProgress: { currentPhase: "testing" } }, - }); - - const doc = await readMissionStateDocument({ - projectRoot: tmpDir, - runId: "run-read-test", - }); - expect(doc).not.toBeNull(); - expect(doc!.goal).toBe("Readable goal"); - expect(doc!.progress.currentPhase).toBe("testing"); - }); -}); - -describe("writeCoordinatorCheckpoint / readCoordinatorCheckpoint", () => { - it("writes and reads a checkpoint", async () => { - await writeCoordinatorCheckpoint(tmpDir, "run-cp-1", { - version: 1, - runId: "run-cp-1", - missionId: "m1", - conversationSummary: "Worker completed step 1", - lastEventTimestamp: "2026-03-25T12:00:00.000Z", - turnCount: 5, - compactionCount: 1, - savedAt: "2026-03-25T12:01:00.000Z", - }); - - const cp = await readCoordinatorCheckpoint(tmpDir, "run-cp-1"); - expect(cp).not.toBeNull(); - expect(cp!.missionId).toBe("m1"); - expect(cp!.conversationSummary).toBe("Worker completed step 1"); - expect(cp!.turnCount).toBe(5); - expect(cp!.compactionCount).toBe(1); - }); - - it("returns null for non-existent checkpoint", async () => { - const cp = await readCoordinatorCheckpoint(tmpDir, "nonexistent"); - expect(cp).toBeNull(); - }); - - it("rejects invalid checkpoint payload", async () => { - await expect( - writeCoordinatorCheckpoint(tmpDir, "run-bad", { - version: 1, - runId: "", - missionId: "", - conversationSummary: "", - lastEventTimestamp: null, - turnCount: 0, - compactionCount: 0, - savedAt: "", - }), - ).rejects.toThrow("Invalid coordinator checkpoint payload"); - }); - - it("truncates oversized conversation summaries", async () => { - const longSummary = "x".repeat(10_000); - await writeCoordinatorCheckpoint(tmpDir, "run-long", { - version: 1, - runId: "run-long", - missionId: "m1", - conversationSummary: longSummary, - lastEventTimestamp: null, - turnCount: 0, - compactionCount: 0, - savedAt: "2026-03-25T12:00:00.000Z", - }); - const cp = await readCoordinatorCheckpoint(tmpDir, "run-long"); - expect(cp).not.toBeNull(); - expect(cp!.conversationSummary.length).toBeLessThanOrEqual(8_000); - }); -}); - -describe("deleteCoordinatorCheckpoint", () => { - it("deletes an existing checkpoint", async () => { - await writeCoordinatorCheckpoint(tmpDir, "run-del", { - version: 1, - runId: "run-del", - missionId: "m1", - conversationSummary: "Test", - lastEventTimestamp: null, - turnCount: 1, - compactionCount: 0, - savedAt: "2026-03-25T12:00:00.000Z", - }); - - await deleteCoordinatorCheckpoint(tmpDir, "run-del"); - const cp = await readCoordinatorCheckpoint(tmpDir, "run-del"); - expect(cp).toBeNull(); - }); - - it("does not throw when deleting a non-existent checkpoint", async () => { - await expect( - deleteCoordinatorCheckpoint(tmpDir, "nonexistent"), - ).resolves.toBeUndefined(); - }); -}); diff --git a/apps/desktop/src/main/services/orchestrator/modelConfigResolver.test.ts b/apps/desktop/src/main/services/orchestrator/modelConfigResolver.test.ts deleted file mode 100644 index 52772f739..000000000 --- a/apps/desktop/src/main/services/orchestrator/modelConfigResolver.test.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { - resolveMissionDecisionTimeoutCapMs, - resolveMissionModelConfig, - resolveOrchestratorModelConfig, -} from "./modelConfigResolver"; - -function createCtx(metadata: Record) { - return { - db: { - get: () => ({ - metadata_json: JSON.stringify(metadata), - }), - }, - callTypeConfigCache: new Map(), - } as any; -} - -describe("modelConfigResolver", () => { - it("reads mission model config from launch metadata", () => { - const ctx = createCtx({ - launch: { - modelConfig: { - orchestratorModel: { - modelId: "openai/gpt-5.3-codex", - provider: "codex", - thinkingLevel: "medium", - }, - decisionTimeoutCapHours: 12, - }, - }, - }); - - expect(resolveMissionModelConfig(ctx, "mission-1")?.orchestratorModel?.modelId).toBe("openai/gpt-5.3-codex"); - expect(resolveOrchestratorModelConfig(ctx, "mission-1", "coordinator").modelId).toBe("openai/gpt-5.3-codex"); - expect(resolveMissionDecisionTimeoutCapMs(ctx, "mission-1")).toBe(12 * 60 * 60 * 1000); - }); - - it("falls back to the legacy root model config shape", () => { - const ctx = createCtx({ - modelConfig: { - orchestratorModel: { - modelId: "anthropic/claude-sonnet-4-6", - provider: "claude", - thinkingLevel: "medium", - }, - decisionTimeoutCapHours: 6, - }, - }); - - expect(resolveMissionModelConfig(ctx, "mission-2")?.orchestratorModel?.modelId).toBe("anthropic/claude-sonnet-4-6"); - expect(resolveMissionDecisionTimeoutCapMs(ctx, "mission-2")).toBe(6 * 60 * 60 * 1000); - }); -}); diff --git a/apps/desktop/src/main/services/orchestrator/orchestrationRuntime.test.ts b/apps/desktop/src/main/services/orchestrator/orchestrationRuntime.test.ts deleted file mode 100644 index 20ef2e7da..000000000 --- a/apps/desktop/src/main/services/orchestrator/orchestrationRuntime.test.ts +++ /dev/null @@ -1,807 +0,0 @@ -// --------------------------------------------------------------------------- -// Tests for M5 orchestration runtime features: -// - Adaptive (VAL-ENH-001..004) -// - Completion gates (VAL-ENH-010..014) -// - Mandatory planning runtime (coordinator enforcement) -// - Approval gate (set_current_phase + phase_approval) -// - Multi-round deliberation (maxQuestions bypass for planning) -// - Model downgrade runtime (spawn_worker usage check) -// --------------------------------------------------------------------------- - -import { describe, expect, it, vi, beforeEach } from "vitest"; -import { createCoordinatorToolSet } from "./coordinatorTools"; -import { validateRunCompletion, evaluateRunCompletionFromPhases } from "./executionPolicy"; -import { createBuiltInPhaseCards } from "../missions/phaseEngine"; -import type { PhaseCard } from "../../../shared/types"; - -function makePlanningPhase(overrides?: Partial): PhaseCard { - return { - id: "builtin:planning", - phaseKey: "planning", - name: "Planning", - description: "Research", - instructions: "Plan the work", - model: { modelId: "anthropic/claude-sonnet-4-6", thinkingLevel: "medium" }, - budget: {}, - orderingConstraints: { mustBeFirst: true }, - askQuestions: { enabled: true, maxQuestions: 5 }, - validationGate: { tier: "none", required: false }, - requiresApproval: true, - isBuiltIn: true, - isCustom: false, - position: 0, - createdAt: "2026-03-01T00:00:00.000Z", - updatedAt: "2026-03-01T00:00:00.000Z", - ...overrides, - }; -} - -function makeDevPhase(overrides?: Partial): PhaseCard { - return { - id: "builtin:development", - phaseKey: "development", - name: "Development", - description: "Implement", - instructions: "Do it", - model: { modelId: "openai/gpt-5.4-codex", thinkingLevel: "medium" }, - budget: {}, - orderingConstraints: {}, - askQuestions: { enabled: false }, - validationGate: { tier: "none", required: false }, - isBuiltIn: true, - isCustom: false, - position: 1, - createdAt: "2026-03-01T00:00:00.000Z", - updatedAt: "2026-03-01T00:00:00.000Z", - ...overrides, - }; -} - -function createHarness(args: { - graph?: any; - missionInterventions?: any[]; - finalizeRunResult?: { finalized: boolean; blockers: string[]; finalStatus: string }; - getMissionBudgetStatus?: () => Promise; - onHardCapTriggered?: (detail: string) => void; - onBudgetWarning?: (pressure: "warning" | "critical", detail: string) => void; - onRunFinalize?: (input: { runId: string; succeeded: boolean; summary?: string; reason?: string }) => void; -}) { - const defaultGraph = { - run: { - id: "run-1", - metadata: { - phaseRuntime: { - currentPhaseKey: "planning", - currentPhaseName: "Planning", - currentPhaseModel: { - modelId: "anthropic/claude-sonnet-4-6", - thinkingLevel: "medium", - }, - }, - phases: [makePlanningPhase(), makeDevPhase()], - }, - }, - steps: [], - attempts: [], - }; - const graph = args.graph ?? defaultGraph; - - const db = { - run: vi.fn(), - get: vi.fn((query: string) => { - if (query.includes("from orchestrator_runs")) { - return { metadata_json: JSON.stringify(graph.run.metadata ?? {}) }; - } - return null; - }), - } as any; - - const mission = { - id: "mission-1", - interventions: args.missionInterventions ?? [], - }; - - const orchestratorService = { - getRunGraph: vi.fn(() => graph), - appendRuntimeEvent: vi.fn(), - appendTimelineEvent: vi.fn(), - emitRuntimeUpdate: vi.fn(), - finalizeRun: vi.fn(() => args.finalizeRunResult ?? { - finalized: true, - blockers: [], - finalStatus: "succeeded", - }), - addReflection: vi.fn(() => ({ - id: "reflection-1", - missionId: "mission-1", - runId: "run-1", - })), - createHandoff: vi.fn(), - startReadyAutopilotAttempts: vi.fn(async () => 0), - completeAttempt: vi.fn(), - updateStepMetadata: vi.fn(({ stepId, metadata }: any) => { - const step = graph.steps.find((s: any) => s.id === stepId); - if (step) step.metadata = metadata; - return step; - }), - skipStep: vi.fn(({ stepId }: any) => { - const step = graph.steps.find((s: any) => s.id === stepId); - if (step) step.status = "skipped"; - }), - addSteps: vi.fn(({ steps }: { steps: any[] }) => { - const idByKey = new Map(graph.steps.map((e: any) => [e.stepKey, e.id])); - return steps.map((input, idx) => { - const step = { - id: `step-${graph.steps.length + idx + 1}`, - runId: "run-1", - missionStepId: null, - stepKey: input.stepKey, - stepIndex: graph.steps.length + idx, - title: input.title ?? input.stepKey, - laneId: input.laneId ?? null, - status: "pending", - joinPolicy: "all_success", - quorumCount: null, - dependencyStepIds: (input.dependencyStepKeys ?? []) - .map((k: string) => idByKey.get(k)) - .filter(Boolean), - retryLimit: 1, - retryCount: 0, - lastAttemptId: null, - createdAt: "2026-03-01T00:00:00.000Z", - updatedAt: "2026-03-01T00:00:00.000Z", - startedAt: null, - completedAt: null, - metadata: input.metadata ?? {}, - }; - graph.steps.push(step); - return step; - }); - }), - supersedeStep: vi.fn(), - updateStepDependencies: vi.fn(), - } as any; - - const missionService = { - get: vi.fn(() => mission), - addIntervention: vi.fn((input: any) => { - const intervention = { - id: `intervention-${mission.interventions.length + 1}`, - interventionType: input.interventionType ?? "manual_input", - status: "open", - title: input.title ?? "", - body: input.body ?? "", - metadata: input.metadata ?? null, - requestedAction: input.requestedAction ?? null, - }; - mission.interventions.push(intervention); - return intervention; - }), - } as any; - - const logger = { - info: vi.fn(), - warn: vi.fn(), - error: vi.fn(), - debug: vi.fn(), - } as any; - - const tools = createCoordinatorToolSet({ - orchestratorService, - missionService, - runId: "run-1", - missionId: "mission-1", - logger, - db, - projectRoot: "/tmp", - workspaceRoot: "/tmp", - onDagMutation: vi.fn(), - getMissionBudgetStatus: args.getMissionBudgetStatus, - onHardCapTriggered: args.onHardCapTriggered, - onBudgetWarning: args.onBudgetWarning, - onRunFinalize: args.onRunFinalize, - }); - - return { tools, orchestratorService, missionService, mission, graph, logger, db }; -} - -// --------------------------------------------------------------------------- -// VAL-ENH-004: Budget check gates high-parallelism spawns -// --------------------------------------------------------------------------- -describe("budget check gates spawns", () => { - it("blocks spawn_worker when budget hard cap is triggered", async () => { - const { tools } = createHarness({ - graph: { - run: { - id: "run-1", - metadata: { - phaseRuntime: { - currentPhaseKey: "development", - currentPhaseName: "Development", - currentPhaseModel: { modelId: "openai/gpt-5.4-codex", thinkingLevel: "medium" }, - }, - phases: [makePlanningPhase(), makeDevPhase()], - }, - }, - steps: [ - { - id: "step-plan", - stepKey: "planner", - title: "Planner", - status: "succeeded", - metadata: { phaseKey: "planning", phaseName: "Planning", stepType: "analysis" }, - }, - ], - attempts: [], - }, - getMissionBudgetStatus: async () => ({ - hardCaps: { - fiveHourTriggered: true, - weeklyTriggered: false, - apiKeyTriggered: false, - fiveHourHardStopPercent: 80, - weeklyHardStopPercent: null, - apiKeyMaxSpendUsd: null, - apiKeySpentUsd: 0, - }, - perProvider: [ - { provider: "claude", fiveHour: { usedPct: 85 }, weekly: { usedPct: 30 } }, - ], - }), - onHardCapTriggered: vi.fn(), - }); - - const result = await (tools.spawn_worker as any).execute({ - name: "blocked-worker", - prompt: "Do work", - dependsOn: [], - }); - - expect(result.ok).toBe(false); - expect(result.hardCapTriggered).toBe(true); - }); -}); - -// --------------------------------------------------------------------------- -// VAL-ENH-010: complete_mission with blockers returns ok:false -// --------------------------------------------------------------------------- -describe("complete_mission gates", () => { - it("returns ok:false when finalizeRun has blockers", async () => { - const { tools } = createHarness({ - graph: { - run: { id: "run-1", metadata: {} }, - steps: [], - attempts: [], - }, - finalizeRunResult: { - finalized: false, - blockers: ["running_attempts: 2 attempt(s) still running"], - finalStatus: "active", - }, - }); - - const result = await (tools.complete_mission as any).execute({ summary: "Done" }); - expect(result.ok).toBe(false); - expect(result.blockers).toContain("running_attempts: 2 attempt(s) still running"); - }); - - // VAL-ENH-014: Active workers block completion - it("blocks completion when workers are still running", async () => { - const { tools } = createHarness({ - graph: { - run: { id: "run-1", metadata: {} }, - steps: [ - { - id: "step-1", - stepKey: "worker-1", - title: "Running worker", - status: "running", - metadata: { stepType: "implementation" }, - }, - ], - attempts: [], - }, - }); - - const result = await (tools.complete_mission as any).execute({ summary: "All done" }); - expect(result.ok).toBe(false); - expect(result.error).toContain("still running"); - expect(result.activeWorkers).toHaveLength(1); - }); -}); - -// --------------------------------------------------------------------------- -// VAL-PLAN-005: set_current_phase creates phase_approval intervention -// --------------------------------------------------------------------------- -describe("approval gate on phase transition", () => { - it("creates phase_approval intervention when leaving a requiresApproval phase", async () => { - const planningPhase = makePlanningPhase({ requiresApproval: true }); - const devPhase = makeDevPhase(); - - const graph = { - run: { - id: "run-1", - metadata: { - phaseRuntime: { - currentPhaseKey: "planning", - currentPhaseName: "Planning", - currentPhaseModel: planningPhase.model, - }, - phases: [planningPhase, devPhase], - }, - }, - steps: [ - { - id: "step-plan", - stepKey: "planner", - title: "Planning Worker", - status: "succeeded", - metadata: { phaseKey: "planning", phaseName: "Planning", stepType: "analysis" }, - }, - ], - attempts: [ - { - id: "attempt-1", - stepId: "step-plan", - status: "succeeded", - }, - ], - }; - - const { tools, missionService, mission } = createHarness({ graph }); - - const result = await (tools.set_current_phase as any).execute({ - phaseKey: "development", - reason: "Planning done", - }); - - // Should be blocked by approval gate - expect(result.ok).toBe(false); - expect(result.error).toContain("approval"); - - // Should have created a phase_approval intervention - const approvalInterventions = mission.interventions.filter( - (i: any) => i.interventionType === "phase_approval" - ); - expect(approvalInterventions.length).toBe(1); - }); - - it("allows transition when approval has been resolved", async () => { - const planningPhase = makePlanningPhase({ requiresApproval: true }); - const devPhase = makeDevPhase(); - - const graph = { - run: { - id: "run-1", - metadata: { - phaseRuntime: { - currentPhaseKey: "planning", - currentPhaseName: "Planning", - currentPhaseModel: planningPhase.model, - }, - phases: [planningPhase, devPhase], - }, - }, - steps: [ - { - id: "step-plan", - stepKey: "planner", - title: "Planning Worker", - status: "succeeded", - metadata: { phaseKey: "planning", phaseName: "Planning", stepType: "analysis" }, - }, - ], - attempts: [ - { - id: "attempt-1", - stepId: "step-plan", - status: "succeeded", - }, - ], - }; - - const { tools, mission } = createHarness({ - graph, - missionInterventions: [ - { - id: "approval-1", - interventionType: "phase_approval", - status: "resolved", - metadata: { - runId: "run-1", - phaseKey: "planning", - targetPhaseKey: "development", - source: "phase_approval_gate", - }, - }, - ], - }); - - const result = await (tools.set_current_phase as any).execute({ - phaseKey: "development", - reason: "Planning done, approval granted", - }); - - expect(result.ok).toBe(true); - expect(result.currentPhaseKey).toBe("development"); - }); - - it("does not reuse approvals from a different run", async () => { - const planningPhase = makePlanningPhase({ requiresApproval: true }); - const devPhase = makeDevPhase(); - - const graph = { - run: { - id: "run-1", - metadata: { - phaseRuntime: { - currentPhaseKey: "planning", - currentPhaseName: "Planning", - currentPhaseModel: planningPhase.model, - }, - phases: [planningPhase, devPhase], - }, - }, - steps: [ - { - id: "step-plan", - stepKey: "planner", - title: "Planning Worker", - status: "succeeded", - metadata: { phaseKey: "planning", phaseName: "Planning", stepType: "analysis" }, - }, - ], - attempts: [ - { - id: "attempt-1", - stepId: "step-plan", - status: "succeeded", - }, - ], - }; - - const { tools, mission } = createHarness({ - graph, - missionInterventions: [ - { - id: "approval-1", - interventionType: "phase_approval", - status: "resolved", - metadata: { - runId: "run-old", - phaseKey: "planning", - targetPhaseKey: "development", - source: "phase_approval_gate", - }, - }, - ], - }); - - const result = await (tools.set_current_phase as any).execute({ - phaseKey: "development", - reason: "Planning done again", - }); - - expect(result.ok).toBe(false); - expect(result.pendingApproval).toBe(true); - expect(mission.interventions.some((entry: any) => - entry.interventionType === "phase_approval" - && entry.status === "open" - && entry.metadata?.runId === "run-1" - )).toBe(true); - }); - - it("applies approval gate to any phase with requiresApproval=true", async () => { - const devPhase = makeDevPhase({ requiresApproval: true }); - const testPhase: PhaseCard = { - id: "builtin:testing", - phaseKey: "testing", - name: "Testing", - description: "Test", - instructions: "Run tests", - model: { modelId: "openai/gpt-5.4-codex", thinkingLevel: "low" }, - budget: {}, - orderingConstraints: {}, - askQuestions: { enabled: false }, - validationGate: { tier: "none", required: false }, - isBuiltIn: true, - isCustom: false, - position: 2, - createdAt: "2026-03-01T00:00:00.000Z", - updatedAt: "2026-03-01T00:00:00.000Z", - }; - - const graph = { - run: { - id: "run-1", - metadata: { - phaseRuntime: { - currentPhaseKey: "development", - currentPhaseName: "Development", - currentPhaseModel: devPhase.model, - }, - phases: [makePlanningPhase(), devPhase, testPhase], - }, - }, - steps: [ - { - id: "step-plan", - stepKey: "planner", - title: "Planner", - status: "succeeded", - metadata: { phaseKey: "planning", phaseName: "Planning", stepType: "analysis" }, - }, - { - id: "step-dev", - stepKey: "dev-worker", - title: "Dev Worker", - status: "succeeded", - metadata: { phaseKey: "development", phaseName: "Development", stepType: "implementation" }, - }, - ], - attempts: [ - { id: "a-1", stepId: "step-plan", status: "succeeded" }, - { id: "a-2", stepId: "step-dev", status: "succeeded" }, - ], - }; - - const { tools, mission } = createHarness({ graph }); - - const result = await (tools.set_current_phase as any).execute({ - phaseKey: "testing", - reason: "Dev done", - }); - - expect(result.ok).toBe(false); - expect(result.error).toContain("approval"); - expect(mission.interventions.some((i: any) => i.interventionType === "phase_approval")).toBe(true); - }); -}); - -// --------------------------------------------------------------------------- -// VAL-PLAN-006: Multi-round deliberation (maxQuestions bypass for planning) -// --------------------------------------------------------------------------- -describe("multi-round deliberation", () => { - it("blocks coordinator-owned planning questions even when planning can loop", async () => { - const planningPhase = makePlanningPhase({ - askQuestions: { enabled: true, maxQuestions: 3 }, - orderingConstraints: { mustBeFirst: true, canLoop: true, loopTarget: "planning" }, - }); - - const graph = { - run: { - id: "run-1", - metadata: { - phaseRuntime: { - currentPhaseKey: "planning", - currentPhaseName: "Planning", - currentPhaseModel: planningPhase.model, - }, - phases: [planningPhase, makeDevPhase()], - }, - }, - steps: [], - attempts: [], - }; - - // Pre-populate 3 prior questions (at the old maxQuestions limit) - const priorInterventions = Array.from({ length: 3 }, (_, i) => ({ - id: `q-${i}`, - interventionType: "manual_input", - status: "resolved", - metadata: { source: "ask_user", phase: "planning", questionCount: 1 }, - })); - - const { tools, mission } = createHarness({ - graph, - missionInterventions: priorInterventions, - }); - - const result = await (tools.ask_user as any).execute({ - questions: [{ question: "What framework should we use?" }], - phase: "planning", - }); - - expect(result.ok).toBe(false); - expect(result.error).toContain("active planning worker must ask"); - }); -}); - -// --------------------------------------------------------------------------- -// VAL-ENH-011: MissionCloseoutRequirement keys enumerated -// --------------------------------------------------------------------------- -describe("closeout requirements enumeration", () => { - it("MissionCloseoutRequirementKey includes all required keys", async () => { - // Verify the type definition includes the expected keys by importing and checking - // at runtime against a known set - const requiredKeys = [ - "planning_document", "research_summary", "changed_files_summary", - "test_report", "implementation_summary", "validation_verdict", - "screenshot", "browser_verification", "browser_trace", - "video_recording", "console_logs", "risk_notes", - "pr_url", "proposal_url", "review_summary", "final_outcome_summary", - ]; - // Import the type definition and verify all keys are valid - // This test validates that the type system covers all expected keys - for (const key of requiredKeys) { - expect(typeof key).toBe("string"); - expect(key.length).toBeGreaterThan(0); - } - expect(requiredKeys).toContain("screenshot"); - expect(requiredKeys).toContain("browser_verification"); - expect(requiredKeys).toContain("test_report"); - expect(requiredKeys).toContain("pr_url"); - }); -}); - -// --------------------------------------------------------------------------- -// VAL-ENH-012: RunCompletionValidation blocks early close -// --------------------------------------------------------------------------- -describe("RunCompletionValidation blocks early close", () => { - it("running attempts prevent completion", () => { - const run = { id: "run-1", status: "active" } as any; - const attempts = [{ id: "a-1", status: "running" }] as any[]; - const result = validateRunCompletion(run, [], attempts, [], null); - expect(result.canComplete).toBe(false); - expect(result.blockers.some((b) => b.code === "running_attempts")).toBe(true); - }); - - it("unresolved interventions prevent completion", () => { - const run = { id: "run-1", status: "active" } as any; - const interventions = [{ status: "open" }]; - const result = validateRunCompletion(run, [], [], [], null, interventions); - expect(result.canComplete).toBe(false); - expect(result.blockers.some((b) => b.code === "unresolved_interventions")).toBe(true); - }); -}); - -// --------------------------------------------------------------------------- -// VAL-ENH-013: CompletionDiagnostic per phase -// --------------------------------------------------------------------------- -describe("CompletionDiagnostic per phase", () => { - it("produces diagnostics for each phase card", () => { - const phases: PhaseCard[] = [ - makePlanningPhase(), - makeDevPhase(), - ]; - const settings = {}; - const steps = [ - { - id: "s-1", - stepKey: "impl-1", - title: "Implement", - status: "succeeded", - metadata: { stepType: "implementation", phaseKey: "development", phaseName: "Development" }, - }, - ]; - const result = evaluateRunCompletionFromPhases(steps as any, phases, settings as any); - expect(result.diagnostics).toBeDefined(); - expect(Array.isArray(result.diagnostics)).toBe(true); - // Should have at least implementation phase diagnostic - const implDiag = result.diagnostics.find((d) => d.phase === "implementation"); - expect(implDiag).toBeDefined(); - }); - - it("blocking diagnostic prevents finalization", () => { - const devPhase = makeDevPhase(); - devPhase.validationGate = { tier: "dedicated", required: true }; - const phases = [makePlanningPhase(), devPhase]; - const settings = {} as any; - // No steps at all — required implementation phase has no steps - const result = evaluateRunCompletionFromPhases([], phases, settings); - const blockingDiags = result.diagnostics.filter((d) => d.blocking); - expect(blockingDiags.length).toBeGreaterThan(0); - }); -}); - -// --------------------------------------------------------------------------- -// Mandatory planning enforcement (coordinator blocks without planning) -// --------------------------------------------------------------------------- -describe("mandatory planning enforcement", () => { - it("injects planning phase when phases are provided without it", () => { - // We can't easily test CoordinatorAgent directly, but we can test the phase injection - // logic by verifying the phaseEngine's createBuiltInPhaseCards includes planning - const builtIn = createBuiltInPhaseCards(); - const planningCard = builtIn.find((c: any) => c.phaseKey === "planning"); - expect(planningCard).toBeDefined(); - expect(planningCard!.requiresApproval).toBe(true); - expect(planningCard!.askQuestions.maxQuestions).toBeNull(); - expect(planningCard!.orderingConstraints.mustBeFirst).toBe(true); - }); -}); - -// --------------------------------------------------------------------------- -// VAL-ENH-002: Fan-out strategy scales with complexity -// --------------------------------------------------------------------------- -describe("fan-out strategy scales with complexity", () => { - it("inline strategy implies 1 worker", () => { - // FanOutDecision with strategy "inline" means no fan-out, just 1 worker - const decision = { strategy: "inline", subtasks: [], reasoning: "simple task" }; - expect(decision.strategy).toBe("inline"); - expect(decision.subtasks.length).toBe(0); // inline = no subtask creation - }); - - it("parallel strategy implies N workers matching subtasks", () => { - const decision = { - strategy: "external_parallel", - subtasks: [ - { title: "frontend", instructions: "Do frontend", files: [], complexity: "moderate" as const }, - { title: "backend", instructions: "Do backend", files: [], complexity: "moderate" as const }, - { title: "tests", instructions: "Write tests", files: [], complexity: "simple" as const }, - ], - reasoning: "3 independent subtasks", - }; - expect(decision.subtasks.length).toBe(3); - }); -}); - -// --------------------------------------------------------------------------- -// Model downgrade in spawn_worker -// --------------------------------------------------------------------------- -describe("model downgrade runtime", () => { - it("logs downgrade when usage exceeds threshold", async () => { - const { tools, logger } = createHarness({ - graph: { - run: { - id: "run-1", - metadata: { - budgetConfig: { - modelDowngradeThresholdPct: 70, - }, - phaseRuntime: { - currentPhaseKey: "development", - currentPhaseName: "Development", - currentPhaseModel: { modelId: "openai/gpt-5.4-codex", thinkingLevel: "medium" }, - }, - phases: [makePlanningPhase(), makeDevPhase()], - }, - }, - steps: [ - { - id: "step-plan", - stepKey: "planner", - title: "Planner", - status: "succeeded", - metadata: { phaseKey: "planning", phaseName: "Planning", stepType: "analysis" }, - }, - ], - attempts: [], - }, - getMissionBudgetStatus: async () => ({ - hardCaps: { - fiveHourTriggered: false, - weeklyTriggered: false, - apiKeyTriggered: false, - }, - perProvider: [ - { - provider: "claude", - fiveHour: { usedPct: 80 }, - weekly: { usedPct: 60 }, - }, - ], - pressure: "warning", - recommendation: "Consider downgrading model", - }), - }); - - // spawn_worker should proceed but with potential downgrade logged - const result = await (tools.spawn_worker as any).execute({ - name: "downgrade-test", - prompt: "Some work", - dependsOn: [], - }); - - // Worker should still be spawned (downgrade is not a blocker) - expect(result.ok).toBe(true); - // Verify downgrade was logged - expect(logger.info).toHaveBeenCalledWith( - "coordinator.spawn_worker.model_downgrade", - expect.objectContaining({ - name: "downgrade-test", - usagePct: 80, - thresholdPct: 70, - }) - ); - }); -}); diff --git a/apps/desktop/src/main/services/orchestrator/orchestratorAdapters.test.ts b/apps/desktop/src/main/services/orchestrator/orchestratorAdapters.test.ts new file mode 100644 index 000000000..2bd55085b --- /dev/null +++ b/apps/desktop/src/main/services/orchestrator/orchestratorAdapters.test.ts @@ -0,0 +1,591 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { buildFullPrompt } from "./baseOrchestratorAdapter"; +import { + mapPermissionToClaude, + mapPermissionToCodex, + mergeMissionPermissionConfig, + normalizeMissionPermissions, +} from "./permissionMapping"; +import { + resolveMissionDecisionTimeoutCapMs, + resolveMissionModelConfig, + resolveOrchestratorModelConfig, +} from "./modelConfigResolver"; + +const mockState = vi.hoisted(() => ({ + resolveClaudeCodeExecutable: vi.fn(() => ({ path: "/mock/bin/claude", source: "path" as const })), +})); + +vi.mock("../ai/claudeCodeExecutable", () => ({ + resolveClaudeCodeExecutable: mockState.resolveClaudeCodeExecutable, +})); + +import { createProviderOrchestratorAdapter } from "./providerOrchestratorAdapter"; + +describe("buildFullPrompt", () => { + it("injects shared facts, mission memory, and project knowledge into worker prompts", () => { + const memoryService = { + getMemoryBudget: (_projectId: string, _level: string, opts?: { scope?: string; scopeOwnerId?: string | null }) => { + return [ + { + id: "mem-project-1", + category: "decision", + content: "Project-wide decisions should stay visible across runs.", + importance: "high", + }, + ]; + }, + } as any; + + const prompt = buildFullPrompt( + { + run: { + id: "run-1", + missionId: "mission-1", + metadata: { + missionGoal: "Stabilize W6 memory behavior", + }, + } as any, + step: { + id: "step-1", + title: "Fix mission memory scoping", + stepKey: "fix-memory", + laneId: "lane-1", + metadata: {}, + dependencyStepIds: [], + joinPolicy: "all_success", + } as any, + attempt: {} as any, + allSteps: [], + contextProfile: {} as any, + laneExport: null, + projectExport: { + content: "Project context body", + } as any, + docsRefs: [], + fullDocs: [], + createTrackedSession: async () => ({ ptyId: "pty-1", sessionId: "session-1" }), + memoryBriefing: { + l0: { title: "Project Memory", entries: [] }, + l1: { + title: "Relevant Project Knowledge", + entries: [ + { + id: "mem-project-1", + category: "decision", + content: "Project-wide decisions should stay visible across runs.", + importance: "high", + }, + ], + }, + l2: { title: "Agent Memory", entries: [] }, + mission: { + title: "Mission Memory", + entries: [ + { + id: "mem-mission-1", + category: "pattern", + content: "Mission memory stays scoped to the current run.", + importance: "medium", + }, + ], + }, + sharedFacts: [ + { + id: "mem-mission-1", + factType: "api_pattern", + content: "Mission memory stays scoped to the current run.", + createdAt: "2026-03-05T12:00:00.000Z", + }, + ], + usedProcedureIds: [], + usedDigestIds: [], + usedMissionMemoryIds: ["mem-mission-1"], + } as any, + }, + "opencode", + { + memoryService, + projectId: "project-1", + } + ); + + expect(prompt.prompt).toContain("## Shared Team Knowledge"); + expect(prompt.prompt).toContain("## Mission Memory"); + expect(prompt.prompt).toContain("Mission memory stays scoped to the current run."); + expect(prompt.prompt).toContain("## Project Knowledge"); + expect(prompt.prompt).toContain("Project-wide decisions should stay visible across runs."); + }); + + it("routes read-only workers to ADE result reporting instead of file writes", () => { + const prompt = buildFullPrompt( + { + run: { + id: "run-1", + missionId: "mission-1", + metadata: { + missionGoal: "Research the sidebar flow", + }, + } as any, + step: { + id: "step-1", + title: "Plan sidebar changes", + stepKey: "plan-sidebar", + laneId: "lane-1", + metadata: { + readOnlyExecution: true, + }, + dependencyStepIds: [], + joinPolicy: "all_success", + } as any, + attempt: {} as any, + allSteps: [], + contextProfile: {} as any, + laneExport: null, + projectExport: { + content: "Project context body", + } as any, + docsRefs: [], + fullDocs: [], + createTrackedSession: async () => ({ ptyId: "pty-1", sessionId: "session-1" }), + }, + "opencode", + {} + ); + + expect(prompt.prompt).toContain("ALWAYS call `report_result`"); + expect(prompt.prompt).toContain("This step cannot write files."); + expect(prompt.prompt).not.toContain("PROGRESS CHECKPOINTING:"); + expect(prompt.prompt).not.toContain("STEP OUTPUT FILE:"); + }); + + it("handles partial briefing structures without throwing", () => { + const prompt = buildFullPrompt( + { + run: { + id: "run-1", + missionId: "mission-1", + metadata: { + missionGoal: "Recover the mission landing path", + }, + } as any, + step: { + id: "step-1", + title: "Recover landing flow", + stepKey: "recover-landing", + laneId: "lane-1", + metadata: {}, + dependencyStepIds: [], + joinPolicy: "all_success", + } as any, + attempt: {} as any, + allSteps: [], + contextProfile: {} as any, + laneExport: null, + projectExport: { + content: "Project context body", + } as any, + docsRefs: [], + fullDocs: [], + createTrackedSession: async () => ({ ptyId: "pty-1", sessionId: "session-1" }), + memoryBriefing: { + mission: { + title: "Mission Memory", + entries: [ + { + id: "mission-memory-1", + category: "note", + content: "Mission landing failures should point to the focused intervention.", + importance: "high", + }, + ], + }, + l1: { + title: "Project Knowledge", + }, + } as any, + }, + "opencode", + {} + ); + + expect(prompt.prompt).toContain("Mission landing failures should point to the focused intervention."); + expect(prompt.prompt).toContain("## Mission Memory"); + }); + + it("keeps checkpoint and step output instructions for writable workers", () => { + const prompt = buildFullPrompt( + { + run: { + id: "run-1", + missionId: "mission-1", + metadata: { + missionGoal: "Implement the sidebar flow", + }, + } as any, + step: { + id: "step-1", + title: "Implement sidebar changes", + stepKey: "implement-sidebar", + laneId: "lane-1", + metadata: {}, + dependencyStepIds: [], + joinPolicy: "all_success", + } as any, + attempt: {} as any, + allSteps: [], + contextProfile: {} as any, + laneExport: null, + projectExport: { + content: "Project context body", + } as any, + docsRefs: [], + fullDocs: [], + createTrackedSession: async () => ({ ptyId: "pty-1", sessionId: "session-1" }), + }, + "opencode", + {} + ); + + expect(prompt.prompt).toContain("ALWAYS call `report_result`"); + expect(prompt.prompt).toContain("PROGRESS CHECKPOINTING:"); + expect(prompt.prompt).toContain("STEP OUTPUT FILE:"); + }); + + it("removes ADE mission-tool instructions for in-process workers", () => { + const prompt = buildFullPrompt( + { + run: { + id: "run-1", + missionId: "mission-1", + metadata: { + missionGoal: "Implement the sidebar flow", + }, + } as any, + step: { + id: "step-1", + title: "Implement sidebar changes", + stepKey: "implement-sidebar", + laneId: "lane-1", + metadata: {}, + dependencyStepIds: [], + joinPolicy: "all_success", + } as any, + attempt: {} as any, + allSteps: [], + contextProfile: {} as any, + laneExport: null, + projectExport: { + content: "Project context body", + } as any, + docsRefs: [], + fullDocs: [], + createTrackedSession: async () => ({ ptyId: "pty-1", sessionId: "session-1" }), + }, + "opencode", + { workerRuntime: "in_process" } + ); + + expect(prompt.prompt).toContain("This worker is running in-process."); + expect(prompt.prompt).toContain("RUNTIME LIMITS:"); + expect(prompt.prompt).not.toContain("ALWAYS call `report_result`"); + expect(prompt.prompt).not.toContain("ADE TOOLING:"); + }); +}); + +describe("providerOrchestratorAdapter", () => { + let projectRoot: string | null = null; + + beforeEach(() => { + mockState.resolveClaudeCodeExecutable.mockReturnValue({ path: "/mock/bin/claude", source: "path" }); + }); + + afterEach(() => { + if (projectRoot) { + fs.rmSync(projectRoot, { recursive: true, force: true }); + projectRoot = null; + } + }); + + it("passes Codex config-toml through to managed chat sessions", async () => { + projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-provider-adapter-")); + const createSession = vi.fn(async () => ({ id: "managed-session-1" })); + const adapter = createProviderOrchestratorAdapter({ + projectRoot, + workspaceRoot: projectRoot, + agentChatService: { + createSession, + } as any, + }); + + const result = await adapter.start({ + run: { + id: "run-1", + missionId: "mission-1", + metadata: {}, + }, + step: { + id: "step-1", + runId: "run-1", + stepKey: "codex-worker", + title: "Codex worker", + stepIndex: 0, + dependencyStepIds: [], + dependencyStepKeys: [], + laneId: "lane-1", + status: "ready", + metadata: { + modelId: "openai/gpt-5.3-codex", + }, + }, + attempt: { + id: "attempt-1", + runId: "run-1", + stepId: "step-1", + }, + allSteps: [], + contextProfile: {} as any, + laneExport: null, + projectExport: { content: "", truncated: false }, + docsRefs: [], + fullDocs: [], + createTrackedSession: vi.fn(), + permissionConfig: { + _providers: { + claude: "full-auto", + codex: "config-toml", + opencode: "full-auto", + codexSandbox: "workspace-write", + }, + }, + } as any); + + expect(result.status).toBe("accepted"); + expect(createSession).toHaveBeenCalledWith(expect.objectContaining({ + provider: "codex", + model: "gpt-5.3-codex", + modelId: "openai/gpt-5.3-codex", + permissionMode: "config-toml", + codexConfigSource: "config-toml", + })); + }); + + it("resolves the Claude executable for direct startup-command overrides", async () => { + projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-provider-adapter-")); + mockState.resolveClaudeCodeExecutable.mockReturnValue({ + path: "C:\\Users\\me\\AppData\\Roaming\\npm\\claude.cmd", + source: "path", + }); + const createTrackedSession = vi.fn(async () => ({ ptyId: "pty-override", sessionId: "session-override" })); + const adapter = createProviderOrchestratorAdapter({ + projectRoot, + workspaceRoot: projectRoot, + agentChatService: null, + }); + + const result = await adapter.start({ + run: { + id: "run-1", + missionId: "mission-1", + metadata: {}, + }, + step: { + id: "step-1", + runId: "run-1", + stepKey: "override-worker", + title: "Override worker", + stepIndex: 0, + dependencyStepIds: [], + dependencyStepKeys: [], + laneId: "lane-1", + status: "ready", + metadata: { + startupCommand: "diagnose the failing check", + }, + }, + attempt: { + id: "attempt-1", + runId: "run-1", + stepId: "step-1", + }, + allSteps: [], + contextProfile: {} as any, + laneExport: null, + projectExport: { content: "", truncated: false }, + docsRefs: [], + fullDocs: [], + createTrackedSession, + } as any); + + expect(result.status).toBe("accepted"); + expect(mockState.resolveClaudeCodeExecutable).toHaveBeenCalledTimes(1); + expect(createTrackedSession).toHaveBeenCalledWith(expect.objectContaining({ + command: "C:\\Users\\me\\AppData\\Roaming\\npm\\claude.cmd", + args: ["-p", expect.stringContaining("diagnose the failing check")], + startupCommand: expect.stringContaining("exec claude -p"), + })); + }); + + it("launches CLI-wrapped fallback workers without shell-only command syntax", async () => { + projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-provider-adapter-")); + const createTrackedSession = vi.fn(async () => ({ ptyId: "pty-1", sessionId: "session-1" })); + const adapter = createProviderOrchestratorAdapter({ + projectRoot, + workspaceRoot: projectRoot, + agentChatService: null, + }); + + const result = await adapter.start({ + run: { + id: "run-1", + missionId: "mission-1", + metadata: {}, + }, + step: { + id: "step-1", + runId: "run-1", + stepKey: "codex-worker", + title: "Codex worker", + stepIndex: 0, + dependencyStepIds: [], + dependencyStepKeys: [], + laneId: "lane-1", + status: "ready", + metadata: { + modelId: "openai/gpt-5.3-codex", + }, + }, + attempt: { + id: "attempt-1", + runId: "run-1", + stepId: "step-1", + }, + allSteps: [], + contextProfile: {} as any, + laneExport: null, + projectExport: { content: "Project context", truncated: false }, + docsRefs: [], + fullDocs: [], + createTrackedSession, + permissionConfig: { + _providers: { + codex: "default", + codexSandbox: "workspace-write", + }, + }, + } as any); + + expect(result.status).toBe("accepted"); + expect(createTrackedSession).toHaveBeenCalledWith(expect.objectContaining({ + command: process.execPath, + args: expect.arrayContaining(["-e"]), + env: expect.objectContaining({ + ELECTRON_RUN_AS_NODE: "1", + ADE_MISSION_ID: "mission-1", + ADE_RUN_ID: "run-1", + ADE_STEP_ID: "step-1", + ADE_ATTEMPT_ID: "attempt-1", + ADE_DEFAULT_ROLE: "agent", + }), + startupCommand: expect.stringContaining("exec codex"), + })); + const firstCreateArgs = (createTrackedSession.mock.calls as any[])[0]?.[0]; + expect(firstCreateArgs?.startupCommand).toContain("< "); + }); +}); + +describe("permissionMapping", () => { + it("maps Codex edit to writable guarded execution", () => { + expect(mapPermissionToCodex("edit")).toEqual({ + approvalPolicy: "untrusted", + sandbox: "workspace-write", + }); + expect(mapPermissionToCodex("plan")).toEqual({ + approvalPolicy: "on-request", + sandbox: "read-only", + }); + expect(mapPermissionToCodex("default")).toEqual({ + approvalPolicy: "on-request", + sandbox: "workspace-write", + }); + }); + + it("preserves Codex full-auto and Claude accept-edits semantics", () => { + expect(mapPermissionToCodex("full-auto")).toEqual({ + approvalPolicy: "never", + sandbox: "danger-full-access", + }); + expect(mapPermissionToClaude("edit")).toBe("acceptEdits"); + }); + + it("merges raw mission overrides without resetting unrelated provider settings", () => { + const merged = mergeMissionPermissionConfig( + { + providers: { + codex: "config-toml", + claude: "edit", + }, + }, + { + inProcess: { mode: "plan" }, + }, + ); + + expect(normalizeMissionPermissions(merged)).toMatchObject({ + codex: "config-toml", + claude: "edit", + opencode: "plan", + }); + }); +}); + +function createModelConfigCtx(metadata: Record) { + return { + db: { + get: () => ({ + metadata_json: JSON.stringify(metadata), + }), + }, + callTypeConfigCache: new Map(), + } as any; +} + +describe("modelConfigResolver", () => { + it("reads mission model config from launch metadata", () => { + const ctx = createModelConfigCtx({ + launch: { + modelConfig: { + orchestratorModel: { + modelId: "openai/gpt-5.3-codex", + provider: "codex", + thinkingLevel: "medium", + }, + decisionTimeoutCapHours: 12, + }, + }, + }); + + expect(resolveMissionModelConfig(ctx, "mission-1")?.orchestratorModel?.modelId).toBe("openai/gpt-5.3-codex"); + expect(resolveOrchestratorModelConfig(ctx, "mission-1", "coordinator").modelId).toBe("openai/gpt-5.3-codex"); + expect(resolveMissionDecisionTimeoutCapMs(ctx, "mission-1")).toBe(12 * 60 * 60 * 1000); + }); + + it("falls back to the legacy root model config shape", () => { + const ctx = createModelConfigCtx({ + modelConfig: { + orchestratorModel: { + modelId: "anthropic/claude-sonnet-4-6", + provider: "claude", + thinkingLevel: "medium", + }, + decisionTimeoutCapHours: 6, + }, + }); + + expect(resolveMissionModelConfig(ctx, "mission-2")?.orchestratorModel?.modelId).toBe("anthropic/claude-sonnet-4-6"); + expect(resolveMissionDecisionTimeoutCapMs(ctx, "mission-2")).toBe(6 * 60 * 60 * 1000); + }); +}); diff --git a/apps/desktop/src/main/services/orchestrator/orchestratorContext.test.ts b/apps/desktop/src/main/services/orchestrator/orchestratorContext.test.ts deleted file mode 100644 index 49e245a03..000000000 --- a/apps/desktop/src/main/services/orchestrator/orchestratorContext.test.ts +++ /dev/null @@ -1,157 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { - deriveMissionStatusFromRun, - filterExecutionSteps, - isDisplayOnlyTaskStep, - parseChatTarget, - sanitizeChatTarget, - teammateThreadIdentity, - deriveThreadTitle, -} from "./orchestratorContext"; - -describe("orchestratorContext teammate chat target handling", () => { - it("parses teammate targets from persisted JSON payloads", () => { - const parsed = parseChatTarget({ - kind: "teammate", - runId: "run-1", - teamMemberId: "tm-1", - sessionId: "session-1", - }); - - expect(parsed).toEqual({ - kind: "teammate", - runId: "run-1", - teamMemberId: "tm-1", - sessionId: "session-1", - }); - }); - - it("sanitizes teammate targets without dropping routing fields", () => { - const sanitized = sanitizeChatTarget({ - kind: "teammate", - runId: " run-1 ", - teamMemberId: " tm-1 ", - sessionId: " session-1 ", - }); - - expect(sanitized).toEqual({ - kind: "teammate", - runId: "run-1", - teamMemberId: "tm-1", - sessionId: "session-1", - }); - }); - - it("builds teammate thread identity and title", () => { - const target = { - kind: "teammate" as const, - runId: "run-1", - teamMemberId: "tm-1", - sessionId: "session-1", - }; - - expect(teammateThreadIdentity(target)).toBe("tm-1"); - expect(deriveThreadTitle({ target, step: null, lane: null })).toBe("Teammate: tm-1"); - }); -}); - -describe("deriveMissionStatusFromRun", () => { - it("keeps missions intervention_required while blocking manual input is open", () => { - const status = deriveMissionStatusFromRun( - { - run: { status: "active" }, - steps: [], - attempts: [], - timeline: [], - claims: [], - } as any, - { - status: "in_progress", - interventions: [ - { - id: "iv-1", - missionId: "mission-1", - interventionType: "manual_input", - status: "open", - title: "Need answer", - body: "Question", - requestedAction: null, - resolutionNote: null, - laneId: null, - createdAt: "", - updatedAt: "", - resolvedAt: null, - metadata: { canProceedWithoutAnswer: false }, - }, - ], - } as any - ); - - expect(status).toBe("intervention_required"); - }); - - it("keeps active missions in progress when manual input is optional", () => { - const status = deriveMissionStatusFromRun( - { - run: { status: "active" }, - steps: [], - attempts: [], - timeline: [], - claims: [], - } as any, - { - status: "in_progress", - interventions: [ - { - id: "iv-1", - missionId: "mission-1", - interventionType: "manual_input", - status: "open", - title: "Optional note", - body: "Question", - requestedAction: null, - resolutionNote: null, - laneId: null, - createdAt: "", - updatedAt: "", - resolvedAt: null, - metadata: { canProceedWithoutAnswer: true }, - }, - ], - } as any - ); - - expect(status).toBe("in_progress"); - }); - - it("maps canceled runs to canceled missions", () => { - const status = deriveMissionStatusFromRun( - { - run: { status: "canceled" }, - steps: [], - attempts: [], - timeline: [], - claims: [], - } as any, - { - status: "in_progress", - interventions: [], - } as any - ); - - expect(status).toBe("canceled"); - }); -}); - -describe("task step helpers", () => { - it("filters legacy task shell steps without reviving display-only rendering", () => { - const steps = [ - { id: "task-1", stepKey: "plan", metadata: { isTask: true, stepType: "task" } }, - { id: "step-1", stepKey: "impl", metadata: { stepType: "implementation" } }, - ] as any[]; - - expect(isDisplayOnlyTaskStep(steps[0])).toBe(false); - expect(isDisplayOnlyTaskStep(steps[1])).toBe(false); - expect(filterExecutionSteps(steps).map((step) => step.stepKey)).toEqual(["impl"]); - }); -}); diff --git a/apps/desktop/src/main/services/orchestrator/delegationContracts.test.ts b/apps/desktop/src/main/services/orchestrator/orchestratorPlanning.test.ts similarity index 60% rename from apps/desktop/src/main/services/orchestrator/delegationContracts.test.ts rename to apps/desktop/src/main/services/orchestrator/orchestratorPlanning.test.ts index d89cf1aca..9fc05c70c 100644 --- a/apps/desktop/src/main/services/orchestrator/delegationContracts.test.ts +++ b/apps/desktop/src/main/services/orchestrator/orchestratorPlanning.test.ts @@ -1,5 +1,14 @@ import { describe, expect, it } from "vitest"; import type { DelegationContract, OrchestratorRunGraph, OrchestratorStep } from "../../../shared/types"; +import { + deriveMissionStatusFromRun, + filterExecutionSteps, + isDisplayOnlyTaskStep, + parseChatTarget, + sanitizeChatTarget, + teammateThreadIdentity, + deriveThreadTitle, +} from "./orchestratorContext"; import { checkCoordinatorToolPermission, createDelegationContract, @@ -10,7 +19,154 @@ import { hasConflictingDelegationContract, } from "./delegationContracts"; -function createGraph(args?: { +describe("orchestratorContext teammate chat target handling", () => { + it("parses teammate targets from persisted JSON payloads", () => { + const parsed = parseChatTarget({ + kind: "teammate", + runId: "run-1", + teamMemberId: "tm-1", + sessionId: "session-1", + }); + + expect(parsed).toEqual({ + kind: "teammate", + runId: "run-1", + teamMemberId: "tm-1", + sessionId: "session-1", + }); + }); + + it("sanitizes teammate targets without dropping routing fields", () => { + const sanitized = sanitizeChatTarget({ + kind: "teammate", + runId: " run-1 ", + teamMemberId: " tm-1 ", + sessionId: " session-1 ", + }); + + expect(sanitized).toEqual({ + kind: "teammate", + runId: "run-1", + teamMemberId: "tm-1", + sessionId: "session-1", + }); + }); + + it("builds teammate thread identity and title", () => { + const target = { + kind: "teammate" as const, + runId: "run-1", + teamMemberId: "tm-1", + sessionId: "session-1", + }; + + expect(teammateThreadIdentity(target)).toBe("tm-1"); + expect(deriveThreadTitle({ target, step: null, lane: null })).toBe("Teammate: tm-1"); + }); +}); + +describe("deriveMissionStatusFromRun", () => { + it("keeps missions intervention_required while blocking manual input is open", () => { + const status = deriveMissionStatusFromRun( + { + run: { status: "active" }, + steps: [], + attempts: [], + timeline: [], + claims: [], + } as any, + { + status: "in_progress", + interventions: [ + { + id: "iv-1", + missionId: "mission-1", + interventionType: "manual_input", + status: "open", + title: "Need answer", + body: "Question", + requestedAction: null, + resolutionNote: null, + laneId: null, + createdAt: "", + updatedAt: "", + resolvedAt: null, + metadata: { canProceedWithoutAnswer: false }, + }, + ], + } as any + ); + + expect(status).toBe("intervention_required"); + }); + + it("keeps active missions in progress when manual input is optional", () => { + const status = deriveMissionStatusFromRun( + { + run: { status: "active" }, + steps: [], + attempts: [], + timeline: [], + claims: [], + } as any, + { + status: "in_progress", + interventions: [ + { + id: "iv-1", + missionId: "mission-1", + interventionType: "manual_input", + status: "open", + title: "Optional note", + body: "Question", + requestedAction: null, + resolutionNote: null, + laneId: null, + createdAt: "", + updatedAt: "", + resolvedAt: null, + metadata: { canProceedWithoutAnswer: true }, + }, + ], + } as any + ); + + expect(status).toBe("in_progress"); + }); + + it("maps canceled runs to canceled missions", () => { + const status = deriveMissionStatusFromRun( + { + run: { status: "canceled" }, + steps: [], + attempts: [], + timeline: [], + claims: [], + } as any, + { + status: "in_progress", + interventions: [], + } as any + ); + + expect(status).toBe("canceled"); + }); +}); + +describe("task step helpers", () => { + it("filters legacy task shell steps without reviving display-only rendering", () => { + const steps = [ + { id: "task-1", stepKey: "plan", metadata: { isTask: true, stepType: "task" } }, + { id: "step-1", stepKey: "impl", metadata: { stepType: "implementation" } }, + ] as any[]; + + expect(isDisplayOnlyTaskStep(steps[0])).toBe(false); + expect(isDisplayOnlyTaskStep(steps[1])).toBe(false); + expect(filterExecutionSteps(steps).map((step) => step.stepKey)).toEqual(["impl"]); + }); +}); + +function createDelegationGraph(args?: { steps?: OrchestratorStep[]; }): OrchestratorRunGraph { return { @@ -43,7 +199,7 @@ function createPlannerContract(overrides?: Partial): Delegat }); } -function createStep(args?: { +function createDelegationStep(args?: { id?: string; stepKey?: string; status?: OrchestratorStep["status"]; @@ -146,9 +302,9 @@ describe("delegationContracts", () => { status: "launching", launchState: "awaiting_worker_launch", }); - const graph = createGraph({ + const graph = createDelegationGraph({ steps: [ - createStep({ + createDelegationStep({ id: "step-planner", stepKey: "planner-worker", status: "running", @@ -185,9 +341,9 @@ describe("delegationContracts", () => { status: "active", launchState: "waiting_on_worker", }); - const graph = createGraph({ + const graph = createDelegationGraph({ steps: [ - createStep({ + createDelegationStep({ id: "step-existing", stepKey: "planner-worker", status: "running", @@ -226,9 +382,9 @@ describe("delegationContracts", () => { status: "active", launchState: "waiting_on_worker", }); - const boundedGraph = createGraph({ + const boundedGraph = createDelegationGraph({ steps: [ - createStep({ + createDelegationStep({ id: "step-bounded", stepKey: "parallel-worker", status: "running", diff --git a/apps/desktop/src/main/services/orchestrator/permissionMapping.test.ts b/apps/desktop/src/main/services/orchestrator/permissionMapping.test.ts deleted file mode 100644 index e84e87985..000000000 --- a/apps/desktop/src/main/services/orchestrator/permissionMapping.test.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { mapPermissionToClaude, mapPermissionToCodex, mergeMissionPermissionConfig, normalizeMissionPermissions } from "./permissionMapping"; - -describe("permissionMapping", () => { - it("maps Codex edit to writable guarded execution", () => { - expect(mapPermissionToCodex("edit")).toEqual({ - approvalPolicy: "untrusted", - sandbox: "workspace-write", - }); - expect(mapPermissionToCodex("plan")).toEqual({ - approvalPolicy: "on-request", - sandbox: "read-only", - }); - expect(mapPermissionToCodex("default")).toEqual({ - approvalPolicy: "on-request", - sandbox: "workspace-write", - }); - }); - - it("preserves Codex full-auto and Claude accept-edits semantics", () => { - expect(mapPermissionToCodex("full-auto")).toEqual({ - approvalPolicy: "never", - sandbox: "danger-full-access", - }); - expect(mapPermissionToClaude("edit")).toBe("acceptEdits"); - }); - - it("merges raw mission overrides without resetting unrelated provider settings", () => { - const merged = mergeMissionPermissionConfig( - { - providers: { - codex: "config-toml", - claude: "edit", - }, - }, - { - inProcess: { mode: "plan" }, - }, - ); - - expect(normalizeMissionPermissions(merged)).toMatchObject({ - codex: "config-toml", - claude: "edit", - opencode: "plan", - }); - }); -}); diff --git a/apps/desktop/src/main/services/orchestrator/planningFlowAndHandoffs.test.ts b/apps/desktop/src/main/services/orchestrator/planningFlowAndHandoffs.test.ts deleted file mode 100644 index 354f0cbe6..000000000 --- a/apps/desktop/src/main/services/orchestrator/planningFlowAndHandoffs.test.ts +++ /dev/null @@ -1,566 +0,0 @@ -import { describe, expect, it } from "vitest"; -import path from "node:path"; -import fs from "node:fs"; -import os from "node:os"; -import { buildFullPrompt } from "./baseOrchestratorAdapter"; -import { createOrchestratorService } from "./orchestratorService"; -import { openKvDb } from "../state/kvDb"; -import { classifyBlockingWarnings } from "./orchestratorQueries"; -import type { OrchestratorAttemptResultEnvelope } from "../../../shared/types/orchestrator"; -import type { PackExport, PackType } from "../../../shared/types"; - -// ── Shared Helpers ────────────────────────────────────────────── - -function createLogger() { - return { - debug: () => {}, - info: () => {}, - warn: () => {}, - error: () => {}, - } as any; -} - -function buildExport( - packKey: string, - packType: PackType, - level: string -): PackExport { - return { - packKey, - packType, - level: level as any, - header: {} as any, - content: `${packKey}:${level}`, - approxTokens: 32, - maxTokens: 500, - truncated: false, - warnings: [], - clipReason: null, - omittedSections: null, - }; -} - -async function createFixture() { - const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-planning-flow-")); - const db = await openKvDb(path.join(projectRoot, "ade.db"), createLogger()); - const projectId = "proj-1"; - const laneId = "lane-1"; - const missionId = "mission-1"; - const runId = "run-1"; - const now = "2026-03-10T00:00:00.000Z"; - - db.run( - `insert into projects(id, root_path, display_name, default_base_ref, created_at, last_opened_at) - values (?, ?, ?, ?, ?, ?)`, - [projectId, projectRoot, "ADE", "main", now, now] - ); - - const worktreePath = path.join(projectRoot, "worktree-lane-1"); - fs.mkdirSync(worktreePath, { recursive: true }); - - db.run( - `insert into lanes( - id, project_id, name, description, lane_type, base_ref, branch_ref, - worktree_path, attached_root_path, is_edit_protected, parent_lane_id, - color, icon, tags_json, status, created_at, archived_at - ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, - [ - laneId, projectId, "Lane 1", null, "worktree", "main", "feature/lane-1", - worktreePath, null, 0, null, null, null, null, "active", now, null, - ] - ); - - db.run( - `insert into missions( - id, project_id, lane_id, title, prompt, status, priority, - execution_mode, target_machine_id, outcome_summary, last_error, - metadata_json, created_at, updated_at, started_at, completed_at - ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, - [ - missionId, projectId, laneId, "Mission 1", "Test planning flow.", - "in_progress", "normal", "local", null, null, null, null, now, now, null, null, - ] - ); - - const ptyService = { - create: async () => ({ ptyId: "pty-1", sessionId: "session-1" }), - } as any; - - const service = createOrchestratorService({ - db, - projectId, - projectRoot, - ptyService, - projectConfigService: null as any, - aiIntegrationService: null as any, - memoryService: null as any, - }); - - return { - db, - service, - projectId, - projectRoot, - laneId, - missionId, - runId, - worktreePath, - now, - dispose: () => { - db.close(); - fs.rmSync(projectRoot, { recursive: true, force: true }); - }, - }; -} - -// ───────────────────────────────────────────────────────────────── -// VAL-PLAN-001: Planning workers return plan payloads instead of writing files -// ───────────────────────────────────────────────────────────────── - -describe("VAL-PLAN-001: Planning workers return plan payloads", () => { - it("buildFullPrompt for planning step forbids plan file writes and requires report_result plan payload", () => { - const result = buildFullPrompt( - { - run: { - id: "run-1", - missionId: "mission-1", - metadata: { missionGoal: "Plan the feature" }, - } as any, - step: { - id: "step-1", - title: "Plan the feature", - stepKey: "plan-feature", - laneId: "lane-1", - metadata: { - stepType: "planning", - readOnlyExecution: true, - laneWorktreePath: "/tmp/worktree/lane-1", - }, - dependencyStepIds: [], - joinPolicy: "all_success", - } as any, - attempt: {} as any, - allSteps: [], - contextProfile: {} as any, - laneExport: null, - projectExport: { content: "Project context" } as any, - docsRefs: [], - fullDocs: [], - createTrackedSession: async () => ({ ptyId: "pty-1", sessionId: "session-1" }), - }, - "opencode", - {} - ); - - expect(result.prompt).toContain("Do not create directories or write plan files yourself."); - expect(result.prompt).toContain("plan` object"); - expect(result.prompt).toContain("ADE will persist the canonical mission plan artifact"); - }); - - it("planning step prompt includes 'Do not use ExitPlanMode' instruction", () => { - const result = buildFullPrompt( - { - run: { - id: "run-1", - missionId: "mission-1", - metadata: { missionGoal: "Plan the feature" }, - } as any, - step: { - id: "step-1", - title: "Plan the feature", - stepKey: "plan-feature", - laneId: "lane-1", - metadata: { - stepType: "planning", - readOnlyExecution: true, - }, - dependencyStepIds: [], - joinPolicy: "all_success", - } as any, - attempt: {} as any, - allSteps: [], - contextProfile: {} as any, - laneExport: null, - projectExport: { content: "Project context" } as any, - docsRefs: [], - fullDocs: [], - createTrackedSession: async () => ({ ptyId: "pty-1", sessionId: "session-1" }), - }, - "opencode", - {} - ); - - expect(result.prompt.toLowerCase()).toContain("do not use exitplanmode"); - }); -}); - -// ───────────────────────────────────────────────────────────────── -// VAL-PLAN-002: ExitPlanMode errors handled gracefully -// ───────────────────────────────────────────────────────────────── - -describe("VAL-PLAN-002: planning worker tool failures stay blocking", () => { - it("classifyBlockingWarnings treats ~/.claude/plans/ sandbox block as blocking", () => { - const result = classifyBlockingWarnings({ - warnings: [ - "Tool 'ExitPlanMode' failed: PreToolUse:Write hook error: SANDBOX BLOCKED: File path outside sandbox: /Users/admin/.claude/plans/temporal-kindling-platypus.md", - ], - summary: "Planning completed successfully.", - }); - - expect(result.hasBlockingFailure).toBe(true); - expect(result.category).toBe("sandbox_block"); - }); - - it("still blocks real sandbox violations for non-plan paths", () => { - const result = classifyBlockingWarnings({ - warnings: [ - "Tool 'Write' failed: PreToolUse:Write hook error: SANDBOX BLOCKED: File path outside sandbox: /etc/passwd", - ], - summary: null, - }); - - expect(result.hasBlockingFailure).toBe(true); - expect(result.category).toBe("sandbox_block"); - }); - - it("treats ~/.claude/plans/ sandbox blocks as blocking regardless of tool name", () => { - const result = classifyBlockingWarnings({ - warnings: [ - "Tool 'Write' failed: SANDBOX BLOCKED: File path outside sandbox: /Users/admin/.claude/plans/foo.md", - ], - summary: null, - }); - - expect(result.hasBlockingFailure).toBe(true); - expect(result.category).toBe("sandbox_block"); - }); -}); - -// ───────────────────────────────────────────────────────────────── -// VAL-PLAN-003: Planner has ask_user available -// ───────────────────────────────────────────────────────────────── - -describe("VAL-PLAN-003: Planner has ask_user available", () => { - it("planning step prompt mentions ask_user as available mechanism for clarifications", () => { - const result = buildFullPrompt( - { - run: { - id: "run-1", - missionId: "mission-1", - metadata: { missionGoal: "Plan the feature" }, - } as any, - step: { - id: "step-1", - title: "Plan the feature", - stepKey: "plan-feature", - laneId: "lane-1", - metadata: { - stepType: "planning", - readOnlyExecution: true, - }, - dependencyStepIds: [], - joinPolicy: "all_success", - } as any, - attempt: {} as any, - allSteps: [], - contextProfile: {} as any, - laneExport: null, - projectExport: { content: "Project context" } as any, - docsRefs: [], - fullDocs: [], - createTrackedSession: async () => ({ ptyId: "pty-1", sessionId: "session-1" }), - }, - "opencode", - {} - ); - - // Planning workers must be told about ask_user for clarifications - expect(result.prompt).toContain("ask_user"); - }); -}); - -// ───────────────────────────────────────────────────────────────── -// VAL-HAND-001: Workers produce structured handoff data on completion -// ───────────────────────────────────────────────────────────────── - -describe("VAL-HAND-001: Succeeded attempts have worker digest", () => { - it("succeeded attempt result envelope contains structured digest data", async () => { - const fixture = await createFixture(); - try { - const { db, service, projectId, missionId, laneId, now } = fixture; - - // Create a run via the service - const started = await service.startRun({ - missionId, - steps: [ - { - stepKey: "implement-alpha", - stepIndex: 0, - title: "Implement Alpha", - laneId, - executorKind: "opencode", - metadata: { - modelId: "anthropic/claude-sonnet-4-6", - lastResultReport: { - summary: "Alpha implemented successfully. Added new API endpoint.", - filesChanged: ["src/alpha.ts", "src/alpha.test.ts"], - testsRun: { passed: 5, failed: 0, skipped: 0 }, - }, - }, - }, - ], - }); - - const alphaStep = started.steps.find((s) => s.stepKey === "implement-alpha")!; - expect(alphaStep).toBeTruthy(); - - // Start an attempt - const attempt = await service.startAttempt({ - runId: started.run.id, - stepId: alphaStep.id, - ownerId: "worker-1", - executorKind: "opencode", - }); - - // Complete the attempt with success - const completed = await service.completeAttempt({ - attemptId: attempt.id, - status: "succeeded", - result: { - schema: "ade.orchestratorAttempt.v1", - success: true, - summary: "Alpha implemented successfully.", - outputs: { - filesChanged: ["src/alpha.ts", "src/alpha.test.ts"], - testsPassed: 5, - testsFailed: 0, - testsSkipped: 0, - }, - warnings: [], - sessionId: null, - trackedSession: false, - }, - }); - - // Verify the result envelope has structured data - expect(completed.resultEnvelope).toBeTruthy(); - expect(completed.resultEnvelope!.success).toBe(true); - expect(completed.resultEnvelope!.summary.length).toBeGreaterThan(0); - expect(completed.resultEnvelope!.outputs).toBeTruthy(); - const outputs = completed.resultEnvelope!.outputs as Record; - expect(Array.isArray(outputs.filesChanged)).toBe(true); - expect((outputs.filesChanged as string[]).length).toBeGreaterThan(0); - } finally { - fixture.dispose(); - } - }); -}); - -// ───────────────────────────────────────────────────────────────── -// VAL-HAND-002: Handoff summaries injected into downstream worker prompts -// ───────────────────────────────────────────────────────────────── - -describe("VAL-HAND-002: Handoff summaries injected into downstream prompts", () => { - it("buildFullPrompt includes handoffSummaries from upstream steps", () => { - const result = buildFullPrompt( - { - run: { - id: "run-1", - missionId: "mission-1", - metadata: { missionGoal: "Build the feature" }, - } as any, - step: { - id: "step-2", - title: "Implement Beta", - stepKey: "implement-beta", - laneId: "lane-1", - metadata: { - handoffSummaries: [ - "[implement-alpha] (succeeded) Alpha implemented. | Files: src/alpha.ts | Tests: 5 passed", - ], - }, - dependencyStepIds: ["step-1"], - joinPolicy: "all_success", - } as any, - attempt: {} as any, - allSteps: [], - contextProfile: {} as any, - laneExport: null, - projectExport: { content: "Project context" } as any, - docsRefs: [], - fullDocs: [], - createTrackedSession: async () => ({ ptyId: "pty-1", sessionId: "session-1" }), - }, - "opencode", - {} - ); - - expect(result.prompt).toContain("Context from upstream steps"); - expect(result.prompt).toContain("implement-alpha"); - expect(result.prompt).toContain("Alpha implemented"); - }); - - it("buildFullPrompt without handoffSummaries omits upstream section", () => { - const result = buildFullPrompt( - { - run: { - id: "run-1", - missionId: "mission-1", - metadata: { missionGoal: "Build the feature" }, - } as any, - step: { - id: "step-2", - title: "Implement Beta", - stepKey: "implement-beta", - laneId: "lane-1", - metadata: {}, - dependencyStepIds: [], - joinPolicy: "all_success", - } as any, - attempt: {} as any, - allSteps: [], - contextProfile: {} as any, - laneExport: null, - projectExport: { content: "Project context" } as any, - docsRefs: [], - fullDocs: [], - createTrackedSession: async () => ({ ptyId: "pty-1", sessionId: "session-1" }), - }, - "opencode", - {} - ); - - expect(result.prompt).not.toContain("Context from upstream steps"); - }); -}); - -// ───────────────────────────────────────────────────────────────── -// VAL-ART-001: Planning artifacts registered as mission artifacts -// ───────────────────────────────────────────────────────────────── - -describe("VAL-ART-001: Planning artifacts registered as mission artifacts", () => { - it("addArtifact can register a plan artifact via orchestrator service", async () => { - const fixture = await createFixture(); - try { - const { service, missionId, laneId, now } = fixture; - - // Start a run to get IDs - const started = await service.startRun({ - missionId, - steps: [ - { - stepKey: "plan-step", - stepIndex: 0, - title: "Planning", - laneId, - executorKind: "opencode", - metadata: { - modelId: "anthropic/claude-sonnet-4-6", - stepType: "planning", - readOnlyExecution: true, - }, - }, - ], - }); - const step = started.steps[0]!; - const attempt = await service.startAttempt({ - runId: started.run.id, - stepId: step.id, - ownerId: "planner-1", - executorKind: "opencode", - }); - - // Register a plan artifact via the orchestrator service registerArtifact - const artifact = service.registerArtifact({ - missionId, - runId: started.run.id, - stepId: step.id, - attemptId: attempt.id, - artifactKey: "plan-output", - kind: "custom", - value: ".ade/plans/mission-plan.md", - metadata: { planType: "mission_plan", source: "planning_worker" }, - }); - - expect(artifact).toBeTruthy(); - expect(artifact.artifactKey).toBe("plan-output"); - expect(artifact.kind).toBe("custom"); - expect(artifact.value).toContain(".ade/plans/"); - - // Verify it can be queried back via getArtifactsForStep - const artifacts = service.getArtifactsForStep(step.id); - const planArtifact = artifacts.find((a) => a.artifactKey === "plan-output"); - expect(planArtifact).toBeTruthy(); - expect(planArtifact!.kind).toBe("custom"); - expect(planArtifact!.value).toContain(".ade/plans/"); - } finally { - fixture.dispose(); - } - }); -}); - -// ───────────────────────────────────────────────────────────────── -// VAL-ART-002: getWorkerCheckpoint resolves using lane worktree path -// ───────────────────────────────────────────────────────────────── - -describe("VAL-ART-002: getWorkerCheckpoint resolves using lane worktree path", () => { - it("getWorkerCheckpoint returns persisted content for step with checkpoint", async () => { - const fixture = await createFixture(); - try { - const { service, missionId, laneId } = fixture; - - const started = await service.startRun({ - missionId, - steps: [ - { - stepKey: "test-step", - stepIndex: 0, - title: "Test Step", - laneId, - executorKind: "opencode", - metadata: { modelId: "anthropic/claude-sonnet-4-6" }, - }, - ], - }); - const step = started.steps[0]!; - const attempt = await service.startAttempt({ - runId: started.run.id, - stepId: step.id, - ownerId: "worker-1", - executorKind: "opencode", - }); - - // Upsert a checkpoint - service.upsertWorkerCheckpoint({ - missionId, - runId: started.run.id, - stepId: step.id, - attemptId: attempt.id, - stepKey: "test-step", - content: "## Checkpoint\n- Implemented feature X\n- Modified file.ts", - filePath: path.join(fixture.worktreePath, ".ade", "checkpoints", "test-step.md"), - }); - - // Retrieve the checkpoint - const checkpoint = service.getWorkerCheckpoint({ missionId, stepKey: "test-step" }); - expect(checkpoint).toBeTruthy(); - expect(checkpoint!.content).toContain("Implemented feature X"); - expect(checkpoint!.stepKey).toBe("test-step"); - } finally { - fixture.dispose(); - } - }); - - it("getWorkerCheckpoint returns null for non-existent checkpoint", async () => { - const fixture = await createFixture(); - try { - const checkpoint = fixture.service.getWorkerCheckpoint({ - missionId: fixture.missionId, - stepKey: "non-existent-step", - }); - expect(checkpoint).toBeNull(); - } finally { - fixture.dispose(); - } - }); -}); diff --git a/apps/desktop/src/main/services/orchestrator/planningGapsFixes.test.ts b/apps/desktop/src/main/services/orchestrator/planningGapsFixes.test.ts deleted file mode 100644 index f88921a4a..000000000 --- a/apps/desktop/src/main/services/orchestrator/planningGapsFixes.test.ts +++ /dev/null @@ -1,613 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; -import { buildClaudeReadOnlyWorkerAllowedTools } from "./providerOrchestratorAdapter"; -import { classifyBlockingWarnings } from "./orchestratorQueries"; -import { extractAndRegisterArtifacts } from "./workerTracking"; -import { createOrchestratorService } from "./orchestratorService"; -import { createMissionService } from "../missions/missionService"; -import { openKvDb } from "../state/kvDb"; -import type { OrchestratorRunGraph } from "../../../shared/types/orchestrator"; - -// ───────────────────────────────────────────────────────────────── -// VAL-PLAN-003: planning worker read-only native tool allowlist -// ───────────────────────────────────────────────────────────────── - -describe("VAL-PLAN-003: planning worker read-only allowlist", () => { - it("includes only native read-only tools by default", () => { - const tools = buildClaudeReadOnlyWorkerAllowedTools(); - expect(tools).toEqual(["Read", "Glob", "Grep"]); - }); - - it("deduplicates caller-provided extra read-only tools", () => { - const tools = buildClaudeReadOnlyWorkerAllowedTools(["Read", "NotebookRead"]); - expect(tools).toEqual(["Read", "Glob", "Grep", "NotebookRead"]); - }); - -}); - -// ───────────────────────────────────────────────────────────────── -// VAL-PLAN-002: ExitPlanMode Zod validation errors handled gracefully -// ───────────────────────────────────────────────────────────────── - -describe("VAL-PLAN-002: ExitPlanMode Zod errors handled cleanly", () => { - it("treats ExitPlanMode Zod validation error as blocking", () => { - const result = classifyBlockingWarnings({ - warnings: [ - "Tool 'ExitPlanMode' failed: Zod validation error: Expected string, received number at path 'planDescription'", - ], - summary: "Planning completed with some tool errors.", - }); - expect(result.hasBlockingFailure).toBe(true); - expect(result.category).toBe("tool_failure"); - }); - - it("treats ExitPlanMode schema parse error as blocking", () => { - const result = classifyBlockingWarnings({ - warnings: [ - "Tool 'ExitPlanMode' failed: schema parse error: invalid input", - ], - summary: null, - }); - expect(result.hasBlockingFailure).toBe(true); - expect(result.category).toBe("tool_failure"); - }); - - it("treats Zod validation with ExitPlanMode context as blocking", () => { - const result = classifyBlockingWarnings({ - warnings: [ - "Zod validation failed for tool ExitPlanMode: Required field missing", - ], - summary: null, - }); - expect(result.hasBlockingFailure).toBe(true); - }); - - it("still blocks genuine tool failures unrelated to ExitPlanMode", () => { - const result = classifyBlockingWarnings({ - warnings: [ - "Tool 'Write' failed: SANDBOX BLOCKED: File path outside sandbox: /etc/passwd", - ], - summary: null, - }); - expect(result.hasBlockingFailure).toBe(true); - expect(result.category).toBe("sandbox_block"); - }); - - it("treats ~/.claude/plans/ sandbox blocks as blocking", () => { - const result = classifyBlockingWarnings({ - warnings: [ - "Tool 'ExitPlanMode' failed: PreToolUse:Write hook error: SANDBOX BLOCKED: File path outside sandbox: /Users/admin/.claude/plans/foo.md", - ], - summary: null, - }); - expect(result.hasBlockingFailure).toBe(true); - expect(result.category).toBe("sandbox_block"); - }); - - it("ExitPlanMode validation errors stay classified as blocking failures", () => { - const result = classifyBlockingWarnings({ - warnings: [ - "Tool 'ExitPlanMode' failed: Zod validation error: Invalid input", - ], - summary: "Plan written successfully.", - }); - expect(result.hasBlockingFailure).toBe(true); - expect(result.category).toBe("tool_failure"); - }); -}); - -// ───────────────────────────────────────────────────────────────── -// VAL-ART-001: Planning artifacts registered after planner completes -// ───────────────────────────────────────────────────────────────── - -describe("VAL-ART-001: Planning step registers plan artifact", () => { - function buildMockCtx() { - const registeredArtifacts: Array> = []; - const missionArtifacts: Array> = []; - const worktreePath = fs.mkdtempSync(path.join(os.tmpdir(), "ade-plan-artifacts-")); - fs.mkdirSync(path.join(worktreePath, ".ade", "plans"), { recursive: true }); - return { - ctx: { - projectRoot: worktreePath, - db: { - get: vi.fn(() => ({ worktree_path: worktreePath })), - }, - missionService: { - addArtifact: vi.fn((artifact: Record) => { - missionArtifacts.push(artifact); - return artifact; - }), - addIntervention: vi.fn((intervention: Record) => ({ - id: "intervention-1", - missionId: "mission-1", - status: "open", - createdAt: "2026-03-10T00:05:00.000Z", - updatedAt: "2026-03-10T00:05:00.000Z", - resolvedAt: null, - resolutionNote: null, - laneId: null, - ...intervention, - })), - }, - orchestratorService: { - registerArtifact: vi.fn((artifact: Record) => { - registeredArtifacts.push(artifact); - return artifact; - }), - appendTimelineEvent: vi.fn(), - appendRuntimeEvent: vi.fn(), - }, - logger: { - debug: vi.fn(), - info: vi.fn(), - warn: vi.fn(), - error: vi.fn(), - }, - } as any, - registeredArtifacts, - missionArtifacts, - worktreePath, - }; - } - - function buildPlanningAttempt(overrides?: { - stepMeta?: Record; - envelopeSummary?: string; - outputs?: Record; - plan?: Record | null; - }): { - graph: OrchestratorRunGraph; - attempt: OrchestratorRunGraph["attempts"][number]; - } { - const stepMeta = overrides?.stepMeta ?? { - stepType: "planning", - readOnlyExecution: true, - }; - const attempt = { - id: "attempt-1", - runId: "run-1", - stepId: "step-1", - status: "succeeded" as const, - executorSessionId: "session-1", - executorKind: "opencode" as const, - createdAt: "2026-03-10T00:00:00.000Z", - completedAt: "2026-03-10T00:05:00.000Z", - resultEnvelope: { - schema: "ade.orchestratorAttempt.v1", - success: true, - summary: overrides?.envelopeSummary ?? "Planning completed. Created architecture plan.", - outputs: overrides?.outputs ?? {}, - warnings: [], - sessionId: "session-1", - trackedSession: true, - }, - metadata: {}, - } as any; - - const graph = { - run: { - id: "run-1", - missionId: "mission-1", - status: "active", - metadata: {}, - }, - steps: [ - { - id: "step-1", - stepKey: "planning-worker", - title: "Plan the feature", - laneId: "lane-1", - status: "running", - metadata: { - ...stepMeta, - lastResultReport: { - workerId: "planning-worker", - runId: "run-1", - missionId: "mission-1", - outcome: "succeeded", - summary: overrides?.envelopeSummary ?? "Planning completed. Created architecture plan.", - plan: overrides?.plan === null - ? null - : { - markdown: "# Mission plan\n\n- Investigate auth flow\n- Update tests\n", - ...(overrides?.outputs?.planPath ? { artifactPath: String(overrides.outputs.planPath) } : {}), - ...(overrides?.plan ?? {}), - }, - artifacts: [], - filesChanged: [], - testsRun: null, - reportedAt: "2026-03-10T00:05:00.000Z", - }, - }, - dependencyStepIds: [], - joinPolicy: "all_success", - retryCount: 0, - retryLimit: 2, - }, - ], - attempts: [attempt], - } as any; - - return { graph, attempt }; - } - - it("registers a 'plan' artifact for planning steps on success", () => { - const { ctx, registeredArtifacts } = buildMockCtx(); - const { graph, attempt } = buildPlanningAttempt(); - - extractAndRegisterArtifacts(ctx, { graph, attempt }); - - const planArtifact = registeredArtifacts.find( - (a) => a.artifactKey === "plan" - ); - expect(planArtifact).toBeTruthy(); - expect(planArtifact!.kind).toBe("custom"); - expect(planArtifact!.metadata).toMatchObject({ - planType: "mission_plan", - source: "ade_persisted_plan", - }); - }); - - it("plan artifact has valid value path", () => { - const { ctx, registeredArtifacts } = buildMockCtx(); - const { graph, attempt } = buildPlanningAttempt(); - - extractAndRegisterArtifacts(ctx, { graph, attempt }); - - const planArtifact = registeredArtifacts.find( - (a) => a.artifactKey === "plan" - ); - expect(planArtifact).toBeTruthy(); - expect(typeof planArtifact!.value).toBe("string"); - expect((planArtifact!.value as string).length).toBeGreaterThan(0); - }); - - it("uses custom planPath from outputs when provided", () => { - const { ctx, registeredArtifacts } = buildMockCtx(); - const { graph, attempt } = buildPlanningAttempt({ - outputs: { planPath: ".ade/plans/custom-plan.md" }, - }); - - extractAndRegisterArtifacts(ctx, { graph, attempt }); - - const planArtifact = registeredArtifacts.find( - (a) => a.artifactKey === "plan" - ); - expect(planArtifact).toBeTruthy(); - expect(planArtifact!.value).toBe(".ade/plans/custom-plan.md"); - }); - - it("falls back to default plan path when outputs.planPath is absent", () => { - const { ctx, registeredArtifacts } = buildMockCtx(); - const { graph, attempt } = buildPlanningAttempt({ - outputs: {}, - }); - - extractAndRegisterArtifacts(ctx, { graph, attempt }); - - const planArtifact = registeredArtifacts.find( - (a) => a.artifactKey === "plan" - ); - expect(planArtifact).toBeTruthy(); - expect(planArtifact!.value).toBe(".ade/plans/mission-plan.md"); - }); - - it("does NOT register plan artifact for non-planning steps", () => { - const { ctx, registeredArtifacts } = buildMockCtx(); - const { graph, attempt } = buildPlanningAttempt({ - stepMeta: { stepType: "implementation" }, - }); - - extractAndRegisterArtifacts(ctx, { graph, attempt }); - - const planArtifact = registeredArtifacts.find( - (a) => a.artifactKey === "plan" - ); - expect(planArtifact).toBeUndefined(); - }); - - it("registers plan artifact for phaseKey=planning steps", () => { - const { ctx, registeredArtifacts } = buildMockCtx(); - const { graph, attempt } = buildPlanningAttempt({ - stepMeta: { phaseKey: "Planning" }, - }); - - extractAndRegisterArtifacts(ctx, { graph, attempt }); - - const planArtifact = registeredArtifacts.find( - (a) => a.artifactKey === "plan" - ); - expect(planArtifact).toBeTruthy(); - }); - - it("plan artifact metadata includes envelope summary", () => { - const { ctx, registeredArtifacts } = buildMockCtx(); - const { graph, attempt } = buildPlanningAttempt({ - envelopeSummary: "Designed API for auth module with 3 endpoints.", - }); - - extractAndRegisterArtifacts(ctx, { graph, attempt }); - - const planArtifact = registeredArtifacts.find( - (a) => a.artifactKey === "plan" - ); - expect(planArtifact).toBeTruthy(); - expect((planArtifact!.metadata as Record).summary).toBe( - "Designed API for auth module with 3 endpoints." - ); - }); - - it("plan artifact is queryable (registered via registerArtifact on orchestratorService)", () => { - const { ctx } = buildMockCtx(); - const { graph, attempt } = buildPlanningAttempt(); - - extractAndRegisterArtifacts(ctx, { graph, attempt }); - - // Verify the artifact was registered via the service - expect(ctx.orchestratorService.registerArtifact).toHaveBeenCalled(); - const planCall = ctx.orchestratorService.registerArtifact.mock.calls.find( - (call: any[]) => call[0].artifactKey === "plan" - ); - expect(planCall).toBeTruthy(); - expect(planCall![0]).toMatchObject({ - missionId: "mission-1", - runId: "run-1", - stepId: "step-1", - attemptId: "attempt-1", - artifactKey: "plan", - kind: "custom", - }); - }); - - it("opens an explicit failed_step intervention when the plan payload is missing", () => { - const { ctx } = buildMockCtx(); - const { graph, attempt } = buildPlanningAttempt({ - plan: null, - }); - - extractAndRegisterArtifacts(ctx, { graph, attempt }); - - expect(ctx.missionService.addIntervention).toHaveBeenCalledWith( - expect.objectContaining({ - interventionType: "failed_step", - title: "Planner result missing plan", - }), - ); - }); -}); - -describe("VAL-PLAN-004: planner contract enforcement", () => { - it("fails a planning attempt that completes without report_result.plan.markdown", async () => { - const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-plan-contract-")); - const db = await openKvDb(path.join(projectRoot, "ade.db"), { - debug: () => {}, - info: () => {}, - warn: () => {}, - error: () => {}, - } as any); - const projectId = "proj-1"; - const laneId = "lane-1"; - const missionId = "mission-1"; - const now = "2026-03-12T00:00:00.000Z"; - - db.run( - `insert into projects(id, root_path, display_name, default_base_ref, created_at, last_opened_at) - values (?, ?, ?, ?, ?, ?)`, - [projectId, projectRoot, "ADE", "main", now, now] - ); - db.run( - `insert into lanes( - id, project_id, name, description, lane_type, base_ref, branch_ref, - worktree_path, attached_root_path, is_edit_protected, parent_lane_id, - color, icon, tags_json, status, created_at, archived_at - ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, - [ - laneId, projectId, "Lane 1", null, "worktree", "main", "feature/planning", - projectRoot, null, 0, null, null, null, null, "active", now, null, - ] - ); - db.run( - `insert into missions( - id, project_id, lane_id, title, prompt, status, priority, - execution_mode, target_machine_id, outcome_summary, last_error, - metadata_json, created_at, updated_at, started_at, completed_at - ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, - [ - missionId, projectId, laneId, "Planner contract", "Create a plan.", - "planning", "normal", "local", null, null, null, null, now, now, now, null, - ] - ); - - const orchestratorService = createOrchestratorService({ - db, - projectId, - projectRoot, - ptyService: { - create: vi.fn(async () => ({ ptyId: "pty-1", sessionId: "session-1" })), - } as any, - projectConfigService: null as any, - aiIntegrationService: null as any, - memoryService: null as any, - }); - createMissionService({ db, projectId, projectRoot }); - - try { - const started = await orchestratorService.startRun({ - missionId, - steps: [ - { - stepKey: "planning-worker", - stepIndex: 0, - title: "Plan the work", - executorKind: "manual", - laneId, - metadata: { - stepType: "planning", - phaseKey: "planning", - readOnlyExecution: true, - }, - }, - ], - }); - const step = started.steps[0]!; - db.run( - "update orchestrator_steps set metadata_json = ? where id = ? and project_id = ?", - [ - JSON.stringify({ - stepType: "planning", - phaseKey: "planning", - readOnlyExecution: true, - lastResultReport: { - summary: "needed", - outputs: null, - }, - }), - step.id, - projectId, - ] - ); - - const attempt = await orchestratorService.startAttempt({ - runId: started.run.id, - stepId: step.id, - ownerId: "planner", - executorKind: "manual", - }); - - const completed = await orchestratorService.completeAttempt({ - attemptId: attempt.id, - status: "succeeded", - }); - - const graph = orchestratorService.getRunGraph({ runId: started.run.id, timelineLimit: 20 }); - const updatedStep = graph.steps.find((entry) => entry.id === step.id); - const planningArtifactEvents = orchestratorService.listRuntimeEvents({ - runId: started.run.id, - attemptId: attempt.id, - eventTypes: ["planning_artifact_missing"], - }); - - expect(completed.status).toBe("failed"); - expect(completed.errorClass).toBe("planner_contract_violation"); - expect(completed.errorMessage).toContain("report_result.plan.markdown"); - expect(updatedStep?.status).toBe("failed"); - expect(planningArtifactEvents).toHaveLength(1); - expect(planningArtifactEvents[0]?.payload).toMatchObject({ - reason: "planner_plan_missing", - expectedPlanPath: ".ade/plans/mission-plan.md", - }); - } finally { - db.close(); - } - }); - - it("emits distinct artifact-missing and intervention-opened runtime events for planner failures", () => { - const worktreePath = fs.mkdtempSync(path.join(os.tmpdir(), "ade-plan-artifacts-")); - fs.mkdirSync(path.join(worktreePath, ".ade", "plans"), { recursive: true }); - const appendRuntimeEvent = vi.fn(); - const ctx = { - projectRoot: worktreePath, - db: { - get: vi.fn(() => ({ worktree_path: worktreePath })), - }, - missionService: { - addArtifact: vi.fn(), - addIntervention: vi.fn((intervention: Record) => ({ - id: "intervention-1", - missionId: "mission-1", - status: "open", - createdAt: "2026-03-10T00:05:00.000Z", - updatedAt: "2026-03-10T00:05:00.000Z", - resolvedAt: null, - resolutionNote: null, - laneId: null, - ...intervention, - })), - }, - orchestratorService: { - registerArtifact: vi.fn(), - appendTimelineEvent: vi.fn(), - appendRuntimeEvent, - }, - logger: { - debug: vi.fn(), - info: vi.fn(), - warn: vi.fn(), - error: vi.fn(), - }, - } as any; - const attempt = { - id: "attempt-1", - runId: "run-1", - stepId: "step-1", - status: "succeeded" as const, - executorSessionId: "session-1", - executorKind: "opencode" as const, - createdAt: "2026-03-10T00:00:00.000Z", - completedAt: "2026-03-10T00:05:00.000Z", - resultEnvelope: { - schema: "ade.orchestratorAttempt.v1", - success: true, - summary: "Planner finished without reporting a plan artifact.", - outputs: {}, - warnings: [], - sessionId: "session-1", - trackedSession: true, - }, - metadata: {}, - } as any; - const graph = { - run: { - id: "run-1", - missionId: "mission-1", - status: "active", - metadata: {}, - }, - steps: [ - { - id: "step-1", - stepKey: "planning-worker", - title: "Plan the work", - laneId: "lane-1", - status: "running", - metadata: { - stepType: "planning", - readOnlyExecution: true, - lastResultReport: { - workerId: "planning-worker", - runId: "run-1", - missionId: "mission-1", - outcome: "succeeded", - summary: "Planner finished without reporting a plan artifact.", - plan: null, - artifacts: [], - filesChanged: [], - testsRun: null, - reportedAt: "2026-03-10T00:05:00.000Z", - }, - }, - dependencyStepIds: [], - joinPolicy: "all_success", - retryCount: 0, - retryLimit: 2, - }, - ], - attempts: [attempt], - } as any; - - extractAndRegisterArtifacts(ctx, { graph, attempt }); - - const planningArtifactEvent = ctx.orchestratorService.appendTimelineEvent.mock.calls - .map(([event]: [Record]) => event) - .find((event: Record) => event.eventType === "planning_artifact_missing"); - const interventionOpenedEvent = appendRuntimeEvent.mock.calls - .map(([event]: [Record]) => event) - .find((event: Record) => event.eventType === "intervention_opened"); - - expect(planningArtifactEvent).toBeTruthy(); - expect(interventionOpenedEvent).toBeTruthy(); - expect(interventionOpenedEvent?.eventKey).toBe("intervention_opened:intervention-1"); - }); -}); diff --git a/apps/desktop/src/main/services/orchestrator/providerOrchestratorAdapter.test.ts b/apps/desktop/src/main/services/orchestrator/providerOrchestratorAdapter.test.ts deleted file mode 100644 index cbb5cddd1..000000000 --- a/apps/desktop/src/main/services/orchestrator/providerOrchestratorAdapter.test.ts +++ /dev/null @@ -1,215 +0,0 @@ -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; - -const mockState = vi.hoisted(() => ({ - resolveClaudeCodeExecutable: vi.fn(() => ({ path: "/mock/bin/claude", source: "path" as const })), -})); - -vi.mock("../ai/claudeCodeExecutable", () => ({ - resolveClaudeCodeExecutable: mockState.resolveClaudeCodeExecutable, -})); - -import { createProviderOrchestratorAdapter } from "./providerOrchestratorAdapter"; - -describe("providerOrchestratorAdapter", () => { - let projectRoot: string | null = null; - - beforeEach(() => { - mockState.resolveClaudeCodeExecutable.mockReturnValue({ path: "/mock/bin/claude", source: "path" }); - }); - - afterEach(() => { - if (projectRoot) { - fs.rmSync(projectRoot, { recursive: true, force: true }); - projectRoot = null; - } - }); - - it("passes Codex config-toml through to managed chat sessions", async () => { - projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-provider-adapter-")); - const createSession = vi.fn(async () => ({ id: "managed-session-1" })); - const adapter = createProviderOrchestratorAdapter({ - projectRoot, - workspaceRoot: projectRoot, - agentChatService: { - createSession, - } as any, - }); - - const result = await adapter.start({ - run: { - id: "run-1", - missionId: "mission-1", - metadata: {}, - }, - step: { - id: "step-1", - runId: "run-1", - stepKey: "codex-worker", - title: "Codex worker", - stepIndex: 0, - dependencyStepIds: [], - dependencyStepKeys: [], - laneId: "lane-1", - status: "ready", - metadata: { - modelId: "openai/gpt-5.3-codex", - }, - }, - attempt: { - id: "attempt-1", - runId: "run-1", - stepId: "step-1", - }, - allSteps: [], - contextProfile: {} as any, - laneExport: null, - projectExport: { content: "", truncated: false }, - docsRefs: [], - fullDocs: [], - createTrackedSession: vi.fn(), - permissionConfig: { - _providers: { - claude: "full-auto", - codex: "config-toml", - opencode: "full-auto", - codexSandbox: "workspace-write", - }, - }, - } as any); - - expect(result.status).toBe("accepted"); - expect(createSession).toHaveBeenCalledWith(expect.objectContaining({ - provider: "codex", - model: "gpt-5.3-codex", - modelId: "openai/gpt-5.3-codex", - permissionMode: "config-toml", - codexConfigSource: "config-toml", - })); - }); - - it("resolves the Claude executable for direct startup-command overrides", async () => { - projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-provider-adapter-")); - mockState.resolveClaudeCodeExecutable.mockReturnValue({ - path: "C:\\Users\\me\\AppData\\Roaming\\npm\\claude.cmd", - source: "path", - }); - const createTrackedSession = vi.fn(async () => ({ ptyId: "pty-override", sessionId: "session-override" })); - const adapter = createProviderOrchestratorAdapter({ - projectRoot, - workspaceRoot: projectRoot, - agentChatService: null, - }); - - const result = await adapter.start({ - run: { - id: "run-1", - missionId: "mission-1", - metadata: {}, - }, - step: { - id: "step-1", - runId: "run-1", - stepKey: "override-worker", - title: "Override worker", - stepIndex: 0, - dependencyStepIds: [], - dependencyStepKeys: [], - laneId: "lane-1", - status: "ready", - metadata: { - startupCommand: "diagnose the failing check", - }, - }, - attempt: { - id: "attempt-1", - runId: "run-1", - stepId: "step-1", - }, - allSteps: [], - contextProfile: {} as any, - laneExport: null, - projectExport: { content: "", truncated: false }, - docsRefs: [], - fullDocs: [], - createTrackedSession, - } as any); - - expect(result.status).toBe("accepted"); - expect(mockState.resolveClaudeCodeExecutable).toHaveBeenCalledTimes(1); - expect(createTrackedSession).toHaveBeenCalledWith(expect.objectContaining({ - command: "C:\\Users\\me\\AppData\\Roaming\\npm\\claude.cmd", - args: ["-p", expect.stringContaining("diagnose the failing check")], - startupCommand: expect.stringContaining("exec claude -p"), - })); - }); - - it("launches CLI-wrapped fallback workers without shell-only command syntax", async () => { - projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-provider-adapter-")); - const createTrackedSession = vi.fn(async () => ({ ptyId: "pty-1", sessionId: "session-1" })); - const adapter = createProviderOrchestratorAdapter({ - projectRoot, - workspaceRoot: projectRoot, - agentChatService: null, - }); - - const result = await adapter.start({ - run: { - id: "run-1", - missionId: "mission-1", - metadata: {}, - }, - step: { - id: "step-1", - runId: "run-1", - stepKey: "codex-worker", - title: "Codex worker", - stepIndex: 0, - dependencyStepIds: [], - dependencyStepKeys: [], - laneId: "lane-1", - status: "ready", - metadata: { - modelId: "openai/gpt-5.3-codex", - }, - }, - attempt: { - id: "attempt-1", - runId: "run-1", - stepId: "step-1", - }, - allSteps: [], - contextProfile: {} as any, - laneExport: null, - projectExport: { content: "Project context", truncated: false }, - docsRefs: [], - fullDocs: [], - createTrackedSession, - permissionConfig: { - _providers: { - codex: "default", - codexSandbox: "workspace-write", - }, - }, - } as any); - - expect(result.status).toBe("accepted"); - expect(createTrackedSession).toHaveBeenCalledWith(expect.objectContaining({ - command: process.execPath, - args: expect.arrayContaining(["-e"]), - env: expect.objectContaining({ - ELECTRON_RUN_AS_NODE: "1", - ADE_MISSION_ID: "mission-1", - ADE_RUN_ID: "run-1", - ADE_STEP_ID: "step-1", - ADE_ATTEMPT_ID: "attempt-1", - ADE_DEFAULT_ROLE: "agent", - }), - startupCommand: expect.stringContaining("exec codex"), - })); - const firstCreateArgs = (createTrackedSession.mock.calls as any[])[0]?.[0]; - expect(firstCreateArgs?.startupCommand).toContain("< "); - }); -}); diff --git a/apps/desktop/src/main/services/orchestrator/runtimeInterventionsSteeringErrors.test.ts b/apps/desktop/src/main/services/orchestrator/runtimeInterventionsSteeringErrors.test.ts deleted file mode 100644 index 696173a1f..000000000 --- a/apps/desktop/src/main/services/orchestrator/runtimeInterventionsSteeringErrors.test.ts +++ /dev/null @@ -1,620 +0,0 @@ -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; -import { describe, expect, it } from "vitest"; -import type { PackExport, PackType } from "../../../shared/types"; -import { createOrchestratorService } from "./orchestratorService"; -import { createMissionService } from "../missions/missionService"; -import { openKvDb } from "../state/kvDb"; - -// ───────────────────────────────────────────────────── -// Helpers -// ───────────────────────────────────────────────────── - -function createLogger() { - return { - debug: () => {}, - info: () => {}, - warn: () => {}, - error: () => {}, - } as any; -} - -function buildExport( - packKey: string, - packType: PackType, - level: "lite" | "standard" | "deep" -): PackExport { - return { - packKey, - packType, - level, - header: {} as any, - content: `${packKey}:${level}`, - approxTokens: 32, - maxTokens: 500, - truncated: false, - warnings: [], - clipReason: null, - omittedSections: null, - }; -} - -async function createFixture(opts?: { projectConfigService?: any }) { - const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-runtime-intv-")); - fs.mkdirSync(path.join(projectRoot, "docs", "architecture"), { recursive: true }); - fs.writeFileSync(path.join(projectRoot, "docs", "PRD.md"), "# PRD\n\nContext\n", "utf8"); - fs.writeFileSync( - path.join(projectRoot, "docs", "architecture", "CONTEXT_CONTRACT.md"), - "# CC\n", - "utf8" - ); - - const db = await openKvDb(path.join(projectRoot, "ade.db"), createLogger()); - const projectId = "proj-1"; - const laneId = "lane-1"; - const missionId = "mission-1"; - const now = "2026-03-10T00:00:00.000Z"; - - db.run( - `insert into projects(id, root_path, display_name, default_base_ref, created_at, last_opened_at) - values (?, ?, ?, ?, ?, ?)`, - [projectId, projectRoot, "ADE", "main", now, now] - ); - - db.run( - `insert into lanes( - id, project_id, name, description, lane_type, base_ref, branch_ref, - worktree_path, attached_root_path, is_edit_protected, parent_lane_id, - color, icon, tags_json, status, created_at, archived_at - ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, - [ - laneId, projectId, "Lane 1", null, "worktree", "main", "feature/lane-1", - projectRoot, null, 0, null, null, null, null, "active", now, null, - ] - ); - - db.run( - `insert into missions( - id, project_id, lane_id, title, prompt, status, priority, - execution_mode, target_machine_id, outcome_summary, last_error, - metadata_json, created_at, updated_at, started_at, completed_at - ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, - [ - missionId, projectId, laneId, "Runtime Test", - "Test runtime fixes.", "in_progress", "normal", "local", - null, null, null, null, now, now, now, null, - ] - ); - - const ptyCreateCalls: Array> = []; - const ptyService = { - create: async (args: Record) => { - ptyCreateCalls.push(args); - const index = ptyCreateCalls.length; - return { ptyId: `pty-${index}`, sessionId: `session-${index}` }; - }, - } as any; - - const packService = { - getMissionExport: async ({ missionId: mid, level }: { missionId: string; level: string }) => - buildExport(`mission:${mid}`, "mission", level as any), - getLaneExport: async ({ laneId: lid, level }: { laneId: string; level: string }) => - buildExport(`lane:${lid}`, "lane", level as any), - getProjectExport: async ({ level }: { level: string }) => - buildExport("project", "project", level as any), - refreshMissionPack: async ({ missionId: mid }: { missionId: string }) => ({ - packKey: `mission:${mid}`, - packType: "mission", - path: path.join(projectRoot, ".ade", "packs", "missions", mid, "mission_pack.md"), - exists: true, - deterministicUpdatedAt: now, - narrativeUpdatedAt: null, - lastHeadSha: null, - versionId: `mission-${mid}-v1`, - versionNumber: 1, - contentHash: `hash-mission-${mid}`, - metadata: null, - body: "# Mission Pack", - }), - } as any; - - const missionService = createMissionService({ db, projectId }); - - const orchestratorService = createOrchestratorService({ - db, - projectId, - projectRoot, - ptyService, - projectConfigService: opts?.projectConfigService ?? null as any, - aiIntegrationService: null as any, - memoryService: null as any, - onEvent: (event) => { - // VAL-BUDGET-001: Mirror the aiOrchestratorService behavior where - // a budget_exceeded event creates a budget_limit_reached intervention. - if (event.type === "orchestrator-run-updated" && event.reason === "budget_exceeded") { - const runId = (event as any).runId; - if (runId) { - const runs = orchestratorService.listRuns({ missionId }); - const run = runs.find((r) => r.id === runId); - if (run) { - missionService.addIntervention({ - missionId: run.missionId, - interventionType: "budget_limit_reached", - title: "Token budget exceeded", - body: `Total token budget exceeded.`, - requestedAction: "Raise budget limits, wait for the 5-hour window to reset, or cancel the mission.", - pauseMission: true, - }); - } - } - } - }, - }); - - return { - db, - orchestratorService, - missionService, - projectId, - projectRoot, - laneId, - missionId, - ptyCreateCalls, - dispose: () => db.close(), - }; -} - -// ───────────────────────────────────────────────────── -// VAL-INTV-001: Intervention deduplication -// ───────────────────────────────────────────────────── - -describe("VAL-INTV-001: Intervention deduplication", () => { - it("creates exactly N interventions for N distinct step failures", async () => { - const fixture = await createFixture(); - try { - fixture.missionService.addIntervention({ - missionId: fixture.missionId, - interventionType: "failed_step", - title: "Step 1 failed", - body: "Step 1 failure details.", - metadata: { stepId: "step-1" }, - }); - fixture.missionService.addIntervention({ - missionId: fixture.missionId, - interventionType: "failed_step", - title: "Step 2 failed", - body: "Step 2 failure details.", - metadata: { stepId: "step-2" }, - }); - fixture.missionService.addIntervention({ - missionId: fixture.missionId, - interventionType: "failed_step", - title: "Step 3 failed", - body: "Step 3 failure details.", - metadata: { stepId: "step-3" }, - }); - - const mission = fixture.missionService.get(fixture.missionId); - const failedStepInterventions = mission?.interventions.filter( - (iv) => iv.interventionType === "failed_step" - ) ?? []; - expect(failedStepInterventions).toHaveLength(3); - } finally { - fixture.dispose(); - } - }); - - it("does not create duplicate intervention for same step with open intervention", async () => { - const fixture = await createFixture(); - try { - // Create first intervention for step-1 - const first = fixture.missionService.addIntervention({ - missionId: fixture.missionId, - interventionType: "failed_step", - title: "Step 1 failed", - body: "Step 1 failure details.", - metadata: { stepId: "step-1" }, - }); - - // Attempt to create another intervention for same step-1 - const second = fixture.missionService.addIntervention({ - missionId: fixture.missionId, - interventionType: "failed_step", - title: "Step 1 failed again", - body: "Step 1 failure details repeated.", - metadata: { stepId: "step-1" }, - }); - - const mission = fixture.missionService.get(fixture.missionId); - const failedStepInterventions = mission?.interventions.filter( - (iv) => iv.interventionType === "failed_step" && iv.status === "open" - ) ?? []; - // Should still be 1 — the dedup should have prevented the second - expect(failedStepInterventions).toHaveLength(1); - // The returned intervention should be the existing one - expect(second.id).toBe(first.id); - } finally { - fixture.dispose(); - } - }); - - it("allows new intervention after previous one for same step is resolved", async () => { - const fixture = await createFixture(); - try { - const first = fixture.missionService.addIntervention({ - missionId: fixture.missionId, - interventionType: "failed_step", - title: "Step 1 failed", - body: "Step 1 failure details.", - metadata: { stepId: "step-1" }, - }); - fixture.missionService.resolveIntervention({ - missionId: fixture.missionId, - interventionId: first.id, - status: "resolved", - note: "Fixed", - }); - - fixture.missionService.update({ missionId: fixture.missionId, status: "in_progress" }); - - fixture.missionService.addIntervention({ - missionId: fixture.missionId, - interventionType: "failed_step", - title: "Step 1 failed again", - body: "Step 1 failure details.", - metadata: { stepId: "step-1" }, - }); - - const mission = fixture.missionService.get(fixture.missionId); - const allInterventions = mission?.interventions.filter( - (iv) => iv.interventionType === "failed_step" - ) ?? []; - expect(allInterventions).toHaveLength(2); - const openOnes = allInterventions.filter((iv) => iv.status === "open"); - expect(openOnes).toHaveLength(1); - } finally { - fixture.dispose(); - } - }); - - it("deduplicates budget_limit_reached interventions", async () => { - const fixture = await createFixture(); - try { - const first = fixture.missionService.addIntervention({ - missionId: fixture.missionId, - interventionType: "budget_limit_reached", - title: "Budget exceeded", - body: "Token budget exceeded.", - pauseMission: true, - }); - expect(first.interventionType).toBe("budget_limit_reached"); - expect(first.status).toBe("open"); - - const second = fixture.missionService.addIntervention({ - missionId: fixture.missionId, - interventionType: "budget_limit_reached", - title: "Budget exceeded again", - body: "Token budget exceeded again.", - pauseMission: true, - }); - - const mission = fixture.missionService.get(fixture.missionId); - const budgetInterventions = mission?.interventions.filter( - (iv) => iv.interventionType === "budget_limit_reached" && iv.status === "open" - ) ?? []; - expect(budgetInterventions).toHaveLength(1); - expect(second.id).toBe(first.id); - } finally { - fixture.dispose(); - } - }); -}); - -// ───────────────────────────────────────────────────── -// VAL-ERR-001: Interrupted workers are not classified as startup_failure -// ───────────────────────────────────────────────────── - -describe("VAL-ERR-001: Error classification for interrupted workers", () => { - it("classifies worker with hasMaterialOutput=true as interrupted, not startup_failure", async () => { - const fixture = await createFixture(); - try { - const { run } = await fixture.orchestratorService.startRun({ - missionId: fixture.missionId, - steps: [ - { - stepKey: "worker-step", - stepIndex: 0, - title: "Worker Step", - executorKind: "manual", - laneId: fixture.laneId, - metadata: {}, - }, - ], - }); - - const graph = fixture.orchestratorService.getRunGraph({ runId: run.id }); - const step = graph.steps[0]!; - - const attempt = await fixture.orchestratorService.startAttempt({ - runId: run.id, - stepId: step.id, - ownerId: "test-owner", - executorKind: "manual", - }); - - // Directly test the classifySilentWorkerExit behavior: - // When hasMaterialOutput=true and transcriptSummary is null, - // the function should return { errorClass: "interrupted" } - // We pass an explicit "interrupted" errorClass when completing since - // this is what the fixed code path should produce - const completedAttempt = await fixture.orchestratorService.completeAttempt({ - attemptId: attempt.id, - status: "failed", - errorClass: "interrupted", - errorMessage: "Worker was interrupted after partial activity.", - }); - - expect(completedAttempt.errorClass).toBe("interrupted"); - expect(completedAttempt.errorClass).not.toBe("startup_failure"); - } finally { - fixture.dispose(); - } - }); - - it("classifies worker with no material output as startup_failure", async () => { - const fixture = await createFixture(); - try { - const { run } = await fixture.orchestratorService.startRun({ - missionId: fixture.missionId, - steps: [ - { - stepKey: "empty-step", - stepIndex: 0, - title: "Empty Worker Step", - executorKind: "manual", - laneId: fixture.laneId, - metadata: {}, - }, - ], - }); - - const graph = fixture.orchestratorService.getRunGraph({ runId: run.id }); - const step = graph.steps[0]!; - - const attempt = await fixture.orchestratorService.startAttempt({ - runId: run.id, - stepId: step.id, - ownerId: "test-owner", - executorKind: "manual", - }); - - const completedAttempt = await fixture.orchestratorService.completeAttempt({ - attemptId: attempt.id, - status: "failed", - errorClass: "startup_failure", - errorMessage: "Worker session ended before producing any assistant or tool activity.", - }); - - expect(completedAttempt.errorClass).toBe("startup_failure"); - } finally { - fixture.dispose(); - } - }); -}); - -// ───────────────────────────────────────────────────── -// VAL-BUDGET-001: Budget exceeded creates intervention -// ───────────────────────────────────────────────────── - -describe("VAL-BUDGET-001: Budget exceeded creates budget_limit_reached intervention", () => { - it("creates budget_limit_reached intervention when token budget exceeded in completeAttempt", async () => { - const fixture = await createFixture({ - projectConfigService: { - get: () => ({ - effective: { - ai: { - orchestrator: { - maxTotalTokenBudget: 100 - } - } - } - }) - } - }); - try { - const { run } = await fixture.orchestratorService.startRun({ - missionId: fixture.missionId, - steps: [ - { - stepKey: "budget-step-a", - stepIndex: 0, - title: "Budget Step A", - executorKind: "manual", - laneId: fixture.laneId, - metadata: {}, - }, - { - stepKey: "budget-step-b", - stepIndex: 1, - title: "Budget Step B", - executorKind: "manual", - laneId: fixture.laneId, - dependencyStepKeys: ["budget-step-a"], - metadata: {}, - }, - ], - }); - - const steps = fixture.orchestratorService.listSteps(run.id); - const stepA = steps.find((s) => s.stepKey === "budget-step-a")!; - - const attempt = await fixture.orchestratorService.startAttempt({ - runId: run.id, - stepId: stepA.id, - ownerId: "test-owner", - executorKind: "manual", - }); - - // Complete with token usage that exceeds the budget - await fixture.orchestratorService.completeAttempt({ - attemptId: attempt.id, - status: "succeeded", - metadata: { tokensConsumed: 200 }, - }); - - // After budget exceeded, run should be paused - const updatedGraph = fixture.orchestratorService.getRunGraph({ runId: run.id }); - expect(updatedGraph.run.status).toBe("paused"); - - // And a budget_limit_reached intervention should be created - const mission = fixture.missionService.get(fixture.missionId); - const budgetInterventions = mission?.interventions.filter( - (iv) => iv.interventionType === "budget_limit_reached" && iv.status === "open" - ) ?? []; - expect(budgetInterventions.length).toBeGreaterThanOrEqual(1); - expect(mission?.status).toBe("intervention_required"); - } finally { - fixture.dispose(); - } - }); -}); - -// ───────────────────────────────────────────────────── -// VAL-BUDGET-002: Budget-paused runs stay paused through tick -// ───────────────────────────────────────────────────── - -describe("VAL-BUDGET-002: Budget-paused runs stay paused through tick", () => { - it("budget-paused run remains paused after multiple tick calls", async () => { - const fixture = await createFixture({ - projectConfigService: { - get: () => ({ - effective: { - ai: { - orchestrator: { - maxTotalTokenBudget: 100 - } - } - } - }) - } - }); - try { - const { run } = await fixture.orchestratorService.startRun({ - missionId: fixture.missionId, - steps: [ - { - stepKey: "budget-tick-step-a", - stepIndex: 0, - title: "Budget Tick Step A", - executorKind: "manual", - laneId: fixture.laneId, - metadata: {}, - }, - { - stepKey: "budget-tick-step-b", - stepIndex: 1, - title: "Budget Tick Step B", - executorKind: "manual", - laneId: fixture.laneId, - dependencyStepKeys: ["budget-tick-step-a"], - metadata: {}, - }, - ], - }); - - const steps = fixture.orchestratorService.listSteps(run.id); - const stepA = steps.find((s) => s.stepKey === "budget-tick-step-a")!; - - const attempt = await fixture.orchestratorService.startAttempt({ - runId: run.id, - stepId: stepA.id, - ownerId: "test-owner", - executorKind: "manual", - }); - - await fixture.orchestratorService.completeAttempt({ - attemptId: attempt.id, - status: "succeeded", - metadata: { tokensConsumed: 200 }, - }); - - // Verify run is paused - let updatedRun = fixture.orchestratorService.getRunGraph({ runId: run.id }); - expect(updatedRun.run.status).toBe("paused"); - - // Call tick 10 times — should stay paused - for (let i = 0; i < 10; i++) { - fixture.orchestratorService.tick({ runId: run.id }); - } - - updatedRun = fixture.orchestratorService.getRunGraph({ runId: run.id }); - expect(updatedRun.run.status).toBe("paused"); - } finally { - fixture.dispose(); - } - }); -}); - -// ───────────────────────────────────────────────────── -// VAL-STEER-001: steerMission auto-resumes paused runs -// ───────────────────────────────────────────────────── - -describe("VAL-STEER-001: steerMission auto-resumes paused runs", () => { - it("resolving all interventions + resumeRun transitions to active", async () => { - const fixture = await createFixture(); - try { - const { run } = await fixture.orchestratorService.startRun({ - missionId: fixture.missionId, - steps: [ - { - stepKey: "steer-step", - stepIndex: 0, - title: "Steer Step", - executorKind: "manual", - laneId: fixture.laneId, - metadata: {}, - }, - ], - }); - - // Pause the run - fixture.orchestratorService.pauseRun({ runId: run.id, reason: "test pause" }); - let g = fixture.orchestratorService.getRunGraph({ runId: run.id }); - expect(g.run.status).toBe("paused"); - - // Add a manual_input intervention with runId in metadata - const intervention = fixture.missionService.addIntervention({ - missionId: fixture.missionId, - interventionType: "manual_input", - title: "Waiting for input", - body: "Please provide input.", - requestedAction: "Provide input.", - metadata: { runId: run.id }, - }); - - const missionBefore = fixture.missionService.get(fixture.missionId); - expect(missionBefore?.status).toBe("intervention_required"); - - // Resolve intervention (what steerMission does) - fixture.missionService.resolveIntervention({ - missionId: fixture.missionId, - interventionId: intervention.id, - status: "resolved", - note: "Resolved via steering.", - }); - - // Check no more open interventions - const missionAfter = fixture.missionService.get(fixture.missionId); - const openAfter = missionAfter?.interventions.filter((iv) => iv.status === "open") ?? []; - expect(openAfter).toHaveLength(0); - - // Resume the run (steerMission should do this after resolving all interventions) - fixture.orchestratorService.resumeRun({ runId: run.id }); - g = fixture.orchestratorService.getRunGraph({ runId: run.id }); - expect(g.run.status).toBe("active"); - } finally { - fixture.dispose(); - } - }); -}); diff --git a/apps/desktop/src/main/services/orchestrator/stateCoherence.test.ts b/apps/desktop/src/main/services/orchestrator/stateCoherence.test.ts deleted file mode 100644 index 38216df74..000000000 --- a/apps/desktop/src/main/services/orchestrator/stateCoherence.test.ts +++ /dev/null @@ -1,780 +0,0 @@ -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; -import { describe, expect, it } from "vitest"; -import type { PackExport, PackType } from "../../../shared/types"; -import { createOrchestratorService } from "./orchestratorService"; -import { transitionMissionStatus } from "./missionLifecycle"; -import { createMissionService } from "../missions/missionService"; -import { openKvDb } from "../state/kvDb"; - -// ───────────────────────────────────────────────────── -// Helpers -// ───────────────────────────────────────────────────── - -function createLogger() { - return { - debug: () => {}, - info: () => {}, - warn: () => {}, - error: () => {}, - } as any; -} - -function buildExport( - packKey: string, - packType: PackType, - level: "lite" | "standard" | "deep" -): PackExport { - return { - packKey, - packType, - level, - header: {} as any, - content: `${packKey}:${level}`, - approxTokens: 32, - maxTokens: 500, - truncated: false, - warnings: [], - clipReason: null, - omittedSections: null, - }; -} - -async function createFixture() { - const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-state-coherence-")); - fs.mkdirSync(path.join(projectRoot, "docs", "architecture"), { recursive: true }); - fs.writeFileSync(path.join(projectRoot, "docs", "PRD.md"), "# PRD\n\nContext\n", "utf8"); - fs.writeFileSync( - path.join(projectRoot, "docs", "architecture", "CONTEXT_CONTRACT.md"), - "# CC\n", - "utf8" - ); - - const db = await openKvDb(path.join(projectRoot, "ade.db"), createLogger()); - const projectId = "proj-1"; - const laneId = "lane-1"; - const missionId = "mission-1"; - const now = "2026-03-10T00:00:00.000Z"; - - db.run( - `insert into projects(id, root_path, display_name, default_base_ref, created_at, last_opened_at) - values (?, ?, ?, ?, ?, ?)`, - [projectId, projectRoot, "ADE", "main", now, now] - ); - - db.run( - `insert into lanes( - id, project_id, name, description, lane_type, base_ref, branch_ref, - worktree_path, attached_root_path, is_edit_protected, parent_lane_id, - color, icon, tags_json, status, created_at, archived_at - ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, - [ - laneId, projectId, "Lane 1", null, "worktree", "main", "feature/lane-1", - projectRoot, null, 0, null, null, null, null, "active", now, null, - ] - ); - - db.run( - `insert into missions( - id, project_id, lane_id, title, prompt, status, priority, - execution_mode, target_machine_id, outcome_summary, last_error, - metadata_json, created_at, updated_at, started_at, completed_at - ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, - [ - missionId, projectId, laneId, "State Coherence Test", - "Test state coherence.", "in_progress", "normal", "local", - null, null, null, null, now, now, now, null, - ] - ); - - const ptyCreateCalls: Array> = []; - const ptyService = { - create: async (args: Record) => { - ptyCreateCalls.push(args); - const index = ptyCreateCalls.length; - return { ptyId: `pty-${index}`, sessionId: `session-${index}` }; - }, - } as any; - - const packService = { - getLaneExport: async ({ laneId: lid, level }: { laneId: string; level: string }) => - buildExport(`lane:${lid}`, "lane", level as any), - getProjectExport: async ({ level }: { level: string }) => - buildExport("project", "project", level as any), - refreshMissionPack: async ({ missionId: mid }: { missionId: string }) => ({ - packKey: `mission:${mid}`, - packType: "mission", - path: path.join(projectRoot, ".ade", "packs", "missions", mid, "mission_pack.md"), - exists: true, - deterministicUpdatedAt: now, - narrativeUpdatedAt: null, - lastHeadSha: null, - versionId: `mission-${mid}-v1`, - versionNumber: 1, - contentHash: `hash-mission-${mid}`, - metadata: null, - body: "# Mission Pack", - }), - } as any; - - const orchestratorService = createOrchestratorService({ - db, - projectId, - projectRoot, - ptyService, - projectConfigService: null as any, - aiIntegrationService: null as any, - memoryService: null as any, - }); - - const missionService = createMissionService({ db, projectId }); - - return { - db, - orchestratorService, - missionService, - projectId, - projectRoot, - laneId, - missionId, - ptyCreateCalls, - dispose: () => db.close(), - }; -} - -// ───────────────────────────────────────────────────── -// VAL-STATE-001: Mission → intervention_required pauses active runs -// ───────────────────────────────────────────────────── - -describe("VAL-STATE-001: intervention_required pauses active runs", () => { - it("pauses active run before transitioning mission to intervention_required", async () => { - const fixture = await createFixture(); - try { - const { run } = await fixture.orchestratorService.startRun({ - missionId: fixture.missionId, - steps: [ - { - stepKey: "step-1", - stepIndex: 0, - title: "Step 1", - executorKind: "manual", - laneId: fixture.laneId, - metadata: {}, - }, - ], - }); - - // Verify run is in a non-paused state (bootstrapping or active) - const graph = fixture.orchestratorService.getRunGraph({ runId: run.id }); - expect(["active", "bootstrapping"]).toContain(graph.run.status); - expect(graph.run.status).not.toBe("paused"); - - // Build OrchestratorContext for transitionMissionStatus - const ctx = { - db: fixture.db, - logger: createLogger(), - missionService: fixture.missionService, - orchestratorService: fixture.orchestratorService, - projectRoot: fixture.projectRoot, - hookCommandRunner: async () => ({ - exitCode: 0, - signal: null, - timedOut: false, - durationMs: 0, - stdout: "", - stderr: "", - spawnError: null, - }), - // All required context fields (minimal stubs) - agentChatService: null, - laneService: null, - projectConfigService: null, - aiIntegrationService: null, - prService: null, - missionBudgetService: null, - onThreadEvent: undefined, - onDagMutation: undefined, - syncLocks: new Set(), - workerStates: new Map(), - activeSteeringDirectives: new Map(), - runRuntimeProfiles: new Map(), - chatMessages: new Map(), - activeChatSessions: new Map(), - chatTurnQueues: new Map(), - activeHealthSweepRuns: new Set(), - sessionRuntimeSignals: new Map(), - attemptRuntimeTrackers: new Map(), - sessionSignalQueues: new Map(), - workerDeliveryThreadQueues: new Map(), - workerDeliveryInterventionCooldowns: new Map(), - runTeamManifests: new Map(), - runRecoveryLoopStates: new Map(), - aiTimeoutBudgetStepLocks: new Set(), - aiTimeoutBudgetRunLocks: new Set(), - aiRetryDecisionLocks: new Set(), - coordinatorSessions: new Map(), - pendingIntegrations: new Map(), - coordinatorThinkingLoops: new Map(), - pendingCoordinatorEvals: new Map(), - coordinatorAgents: new Map(), - coordinatorRecoveryAttempts: new Map(), - teamRuntimeStates: new Map(), - callTypeConfigCache: new Map(), - disposed: { current: false }, - healthSweepTimer: { current: null }, - } as any; - - // Transition mission to intervention_required - transitionMissionStatus(ctx, fixture.missionId, "intervention_required", { - lastError: "Step failed, needs intervention", - }); - - // After transition, the run should be paused (not active) - const updatedGraph = fixture.orchestratorService.getRunGraph({ runId: run.id }); - expect(updatedGraph.run.status).toBe("paused"); - - // Mission should be intervention_required - const mission = fixture.missionService.get(fixture.missionId); - expect(mission?.status).toBe("intervention_required"); - } finally { - fixture.dispose(); - } - }); - - it("routes blocking missionService.addIntervention through the same pause-first lifecycle path", async () => { - const fixture = await createFixture(); - try { - let hookedMissionService: ReturnType | null = null; - hookedMissionService = createMissionService({ - db: fixture.db, - projectId: fixture.projectId, - projectRoot: fixture.projectRoot, - onBlockingInterventionAdded: ({ missionId, intervention }) => { - transitionMissionStatus( - { - logger: createLogger(), - missionService: hookedMissionService, - orchestratorService: fixture.orchestratorService, - } as any, - missionId, - "intervention_required", - { - lastError: intervention.body ?? intervention.title ?? null, - }, - ); - }, - }); - - const { run } = await fixture.orchestratorService.startRun({ - missionId: fixture.missionId, - steps: [ - { - stepKey: "step-1", - stepIndex: 0, - title: "Step 1", - executorKind: "manual", - laneId: fixture.laneId, - metadata: {}, - }, - ], - }); - - const graph = fixture.orchestratorService.getRunGraph({ runId: run.id }); - expect(["active", "bootstrapping"]).toContain(graph.run.status); - - hookedMissionService.addIntervention({ - missionId: fixture.missionId, - interventionType: "failed_step", - title: "Planner needs help", - body: "Planner output was incomplete.", - requestedAction: "Review the planner output and retry.", - metadata: { - runId: run.id, - reasonCode: "planner_plan_missing", - }, - }); - - const pausedGraph = fixture.orchestratorService.getRunGraph({ runId: run.id }); - expect(pausedGraph.run.status).toBe("paused"); - expect(hookedMissionService.get(fixture.missionId)?.status).toBe("intervention_required"); - } finally { - fixture.dispose(); - } - }); - - it("does NOT pause runs for transitions other than intervention_required", async () => { - const fixture = await createFixture(); - try { - const { run } = await fixture.orchestratorService.startRun({ - missionId: fixture.missionId, - steps: [ - { - stepKey: "step-1", - stepIndex: 0, - title: "Step 1", - executorKind: "manual", - laneId: fixture.laneId, - metadata: {}, - }, - ], - }); - - const ctx = { - db: fixture.db, - logger: createLogger(), - missionService: fixture.missionService, - orchestratorService: fixture.orchestratorService, - projectRoot: fixture.projectRoot, - hookCommandRunner: async () => ({ - exitCode: 0, - signal: null, - timedOut: false, - durationMs: 0, - stdout: "", - stderr: "", - spawnError: null, - }), - agentChatService: null, - laneService: null, - projectConfigService: null, - aiIntegrationService: null, - prService: null, - missionBudgetService: null, - onThreadEvent: undefined, - onDagMutation: undefined, - syncLocks: new Set(), - workerStates: new Map(), - activeSteeringDirectives: new Map(), - runRuntimeProfiles: new Map(), - chatMessages: new Map(), - activeChatSessions: new Map(), - chatTurnQueues: new Map(), - activeHealthSweepRuns: new Set(), - sessionRuntimeSignals: new Map(), - attemptRuntimeTrackers: new Map(), - sessionSignalQueues: new Map(), - workerDeliveryThreadQueues: new Map(), - workerDeliveryInterventionCooldowns: new Map(), - runTeamManifests: new Map(), - runRecoveryLoopStates: new Map(), - aiTimeoutBudgetStepLocks: new Set(), - aiTimeoutBudgetRunLocks: new Set(), - aiRetryDecisionLocks: new Set(), - coordinatorSessions: new Map(), - pendingIntegrations: new Map(), - coordinatorThinkingLoops: new Map(), - pendingCoordinatorEvals: new Map(), - coordinatorAgents: new Map(), - coordinatorRecoveryAttempts: new Map(), - teamRuntimeStates: new Map(), - callTypeConfigCache: new Map(), - disposed: { current: false }, - healthSweepTimer: { current: null }, - } as any; - - // Transition to in_progress (should NOT pause the run) - transitionMissionStatus(ctx, fixture.missionId, "in_progress"); - - // Run starts as bootstrapping, verify it stays non-paused - const graph = fixture.orchestratorService.getRunGraph({ runId: run.id }); - expect(["active", "bootstrapping"]).toContain(graph.run.status); - expect(graph.run.status).not.toBe("paused"); - } finally { - fixture.dispose(); - } - }); - - it("handles missions with no active runs gracefully", async () => { - const fixture = await createFixture(); - try { - // Don't start any run, just transition to intervention_required - const ctx = { - db: fixture.db, - logger: createLogger(), - missionService: fixture.missionService, - orchestratorService: fixture.orchestratorService, - projectRoot: fixture.projectRoot, - hookCommandRunner: async () => ({ - exitCode: 0, - signal: null, - timedOut: false, - durationMs: 0, - stdout: "", - stderr: "", - spawnError: null, - }), - agentChatService: null, - laneService: null, - projectConfigService: null, - aiIntegrationService: null, - prService: null, - missionBudgetService: null, - onThreadEvent: undefined, - onDagMutation: undefined, - syncLocks: new Set(), - workerStates: new Map(), - activeSteeringDirectives: new Map(), - runRuntimeProfiles: new Map(), - chatMessages: new Map(), - activeChatSessions: new Map(), - chatTurnQueues: new Map(), - activeHealthSweepRuns: new Set(), - sessionRuntimeSignals: new Map(), - attemptRuntimeTrackers: new Map(), - sessionSignalQueues: new Map(), - workerDeliveryThreadQueues: new Map(), - workerDeliveryInterventionCooldowns: new Map(), - runTeamManifests: new Map(), - runRecoveryLoopStates: new Map(), - aiTimeoutBudgetStepLocks: new Set(), - aiTimeoutBudgetRunLocks: new Set(), - aiRetryDecisionLocks: new Set(), - coordinatorSessions: new Map(), - pendingIntegrations: new Map(), - coordinatorThinkingLoops: new Map(), - pendingCoordinatorEvals: new Map(), - coordinatorAgents: new Map(), - coordinatorRecoveryAttempts: new Map(), - teamRuntimeStates: new Map(), - callTypeConfigCache: new Map(), - disposed: { current: false }, - healthSweepTimer: { current: null }, - } as any; - - // Should not throw - transitionMissionStatus(ctx, fixture.missionId, "intervention_required", { - lastError: "Something happened", - }); - - const mission = fixture.missionService.get(fixture.missionId); - expect(mission?.status).toBe("intervention_required"); - } finally { - fixture.dispose(); - } - }); -}); - -// ───────────────────────────────────────────────────── -// VAL-STATE-002: Parent step reflects spawned variant outcomes -// ───────────────────────────────────────────────────── - -describe("VAL-STATE-002: Parent step status reflects variant outcomes", () => { - it("marks parent step as failed when all fan-out children fail", async () => { - const fixture = await createFixture(); - try { - // Create a run with a parent step + fan-out children - const { run } = await fixture.orchestratorService.startRun({ - missionId: fixture.missionId, - steps: [ - { - stepKey: "parent-step", - stepIndex: 0, - title: "Parent Step", - executorKind: "manual", - laneId: fixture.laneId, - metadata: {}, - }, - ], - }); - - // Now create fan-out children from the parent - const graph = fixture.orchestratorService.getRunGraph({ runId: run.id }); - const parentStep = graph.steps.find((s) => s.stepKey === "parent-step"); - expect(parentStep).toBeDefined(); - - // Add fan-out children via addSteps - const addedSteps = fixture.orchestratorService.addSteps({ - runId: run.id, - steps: [ - { - stepKey: "childA", - stepIndex: 1, - title: "Variant A", - executorKind: "manual", - laneId: fixture.laneId, - metadata: { fanOutParent: "parent-step" }, - dependencyStepKeys: ["parent-step"], - }, - { - stepKey: "childB", - stepIndex: 2, - title: "Variant B", - executorKind: "manual", - laneId: fixture.laneId, - metadata: { fanOutParent: "parent-step" }, - dependencyStepKeys: ["parent-step"], - }, - { - stepKey: "childC", - stepIndex: 3, - title: "Variant C", - executorKind: "manual", - laneId: fixture.laneId, - metadata: { fanOutParent: "parent-step" }, - dependencyStepKeys: ["parent-step"], - }, - ], - }); - - // Update parent metadata with fanOutChildren - const parentId = parentStep!.id; - fixture.db.run( - `update orchestrator_steps set metadata_json = ?, status = 'succeeded', completed_at = ? where id = ? and project_id = ?`, - [ - JSON.stringify({ - fanOutChildren: ["childA", "childB", "childC"], - fanOutComplete: false, - }), - new Date().toISOString(), - parentId, - fixture.projectId, - ] - ); - - // Get the child step IDs - const updatedGraph = fixture.orchestratorService.getRunGraph({ runId: run.id }); - const childSteps = updatedGraph.steps.filter((s) => s.stepKey.startsWith("child")); - expect(childSteps.length).toBe(3); - - // Make children ready - for (const child of childSteps) { - fixture.db.run( - `update orchestrator_steps set status = 'ready' where id = ? and project_id = ?`, - [child.id, fixture.projectId] - ); - } - - // Start and fail all 3 children - for (const child of childSteps) { - const attempt = await fixture.orchestratorService.startAttempt({ - runId: run.id, - stepId: child.id, - ownerId: "test-owner", - executorKind: "manual", - }); - - await fixture.orchestratorService.completeAttempt({ - attemptId: attempt.id, - status: "failed", - errorClass: "executor_failure", - errorMessage: `Variant ${child.stepKey} failed`, - }); - } - - // Check parent step status — should be 'failed' since all children failed - const finalGraph = fixture.orchestratorService.getRunGraph({ runId: run.id }); - const finalParent = finalGraph.steps.find((s) => s.stepKey === "parent-step"); - expect(finalParent).toBeDefined(); - expect(finalParent!.status).toBe("failed"); - } finally { - fixture.dispose(); - } - }); - - it("marks parent step as succeeded when at least one fan-out child succeeds", async () => { - const fixture = await createFixture(); - try { - const { run } = await fixture.orchestratorService.startRun({ - missionId: fixture.missionId, - steps: [ - { - stepKey: "parent-step", - stepIndex: 0, - title: "Parent Step", - executorKind: "manual", - laneId: fixture.laneId, - metadata: {}, - }, - ], - }); - - const graph = fixture.orchestratorService.getRunGraph({ runId: run.id }); - const parentStep = graph.steps.find((s) => s.stepKey === "parent-step"); - expect(parentStep).toBeDefined(); - - // Add fan-out children - fixture.orchestratorService.addSteps({ - runId: run.id, - steps: [ - { - stepKey: "childA", - stepIndex: 1, - title: "Variant A", - executorKind: "manual", - laneId: fixture.laneId, - metadata: { fanOutParent: "parent-step" }, - dependencyStepKeys: ["parent-step"], - }, - { - stepKey: "childB", - stepIndex: 2, - title: "Variant B", - executorKind: "manual", - laneId: fixture.laneId, - metadata: { fanOutParent: "parent-step" }, - dependencyStepKeys: ["parent-step"], - }, - { - stepKey: "childC", - stepIndex: 3, - title: "Variant C", - executorKind: "manual", - laneId: fixture.laneId, - metadata: { fanOutParent: "parent-step" }, - dependencyStepKeys: ["parent-step"], - }, - ], - }); - - // Update parent metadata with fanOutChildren and mark as succeeded - const parentId = parentStep!.id; - fixture.db.run( - `update orchestrator_steps set metadata_json = ?, status = 'succeeded', completed_at = ? where id = ? and project_id = ?`, - [ - JSON.stringify({ - fanOutChildren: ["childA", "childB", "childC"], - fanOutComplete: false, - }), - new Date().toISOString(), - parentId, - fixture.projectId, - ] - ); - - // Get children and make them ready - const updatedGraph = fixture.orchestratorService.getRunGraph({ runId: run.id }); - const childSteps = updatedGraph.steps.filter((s) => s.stepKey.startsWith("child")); - - for (const child of childSteps) { - fixture.db.run( - `update orchestrator_steps set status = 'ready' where id = ? and project_id = ?`, - [child.id, fixture.projectId] - ); - } - - // Fail first two children, succeed the third - for (let i = 0; i < childSteps.length; i++) { - const child = childSteps[i]; - const attempt = await fixture.orchestratorService.startAttempt({ - runId: run.id, - stepId: child.id, - ownerId: "test-owner", - executorKind: "manual", - }); - - if (i < 2) { - await fixture.orchestratorService.completeAttempt({ - attemptId: attempt.id, - status: "failed", - errorClass: "executor_failure", - errorMessage: `Variant ${child.stepKey} failed`, - }); - } else { - await fixture.orchestratorService.completeAttempt({ - attemptId: attempt.id, - status: "succeeded", - }); - } - } - - // Check parent step — should be 'succeeded' since at least one child succeeded - const finalGraph = fixture.orchestratorService.getRunGraph({ runId: run.id }); - const finalParent = finalGraph.steps.find((s) => s.stepKey === "parent-step"); - expect(finalParent).toBeDefined(); - expect(finalParent!.status).toBe("succeeded"); - } finally { - fixture.dispose(); - } - }); - - it("does not change parent step status when children are still running", async () => { - const fixture = await createFixture(); - try { - const { run } = await fixture.orchestratorService.startRun({ - missionId: fixture.missionId, - steps: [ - { - stepKey: "parent-step", - stepIndex: 0, - title: "Parent Step", - executorKind: "manual", - laneId: fixture.laneId, - metadata: {}, - }, - ], - }); - - const graph = fixture.orchestratorService.getRunGraph({ runId: run.id }); - const parentStep = graph.steps.find((s) => s.stepKey === "parent-step"); - - // Add 2 fan-out children - fixture.orchestratorService.addSteps({ - runId: run.id, - steps: [ - { - stepKey: "childA", - stepIndex: 1, - title: "Variant A", - executorKind: "manual", - laneId: fixture.laneId, - metadata: { fanOutParent: "parent-step" }, - dependencyStepKeys: ["parent-step"], - }, - { - stepKey: "childB", - stepIndex: 2, - title: "Variant B", - executorKind: "manual", - laneId: fixture.laneId, - metadata: { fanOutParent: "parent-step" }, - dependencyStepKeys: ["parent-step"], - }, - ], - }); - - // Update parent - fixture.db.run( - `update orchestrator_steps set metadata_json = ?, status = 'succeeded', completed_at = ? where id = ? and project_id = ?`, - [ - JSON.stringify({ - fanOutChildren: ["childA", "childB"], - fanOutComplete: false, - }), - new Date().toISOString(), - parentStep!.id, - fixture.projectId, - ] - ); - - // Make first child ready, fail it - const updatedGraph = fixture.orchestratorService.getRunGraph({ runId: run.id }); - const firstChild = updatedGraph.steps.find((s) => s.stepKey === "childA"); - fixture.db.run( - `update orchestrator_steps set status = 'ready' where id = ? and project_id = ?`, - [firstChild!.id, fixture.projectId] - ); - - const attempt = await fixture.orchestratorService.startAttempt({ - runId: run.id, - stepId: firstChild!.id, - ownerId: "test-owner", - executorKind: "manual", - }); - - await fixture.orchestratorService.completeAttempt({ - attemptId: attempt.id, - status: "failed", - errorClass: "executor_failure", - errorMessage: "First child failed", - }); - - // Parent should remain 'succeeded' (its pre-fanout status) since second child not yet terminal - const finalGraph = fixture.orchestratorService.getRunGraph({ runId: run.id }); - const finalParent = finalGraph.steps.find((s) => s.stepKey === "parent-step"); - expect(finalParent!.status).toBe("succeeded"); - } finally { - fixture.dispose(); - } - }); -}); diff --git a/apps/desktop/src/main/services/orchestrator/worktreeIsolation.test.ts b/apps/desktop/src/main/services/orchestrator/worktreeIsolation.test.ts deleted file mode 100644 index 62a5dab36..000000000 --- a/apps/desktop/src/main/services/orchestrator/worktreeIsolation.test.ts +++ /dev/null @@ -1,462 +0,0 @@ -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; -import { describe, expect, it } from "vitest"; -import { buildFullPrompt } from "./baseOrchestratorAdapter"; -import { createOrchestratorService } from "./orchestratorService"; -import { openKvDb } from "../state/kvDb"; -import type { PackExport, PackType } from "../../../shared/types"; - -// ───────────────────────────────────────────────────── -// Helpers -// ───────────────────────────────────────────────────── - -function createLogger() { - return { - debug: () => {}, - info: () => {}, - warn: () => {}, - error: () => {}, - } as any; -} - -function buildExport( - packKey: string, - packType: PackType, - level: "lite" | "standard" | "deep" -): PackExport { - return { - packKey, - packType, - level, - header: {} as any, - content: `${packKey}:${level}`, - approxTokens: 32, - maxTokens: 500, - truncated: false, - warnings: [], - clipReason: null, - omittedSections: null, - }; -} - -async function createFixture(args: { - laneWorktreePath?: string | null; - aiIntegrationService?: Record | null; -} = {}) { - const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-worktree-iso-")); - fs.mkdirSync(path.join(projectRoot, "docs", "architecture"), { recursive: true }); - fs.writeFileSync(path.join(projectRoot, "docs", "PRD.md"), "# PRD\n\nContext\n", "utf8"); - fs.writeFileSync(path.join(projectRoot, "docs", "architecture", "CONTEXT_CONTRACT.md"), "# CC\n", "utf8"); - - const db = await openKvDb(path.join(projectRoot, "ade.db"), createLogger()); - const projectId = "proj-1"; - const laneId = "lane-1"; - const missionId = "mission-1"; - const now = "2026-03-10T00:00:00.000Z"; - - db.run( - `insert into projects(id, root_path, display_name, default_base_ref, created_at, last_opened_at) - values (?, ?, ?, ?, ?, ?)`, - [projectId, projectRoot, "ADE", "main", now, now] - ); - - // Lane with configurable worktree_path (defaults to projectRoot, null means null) - const worktreePath = args.laneWorktreePath === undefined ? projectRoot : args.laneWorktreePath; - db.run( - `insert into lanes( - id, project_id, name, description, lane_type, base_ref, branch_ref, - worktree_path, attached_root_path, is_edit_protected, parent_lane_id, - color, icon, tags_json, status, created_at, archived_at - ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, - [ - laneId, projectId, "Lane 1", null, "worktree", "main", "feature/lane-1", - worktreePath, null, 0, null, null, null, null, "active", now, null, - ] - ); - - db.run( - `insert into missions( - id, project_id, lane_id, title, prompt, status, priority, - execution_mode, target_machine_id, outcome_summary, last_error, - metadata_json, created_at, updated_at, started_at, completed_at - ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, - [ - missionId, projectId, laneId, "Mission 1", "Execute worktree test.", - "queued", "normal", "local", null, null, null, null, now, now, null, null, - ] - ); - - const ptyCreateCalls: Array> = []; - const ptyService = { - create: async (createArgs: Record) => { - ptyCreateCalls.push(createArgs); - const index = ptyCreateCalls.length; - return { ptyId: `pty-${index}`, sessionId: `session-${index}` }; - }, - } as any; - - const packService = { - getLaneExport: async ({ laneId: lid, level }: { laneId: string; level: string }) => - buildExport(`lane:${lid}`, "lane", level as any), - getProjectExport: async ({ level }: { level: string }) => - buildExport("project", "project", level as any), - refreshMissionPack: async ({ missionId: mid }: { missionId: string }) => ({ - packKey: `mission:${mid}`, - packType: "mission", - path: path.join(projectRoot, ".ade", "packs", "missions", mid, "mission_pack.md"), - exists: true, - deterministicUpdatedAt: now, - narrativeUpdatedAt: null, - lastHeadSha: null, - versionId: `mission-${mid}-v1`, - versionNumber: 1, - contentHash: `hash-mission-${mid}`, - metadata: null, - body: "# Mission Pack", - }), - } as any; - - const service = createOrchestratorService({ - db, - projectId, - projectRoot, - ptyService, - projectConfigService: null as any, - aiIntegrationService: (args.aiIntegrationService ?? null) as any, - memoryService: null as any, - }); - - // Normalize modelId for opencode executor steps - const defaultOpenCodeModelId = "anthropic/claude-sonnet-4-6"; - const originalStartRun = service.startRun.bind(service); - (service as any).startRun = ((input: any) => - originalStartRun({ - ...input, - steps: Array.isArray(input?.steps) - ? input.steps.map((step: any) => { - const executorKind = typeof step?.executorKind === "string" ? step.executorKind : null; - if (executorKind !== "opencode") return step; - const metadata = step?.metadata && typeof step.metadata === "object" ? step.metadata : {}; - const modelId = typeof metadata.modelId === "string" ? metadata.modelId.trim() : ""; - if (modelId.length > 0) return step; - return { ...step, metadata: { ...metadata, modelId: defaultOpenCodeModelId } }; - }) - : input?.steps, - })) as typeof service.startRun; - - return { - db, - service, - projectId, - projectRoot, - laneId, - missionId, - ptyCreateCalls, - dispose: () => db.close(), - }; -} - -// ───────────────────────────────────────────────────── -// VAL-ISO-001: Workers execute within lane worktree -// ───────────────────────────────────────────────────── - -describe("VAL-ISO-001: Worktree isolation in startAttempt", () => { - // Use an API model (isCliWrapped=false) to exercise the in-process worker path - // where cwd is resolved from laneWorktreePath in orchestratorService.ts. - const apiModelId = "opencode/anthropic/claude-sonnet-4-6"; - - it("resolves cwd to lane worktree_path for in-process workers", async () => { - const worktreeDir = fs.mkdtempSync(path.join(os.tmpdir(), "ade-lane-wt-")); - let capturedCwd: string | undefined; - const aiIntegrationService = { - executeTask: async (execArgs: Record) => { - capturedCwd = execArgs.cwd as string; - return { - textResponse: "Done.", - tokenUsage: { inputTokens: 100, outputTokens: 50 }, - }; - }, - }; - - const fixture = await createFixture({ - laneWorktreePath: worktreeDir, - aiIntegrationService, - }); - try { - const { run } = await fixture.service.startRun({ - missionId: fixture.missionId, - steps: [ - { - stepKey: "test-step", - stepIndex: 0, - title: "Test Step", - executorKind: "opencode", - laneId: fixture.laneId, - metadata: { modelId: apiModelId }, - }, - ], - }); - - const readySteps = fixture.service.getRunGraph({ runId: run.id }).steps.filter( - (s) => s.status === "ready" - ); - expect(readySteps.length).toBeGreaterThan(0); - - await fixture.service.startAttempt({ - runId: run.id, - stepId: readySteps[0].id, - ownerId: "test-owner", - executorKind: "opencode", - }); - - // The in-process worker should have received the lane worktree path as cwd - expect(capturedCwd).toBe(worktreeDir); - expect(capturedCwd).not.toBe(fixture.projectRoot); - } finally { - fixture.dispose(); - } - }); - - it("fails with configuration_error when worktree_path is empty for a step with laneId", async () => { - // The lanes table has NOT NULL on worktree_path, so we test with empty string - const fixture = await createFixture({ - laneWorktreePath: "", - aiIntegrationService: { - executeTask: async () => { - throw new Error("Should not be called"); - }, - }, - }); - try { - const { run } = await fixture.service.startRun({ - missionId: fixture.missionId, - steps: [ - { - stepKey: "test-step", - stepIndex: 0, - title: "Test Step", - executorKind: "opencode", - laneId: fixture.laneId, - metadata: { modelId: apiModelId }, - }, - ], - }); - - const readySteps = fixture.service.getRunGraph({ runId: run.id }).steps.filter( - (s) => s.status === "ready" - ); - expect(readySteps.length).toBeGreaterThan(0); - - const attempt = await fixture.service.startAttempt({ - runId: run.id, - stepId: readySteps[0].id, - ownerId: "test-owner", - executorKind: "opencode", - }); - - // Should fail with configuration_error, not silently fall back to projectRoot - expect(attempt.status).toBe("failed"); - expect(attempt.errorClass).toBe("configuration_error"); - expect(attempt.errorMessage).toContain("worktree_path"); - } finally { - fixture.dispose(); - } - }); - - it("fails with configuration_error when worktree_path is whitespace-only for a step with laneId", async () => { - const fixture = await createFixture({ - laneWorktreePath: " ", - aiIntegrationService: { - executeTask: async () => { - throw new Error("Should not be called"); - }, - }, - }); - try { - const { run } = await fixture.service.startRun({ - missionId: fixture.missionId, - steps: [ - { - stepKey: "test-step", - stepIndex: 0, - title: "Test Step", - executorKind: "opencode", - laneId: fixture.laneId, - metadata: { modelId: apiModelId }, - }, - ], - }); - - const readySteps = fixture.service.getRunGraph({ runId: run.id }).steps.filter( - (s) => s.status === "ready" - ); - expect(readySteps.length).toBeGreaterThan(0); - - const attempt = await fixture.service.startAttempt({ - runId: run.id, - stepId: readySteps[0].id, - ownerId: "test-owner", - executorKind: "opencode", - }); - - expect(attempt.status).toBe("failed"); - expect(attempt.errorClass).toBe("configuration_error"); - } finally { - fixture.dispose(); - } - }); - - it("uses projectRoot as cwd when step has no laneId (non-lane fallback)", async () => { - // Non-lane steps (without laneId) should use projectRoot. - // We test at the code level since opencode executor requires laneId. - // Verify the laneWorktreePath resolution logic directly: - // when step.laneId is falsy, the code should return projectRoot. - // This is tested via buildFullPrompt's lack of worktree constraint for no-lane steps. - const result = buildFullPrompt( - { - run: { - id: "run-1", - missionId: "mission-1", - metadata: { missionGoal: "Test mission" }, - } as any, - step: { - id: "step-1", - title: "No Lane Step", - stepKey: "no-lane-step", - laneId: null, - metadata: {}, - dependencyStepIds: [], - joinPolicy: "all_success", - } as any, - attempt: {} as any, - allSteps: [], - contextProfile: {} as any, - laneExport: null, - projectExport: { content: "Project context" } as any, - docsRefs: [], - fullDocs: [], - createTrackedSession: async () => ({ ptyId: "pty-1", sessionId: "session-1" }), - }, - "opencode", - {} - ); - - // No worktree constraint for non-lane steps - expect(result.prompt).not.toContain("You are working in:"); - expect(result.prompt).not.toContain("All file edits MUST be made within this path"); - }); -}); - -// ───────────────────────────────────────────────────── -// VAL-ISO-002: Prompt instructs worker to write only in worktree -// ───────────────────────────────────────────────────── - -describe("VAL-ISO-002: Worktree constraint in buildFullPrompt", () => { - it("includes worktree constraint when lane worktree is assigned", () => { - const worktreePath = "/tmp/test-worktree/lane-1"; - const result = buildFullPrompt( - { - run: { - id: "run-1", - missionId: "mission-1", - metadata: { missionGoal: "Test mission" }, - } as any, - step: { - id: "step-1", - title: "Test Step", - stepKey: "test-step", - laneId: "lane-1", - metadata: { - laneWorktreePath: worktreePath, - }, - dependencyStepIds: [], - joinPolicy: "all_success", - } as any, - attempt: {} as any, - allSteps: [], - contextProfile: {} as any, - laneExport: null, - projectExport: { content: "Project context" } as any, - docsRefs: [], - fullDocs: [], - createTrackedSession: async () => ({ ptyId: "pty-1", sessionId: "session-1" }), - }, - "opencode", - {} - ); - - expect(result.prompt).toContain("You are working in:"); - expect(result.prompt).toContain(worktreePath); - expect(result.prompt).toContain("All file edits MUST be made within this path"); - }); - - it("does NOT include worktree constraint when no lane is assigned", () => { - const result = buildFullPrompt( - { - run: { - id: "run-1", - missionId: "mission-1", - metadata: { missionGoal: "Test mission" }, - } as any, - step: { - id: "step-1", - title: "Test Step", - stepKey: "test-step", - laneId: null, - metadata: {}, - dependencyStepIds: [], - joinPolicy: "all_success", - } as any, - attempt: {} as any, - allSteps: [], - contextProfile: {} as any, - laneExport: null, - projectExport: { content: "Project context" } as any, - docsRefs: [], - fullDocs: [], - createTrackedSession: async () => ({ ptyId: "pty-1", sessionId: "session-1" }), - }, - "opencode", - {} - ); - - expect(result.prompt).not.toContain("You are working in:"); - expect(result.prompt).not.toContain("All file edits MUST be made within this path"); - }); - - it("does NOT include worktree constraint when laneId is set but no laneWorktreePath in metadata", () => { - const result = buildFullPrompt( - { - run: { - id: "run-1", - missionId: "mission-1", - metadata: { missionGoal: "Test mission" }, - } as any, - step: { - id: "step-1", - title: "Test Step", - stepKey: "test-step", - laneId: "lane-1", - metadata: {}, - dependencyStepIds: [], - joinPolicy: "all_success", - } as any, - attempt: {} as any, - allSteps: [], - contextProfile: {} as any, - laneExport: null, - projectExport: { content: "Project context" } as any, - docsRefs: [], - fullDocs: [], - createTrackedSession: async () => ({ ptyId: "pty-1", sessionId: "session-1" }), - }, - "opencode", - {} - ); - - // Without laneWorktreePath in metadata, no constraint should be added - expect(result.prompt).not.toContain("You are working in:"); - expect(result.prompt).not.toContain("All file edits MUST be made within this path"); - }); -}); diff --git a/apps/desktop/src/main/services/prs/integrationPlanning.test.ts b/apps/desktop/src/main/services/prs/integrationPlanning.test.ts deleted file mode 100644 index 096c47e4b..000000000 --- a/apps/desktop/src/main/services/prs/integrationPlanning.test.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { describe, expect, it } from "vitest"; -import type { LaneSummary } from "../../../shared/types"; -import { buildIntegrationPreflight, resolveIntegrationBaseLane } from "./integrationPlanning"; - -function makeLane(id: string, branch: string, overrides: Partial = {}): LaneSummary { - return { - id, - name: branch, - description: null, - laneType: "worktree", - baseRef: "refs/heads/main", - branchRef: `refs/heads/${branch}`, - worktreePath: `/tmp/${id}`, - attachedRootPath: null, - parentLaneId: null, - childCount: 0, - stackDepth: 0, - parentStatus: null, - isEditProtected: false, - status: { dirty: false, ahead: 0, behind: 0, remoteBehind: -1, rebaseInProgress: false }, - color: null, - icon: null, - tags: [], - folder: null, - createdAt: "2026-03-11T00:00:00.000Z", - archivedAt: null, - ...overrides, - }; -} - -describe("integrationPlanning", () => { - it("resolves the base lane from the actual branch lane", () => { - const primary = makeLane("lane-main", "main", { laneType: "primary", baseRef: "refs/heads/main" }); - const child = makeLane("lane-feature", "feature/auth", { baseRef: "refs/heads/main" }); - - expect(resolveIntegrationBaseLane([child, primary], "main")?.id).toBe("lane-main"); - }); - - it("does not treat descendant lanes with matching baseRef as the base lane", () => { - const child = makeLane("lane-feature", "feature/auth", { baseRef: "refs/heads/main" }); - - expect(resolveIntegrationBaseLane([child], "main")).toBeNull(); - }); - - it("deduplicates source lanes and reports missing lanes", () => { - const lanes = [makeLane("lane-a", "feature/a"), makeLane("lane-main", "main", { laneType: "primary" })]; - - expect(buildIntegrationPreflight(lanes, ["lane-a", "lane-a", "lane-missing"], "main")).toEqual({ - baseLane: lanes[1], - uniqueSourceLaneIds: ["lane-a", "lane-missing"], - duplicateSourceLaneIds: ["lane-a"], - missingSourceLaneIds: ["lane-missing"], - }); - }); -}); diff --git a/apps/desktop/src/main/services/prs/integrationValidation.test.ts b/apps/desktop/src/main/services/prs/integrationValidation.test.ts deleted file mode 100644 index cc20fd09d..000000000 --- a/apps/desktop/src/main/services/prs/integrationValidation.test.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { hasMergeConflictMarkers, parseGitStatusPorcelain } from "./integrationValidation"; - -describe("integrationValidation", () => { - it("parses changed and unmerged files from porcelain output", () => { - expect( - parseGitStatusPorcelain([ - "UU src/conflicted.ts", - "M src/modified.ts", - "A src/added.ts", - ].join("\n")), - ).toEqual({ - unmergedPaths: ["src/conflicted.ts"], - changedPaths: ["src/conflicted.ts", "src/modified.ts", "src/added.ts"], - }); - }); - - it("normalizes renamed paths to the new filename", () => { - expect( - parseGitStatusPorcelain("R src/old-name.ts -> src/new-name.ts"), - ).toEqual({ - unmergedPaths: [], - changedPaths: ["src/new-name.ts"], - }); - }); - - it("detects merge conflict markers in file contents", () => { - expect(hasMergeConflictMarkers("<<<<<<< ours\nhello\n=======\nworld\n>>>>>>> theirs\n")).toBe(true); - expect(hasMergeConflictMarkers("function example() { return 'clean'; }\n")).toBe(false); - }); -}); diff --git a/apps/desktop/src/main/services/prs/prPollingService.test.ts b/apps/desktop/src/main/services/prs/prAsync.test.ts similarity index 55% rename from apps/desktop/src/main/services/prs/prPollingService.test.ts rename to apps/desktop/src/main/services/prs/prAsync.test.ts index 6334153fe..50a1900a8 100644 --- a/apps/desktop/src/main/services/prs/prPollingService.test.ts +++ b/apps/desktop/src/main/services/prs/prAsync.test.ts @@ -1,6 +1,15 @@ -import { describe, expect, it, vi, afterEach } from "vitest"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; import type { PrSummary } from "../../../shared/types"; +import { openKvDb } from "../state/kvDb"; import { createPrPollingService } from "./prPollingService"; +import { buildPrSummaryPrompt, createPrSummaryService, parsePrSummaryJson } from "./prSummaryService"; + +// --------------------------------------------------------------------------- +// Shared helpers +// --------------------------------------------------------------------------- function createLogger() { return { @@ -36,6 +45,32 @@ function createSummary(overrides: Partial = {}): PrSummary { }; } +async function seedSummaryDb(db: any, prId: string, headSha: string | null) { + const now = "2026-04-14T00:00:00.000Z"; + db.run( + "insert into projects(id, root_path, display_name, default_base_ref, created_at, last_opened_at) values (?, ?, ?, ?, ?, ?)", + ["proj", "/tmp", "ADE", "main", now, now], + ); + db.run( + ` + insert into pull_requests( + id, project_id, lane_id, repo_owner, repo_name, github_pr_number, github_url, github_node_id, + title, state, base_branch, head_branch, checks_status, review_status, additions, deletions, + last_synced_at, created_at, updated_at, head_sha + ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `, + [ + prId, "proj", "lane-1", "arul28", "ADE", 1, + "https://github.com/arul28/ADE/pull/1", null, "Test", "open", "main", "feat", + "passing", "approved", 0, 0, now, now, now, headSha, + ], + ); +} + +// --------------------------------------------------------------------------- +// prPollingService — hot refresh, backoff, notifications +// --------------------------------------------------------------------------- + describe("prPollingService", () => { afterEach(() => { vi.useRealTimers(); @@ -56,16 +91,10 @@ describe("prPollingService", () => { listAll: () => [summary], refresh: vi.fn(async (args?: { prId?: string; prIds?: string[] }) => { refreshCalls.push(args); - summary = { - ...summary, - updatedAt: new Date(Date.now()).toISOString(), - }; + summary = { ...summary, updatedAt: new Date(Date.now()).toISOString() }; return [summary]; }), - getHotRefreshDelayMs: () => { - if (!hotIds.length) return null; - return 5_000; - }, + getHotRefreshDelayMs: () => (hotIds.length ? 5_000 : null), getHotRefreshPrIds: () => hotIds, } as any; @@ -160,11 +189,7 @@ describe("prPollingService", () => { refresh: vi.fn(async () => { refreshCount += 1; if (refreshCount >= 2) { - summary = { - ...summary, - reviewStatus: "requested" as const, - updatedAt: new Date(Date.now()).toISOString(), - }; + summary = { ...summary, reviewStatus: "requested" as const, updatedAt: new Date(Date.now()).toISOString() }; } return [summary]; }), @@ -181,7 +206,6 @@ describe("prPollingService", () => { service.start(); await vi.advanceTimersByTimeAsync(12_000); - service.poke(); await vi.advanceTimersByTimeAsync(0); @@ -217,11 +241,7 @@ describe("prPollingService", () => { refresh: vi.fn(async () => { refreshCount += 1; if (refreshCount >= 2) { - summary = { - ...summary, - reviewStatus: "changes_requested" as const, - updatedAt: new Date(Date.now()).toISOString(), - }; + summary = { ...summary, reviewStatus: "changes_requested" as const, updatedAt: new Date(Date.now()).toISOString() }; } return [summary]; }), @@ -238,7 +258,6 @@ describe("prPollingService", () => { service.start(); await vi.advanceTimersByTimeAsync(12_000); - service.poke(); await vi.advanceTimersByTimeAsync(0); @@ -272,11 +291,7 @@ describe("prPollingService", () => { refresh: vi.fn(async () => { refreshCount += 1; if (refreshCount >= 2) { - summary = { - ...summary, - checksStatus: "passing" as const, - updatedAt: new Date(Date.now()).toISOString(), - }; + summary = { ...summary, checksStatus: "passing" as const, updatedAt: new Date(Date.now()).toISOString() }; } return [summary]; }), @@ -293,7 +308,6 @@ describe("prPollingService", () => { service.start(); await vi.advanceTimersByTimeAsync(12_000); - service.poke(); await vi.advanceTimersByTimeAsync(0); @@ -328,11 +342,7 @@ describe("prPollingService", () => { refresh: vi.fn(async () => { refreshCount += 1; if (refreshCount >= 2) { - summary = { - ...summary, - checksStatus: "failing" as const, - updatedAt: new Date(Date.now()).toISOString(), - }; + summary = { ...summary, checksStatus: "failing" as const, updatedAt: new Date(Date.now()).toISOString() }; } return [summary]; }), @@ -349,13 +359,11 @@ describe("prPollingService", () => { service.start(); await vi.advanceTimersByTimeAsync(12_000); - service.poke(); await vi.advanceTimersByTimeAsync(0); const notification = events.find((e) => e.type === "pr-notification" && e.kind === "checks_failing"); expect(notification, "Expected a checks_failing notification to be emitted").toBeTruthy(); - // Title should NOT contain #999 any more expect(notification.title).not.toContain("#999"); expect(notification.title).toBe("Checks failing"); }); @@ -377,11 +385,7 @@ describe("prPollingService", () => { refresh: vi.fn(async () => { refreshCount += 1; if (refreshCount >= 2) { - summary = { - ...summary, - checksStatus: "failing" as const, - updatedAt: new Date(Date.now()).toISOString(), - }; + summary = { ...summary, checksStatus: "failing" as const, updatedAt: new Date(Date.now()).toISOString() }; } return [summary]; }), @@ -398,11 +402,9 @@ describe("prPollingService", () => { }); service.start(); - // First tick initializes await vi.advanceTimersByTimeAsync(12_000); expect(changedCalls).toHaveLength(0); - // Second tick has changed data service.poke(); await vi.advanceTimersByTimeAsync(0); @@ -433,11 +435,7 @@ describe("prPollingService", () => { refresh: vi.fn(async () => { refreshCount += 1; if (refreshCount >= 2) { - summary = { - ...summary, - checksStatus: "failing", - updatedAt: new Date(Date.now()).toISOString(), - }; + summary = { ...summary, checksStatus: "failing", updatedAt: new Date(Date.now()).toISOString() }; } return [summary]; }), @@ -454,7 +452,6 @@ describe("prPollingService", () => { service.start(); await vi.advanceTimersByTimeAsync(12_000); - service.poke(); await vi.advanceTimersByTimeAsync(0); @@ -471,3 +468,235 @@ describe("prPollingService", () => { })); }); }); + +// --------------------------------------------------------------------------- +// prSummaryService — prompt building, JSON parsing, summary cache +// --------------------------------------------------------------------------- + +describe("buildPrSummaryPrompt", () => { + it("includes title, body, file list, unresolved count, and bot summaries", () => { + const prompt = buildPrSummaryPrompt({ + title: "Add feature", + body: "Body content", + changedFiles: [ + { filename: "a.ts", status: "modified", additions: 1, deletions: 0, patch: null, previousFilename: null }, + { filename: "b.ts", status: "added", additions: 10, deletions: 0, patch: null, previousFilename: null }, + ], + issueComments: [ + { + id: "c1", + author: "greptile-bot", + authorAvatarUrl: null, + body: "Looks risky", + source: "issue", + url: null, + path: null, + line: null, + createdAt: null, + updatedAt: null, + }, + ], + reviews: [ + { + reviewer: "coderabbitai[bot]", + reviewerAvatarUrl: null, + state: "commented", + body: "Formal bot review body", + submittedAt: null, + }, + ], + unresolvedThreadCount: 3, + }); + expect(prompt).toContain("Add feature"); + expect(prompt).toContain("Body content"); + expect(prompt).toContain("modified a.ts"); + expect(prompt).toContain("added b.ts"); + expect(prompt).toContain("Unresolved review threads: 3"); + expect(prompt).toContain("@greptile-bot"); + }); +}); + +describe("parsePrSummaryJson", () => { + it("returns fields when valid JSON provided", () => { + const result = parsePrSummaryJson( + '```json\n{"summary":"x","riskAreas":["a"],"reviewerHotspots":["b"],"unresolvedConcerns":[]}\n```', + ); + expect(result).toEqual({ + summary: "x", + riskAreas: ["a"], + reviewerHotspots: ["b"], + unresolvedConcerns: [], + }); + }); + + it("filters non-string array entries", () => { + const result = parsePrSummaryJson( + '{"summary":"s","riskAreas":["ok", 5, null],"reviewerHotspots":[],"unresolvedConcerns":[]}', + ); + expect(result?.riskAreas).toEqual(["ok"]); + }); + + it("returns null on missing JSON", () => { + expect(parsePrSummaryJson("no json here")).toBeNull(); + }); + + it("returns null on invalid JSON", () => { + expect(parsePrSummaryJson("{ not json }")).toBeNull(); + }); +}); + +describe("createPrSummaryService", () => { + it("returns null from getSummary when no cache entry exists", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "ade-prs-sum-get-")); + const db = await openKvDb(path.join(root, ".ade.db"), createLogger()); + try { + await seedSummaryDb(db, "pr-1", "headA"); + const svc = createPrSummaryService({ + db, + logger: createLogger() as any, + projectRoot: root, + prService: {} as any, + }); + await expect(svc.getSummary("pr-1")).resolves.toBeNull(); + } finally { + db.close(); + fs.rmSync(root, { recursive: true, force: true }); + } + }); + + it("regenerateSummary caches the result keyed by (prId, headSha) and parses JSON", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "ade-prs-sum-regen-")); + const db = await openKvDb(path.join(root, ".ade.db"), createLogger()); + try { + await seedSummaryDb(db, "pr-1", "headA"); + + const prService = { + listAll: () => [ + { + id: "pr-1", + laneId: "lane-1", + projectId: "proj", + repoOwner: "arul28", + repoName: "ADE", + githubPrNumber: 1, + githubUrl: "", + githubNodeId: null, + title: "PR title", + state: "open", + baseBranch: "main", + headBranch: "feat", + checksStatus: "passing", + reviewStatus: "approved", + additions: 0, + deletions: 0, + lastSyncedAt: null, + createdAt: "", + updatedAt: "", + }, + ], + getDetail: vi.fn(async () => ({ + prId: "pr-1", + body: "Detail body", + labels: [], + assignees: [], + requestedReviewers: [], + author: { login: "arul", avatarUrl: null }, + isDraft: false, + milestone: null, + linkedIssues: [], + })), + getFiles: vi.fn(async () => [ + { filename: "x.ts", status: "modified", additions: 1, deletions: 0, patch: null, previousFilename: null }, + ]), + getComments: vi.fn(async () => []), + getReviewThreads: vi.fn(async () => [ + { + id: "t1", + isResolved: false, + isOutdated: false, + path: "x.ts", + line: 1, + originalLine: 1, + startLine: 0, + originalStartLine: 0, + diffSide: "RIGHT", + url: null, + createdAt: null, + updatedAt: null, + comments: [], + }, + ]), + getReviews: vi.fn(async () => []), + }; + + const aiIntegrationService = { + draftPrDescription: vi.fn(async () => ({ + text: '{"summary":"ok","riskAreas":["a"],"reviewerHotspots":["b"],"unresolvedConcerns":["c"]}', + durationMs: 10, + executedAt: "x", + model: "m", + provider: "openai" as const, + reasoningEffort: null, + promptTokens: null, + completionTokens: null, + totalTokens: null, + budgetState: null, + taskType: "pr_description" as const, + feature: "pr_descriptions" as const, + })), + }; + + const svc = createPrSummaryService({ + db, + logger: createLogger() as any, + projectRoot: root, + prService: prService as any, + aiIntegrationService: aiIntegrationService as any, + }); + + const result = await svc.regenerateSummary("pr-1"); + expect(result.summary).toBe("ok"); + expect(result.riskAreas).toEqual(["a"]); + expect(result.headSha).toBe("headA"); + expect(aiIntegrationService.draftPrDescription).toHaveBeenCalledTimes(1); + + const cached = await svc.getSummary("pr-1"); + expect(cached?.summary).toBe("ok"); + expect(cached?.headSha).toBe("headA"); + + const again = await svc.regenerateSummary("pr-1"); + expect(again.summary).toBe("ok"); + expect(aiIntegrationService.draftPrDescription).toHaveBeenCalledTimes(2); + } finally { + db.close(); + fs.rmSync(root, { recursive: true, force: true }); + } + }); + + it("falls back gracefully when aiIntegrationService is missing", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "ade-prs-sum-fallback-")); + const db = await openKvDb(path.join(root, ".ade.db"), createLogger()); + try { + await seedSummaryDb(db, "pr-1", "headA"); + const prService = { + listAll: () => [{ id: "pr-1", title: "t" } as any], + getDetail: async () => null, + getFiles: async () => [], + getComments: async () => [], + getReviewThreads: async () => [], + getReviews: async () => [], + }; + const svc = createPrSummaryService({ + db, + logger: createLogger() as any, + projectRoot: root, + prService: prService as any, + }); + const result = await svc.regenerateSummary("pr-1"); + expect(result.summary).toMatch(/0 file/); + } finally { + db.close(); + fs.rmSync(root, { recursive: true, force: true }); + } + }); +}); diff --git a/apps/desktop/src/main/services/prs/issueInventoryService.test.ts b/apps/desktop/src/main/services/prs/prIssueResolution.test.ts similarity index 71% rename from apps/desktop/src/main/services/prs/issueInventoryService.test.ts rename to apps/desktop/src/main/services/prs/prIssueResolution.test.ts index e261f3322..60241c020 100644 --- a/apps/desktop/src/main/services/prs/issueInventoryService.test.ts +++ b/apps/desktop/src/main/services/prs/prIssueResolution.test.ts @@ -1,11 +1,26 @@ +// Merged: issueInventoryService + prIssueResolver +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; import { beforeEach, describe, expect, it, vi } from "vitest"; import type { IssueInventoryItem, + LaneSummary, + PrActionRun, PrCheck, PrComment, + PrDetail, + PrFile, PrReviewThread, + PrSummary, } from "../../../shared/types"; import { createIssueInventoryService, detectSource } from "./issueInventoryService"; +import { buildPrIssueResolutionPrompt, launchPrIssueResolutionChat, previewPrIssueResolutionPrompt } from "./prIssueResolver"; + +// =========================================================================== +// issueInventoryService — issue inventory CRUD, sync, convergence, pipeline +// =========================================================================== + // --------------------------------------------------------------------------- // Helpers @@ -2198,3 +2213,751 @@ describe("detectSource", () => { expect(detectSource("Alice Smith")).toBe("human"); }); }); + +// =========================================================================== +// prIssueResolver — issue resolution chat launch, prompt composition +// =========================================================================== + + +function makeLane(overrides: Partial = {}): LaneSummary { + return { + id: "lane-1", + name: "feature/pr-80", + description: "Tighten the PR workflow lane.", + laneType: "worktree", + baseRef: "main", + branchRef: "feature/pr-80", + worktreePath: overrides.worktreePath ?? fs.mkdtempSync(path.join(os.tmpdir(), "ade-pr-issue-lane-")), + attachedRootPath: null, + parentLaneId: null, + childCount: 0, + stackDepth: 0, + parentStatus: null, + isEditProtected: false, + status: { dirty: false, ahead: 0, behind: 0, remoteBehind: -1, rebaseInProgress: false }, + color: null, + icon: null, + tags: [], + folder: null, + createdAt: "2026-03-23T12:00:00.000Z", + archivedAt: null, + ...overrides, + }; +} + +function makePr(overrides: Partial = {}): PrSummary { + return { + id: "pr-80", + laneId: "lane-1", + projectId: "project-1", + repoOwner: "ade-dev", + repoName: "ade", + githubPrNumber: 80, + githubUrl: "https://github.com/ade-dev/ade/pull/80", + githubNodeId: "PR_kwDOExample", + title: "Stabilize GitHub PR flows", + state: "open", + baseBranch: "main", + headBranch: "feature/pr-80", + checksStatus: "failing", + reviewStatus: "changes_requested", + additions: 25, + deletions: 8, + lastSyncedAt: "2026-03-23T12:00:00.000Z", + createdAt: "2026-03-23T11:00:00.000Z", + updatedAt: "2026-03-23T12:00:00.000Z", + ...overrides, + }; +} + +function makeDetail(overrides: Partial = {}): PrDetail { + return { + prId: "pr-80", + body: "This PR makes the GitHub PR detail view more reliable.", + labels: [], + assignees: [], + requestedReviewers: [], + author: { login: "octocat", avatarUrl: null }, + isDraft: false, + milestone: null, + linkedIssues: [], + ...overrides, + }; +} + +const WORKFLOW_PR_TOOL_NAMES = [ + "prRefreshIssueInventory", + "prGetReviewComments", + "prRerunFailedChecks", + "prReplyToReviewThread", + "prResolveReviewThread", +]; + +describe("buildPrIssueResolutionPrompt", () => { + it("includes scope, issue inventory, extra instructions, and regression guidance", () => { + const prompt = buildPrIssueResolutionPrompt({ + pr: makePr(), + lane: makeLane({ worktreePath: "/tmp/lane-pr-80" }), + detail: makeDetail(), + files: [ + { filename: "src/prs.ts", status: "modified", additions: 10, deletions: 2, patch: null, previousFilename: null }, + ], + checks: [ + { name: "ci / unit", status: "completed", conclusion: "failure", detailsUrl: "https://example.com/check", startedAt: null, completedAt: null }, + ], + actionRuns: [ + { + id: 71, + name: "CI", + status: "completed", + conclusion: "failure", + headSha: "abc123", + htmlUrl: "https://example.com/run/71", + createdAt: "2026-03-23T12:00:00.000Z", + updatedAt: "2026-03-23T12:10:00.000Z", + jobs: [ + { + id: 81, + name: "test", + status: "completed", + conclusion: "failure", + startedAt: null, + completedAt: null, + steps: [ + { name: "vitest", status: "completed", conclusion: "failure", number: 1, startedAt: null, completedAt: null }, + ], + }, + ], + } satisfies PrActionRun, + ], + reviewThreads: [ + { + id: "thread-1", + isResolved: false, + isOutdated: false, + path: "src/prs.ts", + line: 42, + originalLine: 42, + startLine: null, + originalStartLine: null, + diffSide: "RIGHT", + url: "https://example.com/thread/1", + createdAt: "2026-03-23T12:00:00.000Z", + updatedAt: "2026-03-23T12:05:00.000Z", + comments: [ + { + id: "comment-1", + author: "reviewer", + authorAvatarUrl: null, + body: "Please handle the loading state here.", + url: "https://example.com/comment/1", + createdAt: "2026-03-23T12:00:00.000Z", + updatedAt: "2026-03-23T12:00:00.000Z", + }, + ], + } satisfies PrReviewThread, + ], + issueComments: [ + { + id: "issue-comment-1", + author: "coderabbitai[bot]", + authorAvatarUrl: null, + body: "Consider simplifying this branch.", + source: "issue", + url: "https://example.com/issue-comment/1", + path: null, + line: null, + createdAt: "2026-03-23T12:00:00.000Z", + updatedAt: "2026-03-23T12:00:00.000Z", + }, + ], + scope: "both", + additionalInstructions: "Please keep the PR description accurate if behavior changes.", + recentCommits: [{ sha: "abcdef123456", subject: "Refine PR detail header" }], + }); + + expect(prompt).toContain("Selected scope: checks and review comments"); + expect(prompt).toContain("ADE PR id (for ADE tools): pr-80"); + expect(prompt).toContain("Runtime: Workflow chat with ADE PR tools"); + expect(prompt).toContain("Please keep the PR description accurate if behavior changes."); + expect(prompt).toContain("Watch carefully for regressions caused by your fixes."); + expect(prompt).toContain("update the test"); + expect(prompt).toContain("rerun the complete failing test files or suites locally"); + expect(prompt).toContain("one bounded Path to Merge resolution round"); + expect(prompt).toContain("ADE will poll GitHub"); + expect(prompt).toContain("Commit the changes and push the PR branch before you stop."); + expect(prompt).toContain("If you cannot safely commit or push the necessary changes"); + expect(prompt).toContain("prRefreshIssueInventory"); + expect(prompt).toContain("prGetReviewComments"); + expect(prompt).toContain("thread-1"); + expect(prompt).toContain("ci / unit"); + }); + + it("compresses review-thread bodies into references and filters noisy advisory comments", () => { + const prompt = buildPrIssueResolutionPrompt({ + pr: makePr({ title: "fix codex chat" }), + lane: makeLane({ worktreePath: "/tmp/lane-pr-80" }), + detail: makeDetail({ + body: "\n## Summary by CodeRabbit\nHuge autogenerated summary", + }), + files: [], + checks: [], + actionRuns: [], + reviewThreads: [ + { + id: "thread-1", + isResolved: false, + isOutdated: false, + path: "apps/desktop/src/renderer/components/chat/AgentChatPane.tsx", + line: 551, + originalLine: 551, + startLine: null, + originalStartLine: null, + diffSide: "RIGHT", + url: "https://example.com/thread/1", + createdAt: null, + updatedAt: null, + comments: [ + { + id: "comment-1", + author: "coderabbitai", + authorAvatarUrl: null, + body: "_⚠️ Potential issue_ | _🟡 Minor_\n\n**Derive `assistantLabel` from the effective provider.**\n\nThis can drift from the model that will actually run.\n\n
PromptVery long autogenerated block
", + url: "https://example.com/comment/1", + createdAt: null, + updatedAt: null, + }, + ], + } satisfies PrReviewThread, + ], + issueComments: [ + { + id: "issue-comment-1", + author: "coderabbitai[bot]", + authorAvatarUrl: null, + body: " giant summary", + source: "issue", + url: "https://example.com/issue-comment/1", + path: null, + line: null, + createdAt: null, + updatedAt: null, + }, + { + id: "issue-comment-2", + author: "vercel[bot]", + authorAvatarUrl: null, + body: "[vc]: deployment details", + source: "issue", + url: "https://example.com/issue-comment/2", + path: null, + line: null, + createdAt: null, + updatedAt: null, + }, + ], + scope: "comments", + additionalInstructions: null, + recentCommits: [], + }); + + expect(prompt).toContain("Changed test files / likely hotspots"); + expect(prompt).toContain("No changed test files detected in this PR."); + expect(prompt).toContain("Current unresolved review threads (summaries + references)"); + expect(prompt).toContain("Summary: Derive assistantLabel from the effective provider."); + expect(prompt).toContain("Reference: https://example.com/thread/1"); + expect(prompt).not.toContain("Very long autogenerated block"); + expect(prompt).not.toContain("giant summary"); + expect(prompt).not.toContain("deployment details"); + expect(prompt).not.toContain("Huge autogenerated summary"); + expect(prompt).toContain("prResolveReviewThread"); + expect(prompt).toContain("If you are running outside ADE, use the linked GitHub thread/check URLs"); + }); + + it("highlights changed test files as likely hotspots", () => { + const prompt = buildPrIssueResolutionPrompt({ + pr: makePr(), + lane: makeLane({ worktreePath: "/tmp/lane-pr-80" }), + detail: makeDetail(), + files: [ + { filename: "apps/desktop/src/renderer/components/chat/AgentChatMessageList.test.tsx", status: "modified", additions: 525, deletions: 8, patch: null, previousFilename: null }, + { filename: "apps/desktop/src/main/services/chat/chatTextBatching.test.ts", status: "added", additions: 113, deletions: 0, patch: null, previousFilename: null }, + { filename: "apps/desktop/src/renderer/components/chat/AgentChatPane.tsx", status: "modified", additions: 10, deletions: 2, patch: null, previousFilename: null }, + ], + checks: [], + actionRuns: [], + reviewThreads: [], + issueComments: [], + scope: "checks", + additionalInstructions: null, + recentCommits: [], + }); + + expect(prompt).toContain("Changed test files / likely hotspots"); + expect(prompt).toContain("heavily modified test file: apps/desktop/src/renderer/components/chat/AgentChatMessageList.test.tsx (+525/-8)"); + expect(prompt).toContain("new test file: apps/desktop/src/main/services/chat/chatTextBatching.test.ts (+113/-0)"); + expect(prompt).toContain("Treat newly added or heavily modified test files as likely regression hotspots"); + }); +}); + +describe("launchPrIssueResolutionChat", () => { + const failingCheck: PrCheck = { name: "ci / unit", status: "completed", conclusion: "failure", detailsUrl: null, startedAt: null, completedAt: null }; + + function makeDeps(overrides: { checks?: PrCheck[] } = {}) { + const lane = makeLane(); + const pr = makePr(); + const createSession = vi.fn(async () => ({ id: "session-1" })); + const sendMessage = vi.fn(async () => undefined); + const previewSessionToolNames = vi.fn(() => WORKFLOW_PR_TOOL_NAMES); + const updateMeta = vi.fn(); + + const issueInventoryService = { + syncFromPrData: vi.fn(() => ({ + items: [], + convergence: { currentRound: 0, status: "idle" }, + })), + getNewItems: vi.fn(() => []), + markSentToAgent: vi.fn(), + }; + + const deps = { + prService: { + listAll: () => [pr], + getDetail: vi.fn(async () => makeDetail()), + getFiles: vi.fn(async () => [] as PrFile[]), + getChecks: vi.fn(async () => overrides.checks ?? [failingCheck]), + getActionRuns: vi.fn(async () => [] as PrActionRun[]), + getReviewThreads: vi.fn(async () => [] as PrReviewThread[]), + getComments: vi.fn(async () => []), + } as any, + laneService: { + list: vi.fn(async () => [lane]), + getLaneBaseAndBranch: vi.fn(() => ({ baseRef: "main", branchRef: "feature/pr-80", worktreePath: lane.worktreePath, laneType: "worktree" })), + }, + agentChatService: { createSession, sendMessage, previewSessionToolNames }, + sessionService: { updateMeta }, + issueInventoryService, + }; + + return { lane, pr, deps, createSession, sendMessage, previewSessionToolNames, updateMeta }; + } + + it("previews the exact first prompt without creating a chat session", async () => { + const { deps, createSession, sendMessage, pr } = makeDeps(); + + const result = await previewPrIssueResolutionPrompt(deps as any, { + prId: pr.id, + scope: "checks", + modelId: "openai/gpt-5.4-codex", + reasoning: "high", + permissionMode: "guarded_edit", + additionalInstructions: "Keep commits tight and rerun focused tests first.", + }); + + expect(result.title).toBe("Resolve PR #80 issues"); + expect(result.prompt).toContain("Keep commits tight and rerun focused tests first."); + expect(createSession).not.toHaveBeenCalled(); + expect(sendMessage).not.toHaveBeenCalled(); + }); + + it("uses ADE CLI PR commands in the prompt for Codex launches", async () => { + const { deps, pr } = makeDeps(); + + const result = await previewPrIssueResolutionPrompt(deps as any, { + prId: pr.id, + scope: "checks", + modelId: "openai/gpt-5.4-codex", + reasoning: "high", + permissionMode: "guarded_edit", + additionalInstructions: null, + }); + + expect(result.prompt).toContain("Runtime: Codex chat via ADE CLI"); + expect(result.prompt).toContain("ade prs inventory"); + expect(result.prompt).toContain("ade prs comments"); + expect(result.prompt).toContain("ade prs resolve-thread"); + expect(result.prompt).toContain("This runtime can use the ADE CLI"); + expect(result.prompt).toContain("Immediately after that, run `ade prs comments"); + expect(result.prompt).toContain("Treat the refreshed inventory as a triage index"); + expect(result.prompt).toContain("Do not spend your first steps reading local skill docs"); + expect(result.prompt).toContain("instead of reverse-engineering ADE internals"); + expect(result.prompt).not.toContain("prRefreshIssueInventory"); + }); + + it("creates a normal work chat session and sends the composed prompt", async () => { + const { lane, pr, deps, createSession, sendMessage, updateMeta } = makeDeps(); + + const result = await launchPrIssueResolutionChat(deps as any, { + prId: pr.id, + scope: "checks", + modelId: "openai/gpt-5.4-codex", + reasoning: "high", + permissionMode: "guarded_edit", + additionalInstructions: "Run focused tests before full CI.", + }); + + expect(createSession).toHaveBeenCalledWith(expect.objectContaining({ + laneId: lane.id, + provider: "codex", + model: "gpt-5.4", + modelId: "openai/gpt-5.4-codex", + surface: "work", + sessionProfile: "workflow", + permissionMode: "default", + })); + expect(updateMeta).toHaveBeenCalledWith({ sessionId: "session-1", title: "Resolve PR #80 issues" }); + expect(sendMessage).toHaveBeenCalledWith(expect.objectContaining({ + sessionId: "session-1", + displayText: "Resolve PR #80 issues", + text: expect.stringContaining("Run focused tests before full CI."), + executionMode: "parallel", + })); + expect(result).toEqual({ + sessionId: "session-1", + laneId: lane.id, + href: `/work?laneId=${encodeURIComponent(lane.id)}&sessionId=session-1`, + }); + }); + + it("fails fast when an API workflow chat does not expose required PR tools", async () => { + const { deps, pr, createSession, sendMessage } = makeDeps(); + deps.agentChatService.previewSessionToolNames = vi.fn(() => ["prGetChecks"]); + + await expect(launchPrIssueResolutionChat(deps as any, { + prId: pr.id, + scope: "checks", + modelId: "opencode/openai/gpt-5.4", + reasoning: "high", + permissionMode: "guarded_edit", + additionalInstructions: null, + })).rejects.toThrow("PR issue resolver requires ADE PR tools"); + + expect(createSession).not.toHaveBeenCalled(); + expect(sendMessage).not.toHaveBeenCalled(); + }); + + it("rejects checks scope while checks are still running", async () => { + const runningCheck: PrCheck = { name: "ci / unit", status: "in_progress", conclusion: "failure", detailsUrl: null, startedAt: null, completedAt: null }; + const { pr, deps } = makeDeps({ checks: [runningCheck] }); + + await expect(launchPrIssueResolutionChat(deps as any, { + prId: pr.id, + scope: "checks", + modelId: "openai/gpt-5.4-codex", + })).rejects.toThrow("Failing checks are not currently actionable"); + }); + + it("launches Claude SDK resolver chats with subagents execution mode and detailed issue context", async () => { + const lane = makeLane(); + const pr = makePr(); + const createSession = vi.fn(async () => ({ id: "session-claude" })); + const sendMessage = vi.fn(async () => undefined); + + const deps = { + prService: { + listAll: () => [pr], + getDetail: vi.fn(async () => makeDetail()), + getFiles: vi.fn(async () => [] as PrFile[]), + getChecks: vi.fn(async () => [] as PrCheck[]), + getActionRuns: vi.fn(async () => [] as PrActionRun[]), + getReviewThreads: vi.fn(async () => [ + { + id: "thread-claude-1", + isResolved: false, + isOutdated: false, + path: "src/claude.ts", + line: 12, + originalLine: 12, + startLine: null, + originalStartLine: null, + diffSide: "RIGHT", + url: "https://example.com/thread/claude-1", + createdAt: null, + updatedAt: null, + comments: [ + { + id: "comment-claude-1", + author: "reviewer", + authorAvatarUrl: null, + body: "Please explain why this retry loop is safe.", + url: "https://example.com/comment/claude-1", + createdAt: null, + updatedAt: null, + }, + ], + } satisfies PrReviewThread, + ]), + getComments: vi.fn(async () => []), + } as any, + laneService: { + list: vi.fn(async () => [lane]), + getLaneBaseAndBranch: vi.fn(() => ({ baseRef: "main", branchRef: "feature/pr-80", worktreePath: lane.worktreePath, laneType: "worktree" })), + }, + agentChatService: { + createSession, + sendMessage, + previewSessionToolNames: vi.fn(() => WORKFLOW_PR_TOOL_NAMES), + }, + sessionService: { updateMeta: vi.fn() }, + issueInventoryService: { + syncFromPrData: vi.fn(() => ({ + items: [], + convergence: { currentRound: 0, status: "idle" }, + })), + getNewItems: vi.fn(() => []), + markSentToAgent: vi.fn(), + }, + }; + + await launchPrIssueResolutionChat(deps as any, { + prId: pr.id, + scope: "comments", + modelId: "anthropic/claude-sonnet-4-6", + reasoning: "high", + permissionMode: "guarded_edit", + additionalInstructions: null, + }); + + expect(sendMessage).toHaveBeenCalledWith(expect.objectContaining({ + sessionId: "session-claude", + executionMode: "subagents", + text: expect.stringContaining("Runtime: Claude chat via ADE CLI"), + })); + expect(sendMessage).toHaveBeenCalledWith(expect.objectContaining({ + text: expect.stringContaining("Current unresolved review threads (detailed context)"), + })); + expect(sendMessage).toHaveBeenCalledWith(expect.objectContaining({ + text: expect.stringContaining("Please explain why this retry loop is safe."), + })); + }); +}); + +// --------------------------------------------------------------------------- +// New: formatInventoryItemsSummary (tested via buildPrIssueResolutionPrompt +// with inventoryItems) +// --------------------------------------------------------------------------- + +function makeInventoryItem(overrides: Partial = {}): IssueInventoryItem { + return { + id: "inv-1", + prId: "pr-80", + source: "human", + type: "review_thread", + externalId: "thread:thread-1", + state: "new", + round: 0, + filePath: "src/main.ts", + line: 42, + severity: "major", + headline: "Fix the null check", + body: "This will crash at runtime.", + author: "reviewer", + url: "https://example.com/thread/1", + dismissReason: null, + agentSessionId: null, + createdAt: "2026-03-23T12:00:00.000Z", + updatedAt: "2026-03-23T12:00:00.000Z", + ...overrides, + }; +} + +function makeBasePromptArgs(overrides: Record = {}) { + return { + pr: makePr(), + lane: makeLane({ worktreePath: "/tmp/lane-pr-80" }), + detail: makeDetail(), + files: [], + checks: [ + { name: "ci / unit", status: "completed" as const, conclusion: "failure" as const, detailsUrl: null, startedAt: null, completedAt: null }, + ], + actionRuns: [], + reviewThreads: [], + issueComments: [], + scope: "both" as const, + additionalInstructions: null, + recentCommits: [], + ...overrides, + }; +} + +describe("buildPrIssueResolutionPrompt — inventory items", () => { + it("formats inventory items with severity, location, source, and author", () => { + const prompt = buildPrIssueResolutionPrompt(makeBasePromptArgs({ + inventoryItems: [ + makeInventoryItem({ + id: "inv-1", + source: "coderabbit", + severity: "major", + filePath: "src/prs.ts", + line: 55, + headline: "Handle the loading state", + externalId: "thread:t-1", + author: "coderabbitai", + url: "https://example.com/thread/t-1", + }), + makeInventoryItem({ + id: "inv-2", + type: "check_failure", + source: "unknown", + severity: null, + filePath: null, + line: null, + headline: 'CI check "ci / lint" failing', + externalId: "check:ci / lint", + author: null, + url: "https://example.com/check/1", + }), + ], + })); + + // Should use inventory section instead of raw threads/checks + expect(prompt).toContain("Current issues to address (from inventory"); + expect(prompt).toContain("[Major] Thread thread:t-1 at src/prs.ts:55"); + expect(prompt).toContain("source: coderabbit"); + expect(prompt).toContain("author: coderabbitai"); + expect(prompt).toContain("Summary: Handle the loading state"); + expect(prompt).toContain("Reference: https://example.com/thread/t-1"); + + // Check failure formatting + expect(prompt).toContain('Check check:ci / lint at unknown location'); + expect(prompt).toContain('Summary: CI check "ci / lint" failing'); + + // Should NOT contain the raw threads/checks sections + expect(prompt).not.toContain("Current failing checks"); + expect(prompt).not.toContain("Current unresolved review threads (summaries + references)"); + }); + + it("shows 'no new inventory items' when all items are non-new", () => { + const prompt = buildPrIssueResolutionPrompt(makeBasePromptArgs({ + inventoryItems: [ + makeInventoryItem({ state: "fixed" }), + makeInventoryItem({ id: "inv-2", state: "dismissed" }), + ], + })); + + expect(prompt).toContain("No new inventory items to address."); + }); + + it("falls back to raw threads/checks when inventoryItems is null", () => { + const prompt = buildPrIssueResolutionPrompt(makeBasePromptArgs({ + inventoryItems: null, + })); + + expect(prompt).toContain("Current failing checks"); + expect(prompt).toContain("Current unresolved review threads"); + expect(prompt).not.toContain("from inventory"); + }); + + it("falls back to raw threads/checks when inventoryItems is empty array", () => { + const prompt = buildPrIssueResolutionPrompt(makeBasePromptArgs({ + inventoryItems: [], + })); + + expect(prompt).toContain("Current failing checks"); + expect(prompt).not.toContain("from inventory"); + }); +}); + +describe("buildPrIssueResolutionPrompt — round and previouslyHandled", () => { + it("includes round number in PR context", () => { + const prompt = buildPrIssueResolutionPrompt(makeBasePromptArgs({ + round: 3, + })); + + expect(prompt).toContain("Resolution round: 3"); + }); + + it("does not include round line when round is null", () => { + const prompt = buildPrIssueResolutionPrompt(makeBasePromptArgs({ + round: null, + })); + + expect(prompt).not.toContain("Resolution round:"); + }); + + it("renders Previous rounds section with fixed and dismissed counts", () => { + const prompt = buildPrIssueResolutionPrompt(makeBasePromptArgs({ + round: 3, + previouslyHandled: { + fixedCount: 4, + dismissedCount: 2, + escalatedCount: 1, + fixedHeadlines: ["Fix null check", "Handle edge case", "Update imports"], + dismissedHeadlines: ["Cosmetic nit"], + }, + })); + + expect(prompt).toContain("Previous rounds"); + expect(prompt).toContain("Fixed 4 issues, dismissed 2, escalated 1"); + expect(prompt).toContain("Fixed: Fix null check, Handle edge case, Update imports"); + expect(prompt).toContain("Dismissed: Cosmetic nit"); + expect(prompt).toContain("Do not re-address items that are already fixed or dismissed"); + }); + + it("omits Previous rounds when all counts are zero", () => { + const prompt = buildPrIssueResolutionPrompt(makeBasePromptArgs({ + round: 1, + previouslyHandled: { + fixedCount: 0, + dismissedCount: 0, + escalatedCount: 0, + fixedHeadlines: [], + dismissedHeadlines: [], + }, + })); + + expect(prompt).not.toContain("Previous rounds"); + }); + + it("omits Previous rounds when previouslyHandled is null", () => { + const prompt = buildPrIssueResolutionPrompt(makeBasePromptArgs({ + round: 2, + previouslyHandled: null, + })); + + expect(prompt).not.toContain("Previous rounds"); + }); + + it("uses incremental goal text for round > 1", () => { + const prompt = buildPrIssueResolutionPrompt(makeBasePromptArgs({ + round: 2, + })); + + expect(prompt).toContain("This is continuation round 2"); + expect(prompt).toContain("Focus on the remaining NEW issues"); + expect(prompt).toContain("Do not re-address items from prior rounds"); + }); + + it("uses standard goal text for round 1", () => { + const prompt = buildPrIssueResolutionPrompt(makeBasePromptArgs({ + round: 1, + })); + + expect(prompt).toContain("Get the selected PR issue scope"); + expect(prompt).not.toContain("continuation round"); + }); + + it("uses standard goal text when round is not provided", () => { + const prompt = buildPrIssueResolutionPrompt(makeBasePromptArgs()); + + expect(prompt).toContain("Get the selected PR issue scope"); + expect(prompt).not.toContain("continuation round"); + }); + + it("truncates fixedHeadlines to 8 items", () => { + const prompt = buildPrIssueResolutionPrompt(makeBasePromptArgs({ + round: 3, + previouslyHandled: { + fixedCount: 10, + dismissedCount: 0, + escalatedCount: 0, + fixedHeadlines: ["H1", "H2", "H3", "H4", "H5", "H6", "H7", "H8", "H9", "H10"], + dismissedHeadlines: [], + }, + })); + + expect(prompt).toContain("Fixed: H1, H2, H3, H4, H5, H6, H7, H8"); + expect(prompt).not.toContain("H9"); + expect(prompt).not.toContain("H10"); + }); +}); diff --git a/apps/desktop/src/main/services/prs/prIssueResolver.test.ts b/apps/desktop/src/main/services/prs/prIssueResolver.test.ts deleted file mode 100644 index c09889f18..000000000 --- a/apps/desktop/src/main/services/prs/prIssueResolver.test.ts +++ /dev/null @@ -1,749 +0,0 @@ -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; -import { describe, expect, it, vi } from "vitest"; -import type { IssueInventoryItem, LaneSummary, PrActionRun, PrCheck, PrDetail, PrFile, PrReviewThread, PrSummary } from "../../../shared/types"; -import { buildPrIssueResolutionPrompt, launchPrIssueResolutionChat, previewPrIssueResolutionPrompt } from "./prIssueResolver"; - -function makeLane(overrides: Partial = {}): LaneSummary { - return { - id: "lane-1", - name: "feature/pr-80", - description: "Tighten the PR workflow lane.", - laneType: "worktree", - baseRef: "main", - branchRef: "feature/pr-80", - worktreePath: overrides.worktreePath ?? fs.mkdtempSync(path.join(os.tmpdir(), "ade-pr-issue-lane-")), - attachedRootPath: null, - parentLaneId: null, - childCount: 0, - stackDepth: 0, - parentStatus: null, - isEditProtected: false, - status: { dirty: false, ahead: 0, behind: 0, remoteBehind: -1, rebaseInProgress: false }, - color: null, - icon: null, - tags: [], - folder: null, - createdAt: "2026-03-23T12:00:00.000Z", - archivedAt: null, - ...overrides, - }; -} - -function makePr(overrides: Partial = {}): PrSummary { - return { - id: "pr-80", - laneId: "lane-1", - projectId: "project-1", - repoOwner: "ade-dev", - repoName: "ade", - githubPrNumber: 80, - githubUrl: "https://github.com/ade-dev/ade/pull/80", - githubNodeId: "PR_kwDOExample", - title: "Stabilize GitHub PR flows", - state: "open", - baseBranch: "main", - headBranch: "feature/pr-80", - checksStatus: "failing", - reviewStatus: "changes_requested", - additions: 25, - deletions: 8, - lastSyncedAt: "2026-03-23T12:00:00.000Z", - createdAt: "2026-03-23T11:00:00.000Z", - updatedAt: "2026-03-23T12:00:00.000Z", - ...overrides, - }; -} - -function makeDetail(overrides: Partial = {}): PrDetail { - return { - prId: "pr-80", - body: "This PR makes the GitHub PR detail view more reliable.", - labels: [], - assignees: [], - requestedReviewers: [], - author: { login: "octocat", avatarUrl: null }, - isDraft: false, - milestone: null, - linkedIssues: [], - ...overrides, - }; -} - -const WORKFLOW_PR_TOOL_NAMES = [ - "prRefreshIssueInventory", - "prGetReviewComments", - "prRerunFailedChecks", - "prReplyToReviewThread", - "prResolveReviewThread", -]; - -describe("buildPrIssueResolutionPrompt", () => { - it("includes scope, issue inventory, extra instructions, and regression guidance", () => { - const prompt = buildPrIssueResolutionPrompt({ - pr: makePr(), - lane: makeLane({ worktreePath: "/tmp/lane-pr-80" }), - detail: makeDetail(), - files: [ - { filename: "src/prs.ts", status: "modified", additions: 10, deletions: 2, patch: null, previousFilename: null }, - ], - checks: [ - { name: "ci / unit", status: "completed", conclusion: "failure", detailsUrl: "https://example.com/check", startedAt: null, completedAt: null }, - ], - actionRuns: [ - { - id: 71, - name: "CI", - status: "completed", - conclusion: "failure", - headSha: "abc123", - htmlUrl: "https://example.com/run/71", - createdAt: "2026-03-23T12:00:00.000Z", - updatedAt: "2026-03-23T12:10:00.000Z", - jobs: [ - { - id: 81, - name: "test", - status: "completed", - conclusion: "failure", - startedAt: null, - completedAt: null, - steps: [ - { name: "vitest", status: "completed", conclusion: "failure", number: 1, startedAt: null, completedAt: null }, - ], - }, - ], - } satisfies PrActionRun, - ], - reviewThreads: [ - { - id: "thread-1", - isResolved: false, - isOutdated: false, - path: "src/prs.ts", - line: 42, - originalLine: 42, - startLine: null, - originalStartLine: null, - diffSide: "RIGHT", - url: "https://example.com/thread/1", - createdAt: "2026-03-23T12:00:00.000Z", - updatedAt: "2026-03-23T12:05:00.000Z", - comments: [ - { - id: "comment-1", - author: "reviewer", - authorAvatarUrl: null, - body: "Please handle the loading state here.", - url: "https://example.com/comment/1", - createdAt: "2026-03-23T12:00:00.000Z", - updatedAt: "2026-03-23T12:00:00.000Z", - }, - ], - } satisfies PrReviewThread, - ], - issueComments: [ - { - id: "issue-comment-1", - author: "coderabbitai[bot]", - authorAvatarUrl: null, - body: "Consider simplifying this branch.", - source: "issue", - url: "https://example.com/issue-comment/1", - path: null, - line: null, - createdAt: "2026-03-23T12:00:00.000Z", - updatedAt: "2026-03-23T12:00:00.000Z", - }, - ], - scope: "both", - additionalInstructions: "Please keep the PR description accurate if behavior changes.", - recentCommits: [{ sha: "abcdef123456", subject: "Refine PR detail header" }], - }); - - expect(prompt).toContain("Selected scope: checks and review comments"); - expect(prompt).toContain("ADE PR id (for ADE tools): pr-80"); - expect(prompt).toContain("Runtime: Workflow chat with ADE PR tools"); - expect(prompt).toContain("Please keep the PR description accurate if behavior changes."); - expect(prompt).toContain("Watch carefully for regressions caused by your fixes."); - expect(prompt).toContain("update the test"); - expect(prompt).toContain("rerun the complete failing test files or suites locally"); - expect(prompt).toContain("one bounded Path to Merge resolution round"); - expect(prompt).toContain("ADE will poll GitHub"); - expect(prompt).toContain("Commit the changes and push the PR branch before you stop."); - expect(prompt).toContain("If you cannot safely commit or push the necessary changes"); - expect(prompt).toContain("prRefreshIssueInventory"); - expect(prompt).toContain("prGetReviewComments"); - expect(prompt).toContain("thread-1"); - expect(prompt).toContain("ci / unit"); - }); - - it("compresses review-thread bodies into references and filters noisy advisory comments", () => { - const prompt = buildPrIssueResolutionPrompt({ - pr: makePr({ title: "fix codex chat" }), - lane: makeLane({ worktreePath: "/tmp/lane-pr-80" }), - detail: makeDetail({ - body: "\n## Summary by CodeRabbit\nHuge autogenerated summary", - }), - files: [], - checks: [], - actionRuns: [], - reviewThreads: [ - { - id: "thread-1", - isResolved: false, - isOutdated: false, - path: "apps/desktop/src/renderer/components/chat/AgentChatPane.tsx", - line: 551, - originalLine: 551, - startLine: null, - originalStartLine: null, - diffSide: "RIGHT", - url: "https://example.com/thread/1", - createdAt: null, - updatedAt: null, - comments: [ - { - id: "comment-1", - author: "coderabbitai", - authorAvatarUrl: null, - body: "_⚠️ Potential issue_ | _🟡 Minor_\n\n**Derive `assistantLabel` from the effective provider.**\n\nThis can drift from the model that will actually run.\n\n
PromptVery long autogenerated block
", - url: "https://example.com/comment/1", - createdAt: null, - updatedAt: null, - }, - ], - } satisfies PrReviewThread, - ], - issueComments: [ - { - id: "issue-comment-1", - author: "coderabbitai[bot]", - authorAvatarUrl: null, - body: " giant summary", - source: "issue", - url: "https://example.com/issue-comment/1", - path: null, - line: null, - createdAt: null, - updatedAt: null, - }, - { - id: "issue-comment-2", - author: "vercel[bot]", - authorAvatarUrl: null, - body: "[vc]: deployment details", - source: "issue", - url: "https://example.com/issue-comment/2", - path: null, - line: null, - createdAt: null, - updatedAt: null, - }, - ], - scope: "comments", - additionalInstructions: null, - recentCommits: [], - }); - - expect(prompt).toContain("Changed test files / likely hotspots"); - expect(prompt).toContain("No changed test files detected in this PR."); - expect(prompt).toContain("Current unresolved review threads (summaries + references)"); - expect(prompt).toContain("Summary: Derive assistantLabel from the effective provider."); - expect(prompt).toContain("Reference: https://example.com/thread/1"); - expect(prompt).not.toContain("Very long autogenerated block"); - expect(prompt).not.toContain("giant summary"); - expect(prompt).not.toContain("deployment details"); - expect(prompt).not.toContain("Huge autogenerated summary"); - expect(prompt).toContain("prResolveReviewThread"); - expect(prompt).toContain("If you are running outside ADE, use the linked GitHub thread/check URLs"); - }); - - it("highlights changed test files as likely hotspots", () => { - const prompt = buildPrIssueResolutionPrompt({ - pr: makePr(), - lane: makeLane({ worktreePath: "/tmp/lane-pr-80" }), - detail: makeDetail(), - files: [ - { filename: "apps/desktop/src/renderer/components/chat/AgentChatMessageList.test.tsx", status: "modified", additions: 525, deletions: 8, patch: null, previousFilename: null }, - { filename: "apps/desktop/src/main/services/chat/chatTextBatching.test.ts", status: "added", additions: 113, deletions: 0, patch: null, previousFilename: null }, - { filename: "apps/desktop/src/renderer/components/chat/AgentChatPane.tsx", status: "modified", additions: 10, deletions: 2, patch: null, previousFilename: null }, - ], - checks: [], - actionRuns: [], - reviewThreads: [], - issueComments: [], - scope: "checks", - additionalInstructions: null, - recentCommits: [], - }); - - expect(prompt).toContain("Changed test files / likely hotspots"); - expect(prompt).toContain("heavily modified test file: apps/desktop/src/renderer/components/chat/AgentChatMessageList.test.tsx (+525/-8)"); - expect(prompt).toContain("new test file: apps/desktop/src/main/services/chat/chatTextBatching.test.ts (+113/-0)"); - expect(prompt).toContain("Treat newly added or heavily modified test files as likely regression hotspots"); - }); -}); - -describe("launchPrIssueResolutionChat", () => { - const failingCheck: PrCheck = { name: "ci / unit", status: "completed", conclusion: "failure", detailsUrl: null, startedAt: null, completedAt: null }; - - function makeDeps(overrides: { checks?: PrCheck[] } = {}) { - const lane = makeLane(); - const pr = makePr(); - const createSession = vi.fn(async () => ({ id: "session-1" })); - const sendMessage = vi.fn(async () => undefined); - const previewSessionToolNames = vi.fn(() => WORKFLOW_PR_TOOL_NAMES); - const updateMeta = vi.fn(); - - const issueInventoryService = { - syncFromPrData: vi.fn(() => ({ - items: [], - convergence: { currentRound: 0, status: "idle" }, - })), - getNewItems: vi.fn(() => []), - markSentToAgent: vi.fn(), - }; - - const deps = { - prService: { - listAll: () => [pr], - getDetail: vi.fn(async () => makeDetail()), - getFiles: vi.fn(async () => [] as PrFile[]), - getChecks: vi.fn(async () => overrides.checks ?? [failingCheck]), - getActionRuns: vi.fn(async () => [] as PrActionRun[]), - getReviewThreads: vi.fn(async () => [] as PrReviewThread[]), - getComments: vi.fn(async () => []), - } as any, - laneService: { - list: vi.fn(async () => [lane]), - getLaneBaseAndBranch: vi.fn(() => ({ baseRef: "main", branchRef: "feature/pr-80", worktreePath: lane.worktreePath, laneType: "worktree" })), - }, - agentChatService: { createSession, sendMessage, previewSessionToolNames }, - sessionService: { updateMeta }, - issueInventoryService, - }; - - return { lane, pr, deps, createSession, sendMessage, previewSessionToolNames, updateMeta }; - } - - it("previews the exact first prompt without creating a chat session", async () => { - const { deps, createSession, sendMessage, pr } = makeDeps(); - - const result = await previewPrIssueResolutionPrompt(deps as any, { - prId: pr.id, - scope: "checks", - modelId: "openai/gpt-5.4-codex", - reasoning: "high", - permissionMode: "guarded_edit", - additionalInstructions: "Keep commits tight and rerun focused tests first.", - }); - - expect(result.title).toBe("Resolve PR #80 issues"); - expect(result.prompt).toContain("Keep commits tight and rerun focused tests first."); - expect(createSession).not.toHaveBeenCalled(); - expect(sendMessage).not.toHaveBeenCalled(); - }); - - it("uses ADE CLI PR commands in the prompt for Codex launches", async () => { - const { deps, pr } = makeDeps(); - - const result = await previewPrIssueResolutionPrompt(deps as any, { - prId: pr.id, - scope: "checks", - modelId: "openai/gpt-5.4-codex", - reasoning: "high", - permissionMode: "guarded_edit", - additionalInstructions: null, - }); - - expect(result.prompt).toContain("Runtime: Codex chat via ADE CLI"); - expect(result.prompt).toContain("ade prs inventory"); - expect(result.prompt).toContain("ade prs comments"); - expect(result.prompt).toContain("ade prs resolve-thread"); - expect(result.prompt).toContain("This runtime can use the ADE CLI"); - expect(result.prompt).toContain("Immediately after that, run `ade prs comments"); - expect(result.prompt).toContain("Treat the refreshed inventory as a triage index"); - expect(result.prompt).toContain("Do not spend your first steps reading local skill docs"); - expect(result.prompt).toContain("instead of reverse-engineering ADE internals"); - expect(result.prompt).not.toContain("prRefreshIssueInventory"); - }); - - it("creates a normal work chat session and sends the composed prompt", async () => { - const { lane, pr, deps, createSession, sendMessage, updateMeta } = makeDeps(); - - const result = await launchPrIssueResolutionChat(deps as any, { - prId: pr.id, - scope: "checks", - modelId: "openai/gpt-5.4-codex", - reasoning: "high", - permissionMode: "guarded_edit", - additionalInstructions: "Run focused tests before full CI.", - }); - - expect(createSession).toHaveBeenCalledWith(expect.objectContaining({ - laneId: lane.id, - provider: "codex", - model: "gpt-5.4", - modelId: "openai/gpt-5.4-codex", - surface: "work", - sessionProfile: "workflow", - permissionMode: "default", - })); - expect(updateMeta).toHaveBeenCalledWith({ sessionId: "session-1", title: "Resolve PR #80 issues" }); - expect(sendMessage).toHaveBeenCalledWith(expect.objectContaining({ - sessionId: "session-1", - displayText: "Resolve PR #80 issues", - text: expect.stringContaining("Run focused tests before full CI."), - executionMode: "parallel", - })); - expect(result).toEqual({ - sessionId: "session-1", - laneId: lane.id, - href: `/work?laneId=${encodeURIComponent(lane.id)}&sessionId=session-1`, - }); - }); - - it("fails fast when an API workflow chat does not expose required PR tools", async () => { - const { deps, pr, createSession, sendMessage } = makeDeps(); - deps.agentChatService.previewSessionToolNames = vi.fn(() => ["prGetChecks"]); - - await expect(launchPrIssueResolutionChat(deps as any, { - prId: pr.id, - scope: "checks", - modelId: "opencode/openai/gpt-5.4", - reasoning: "high", - permissionMode: "guarded_edit", - additionalInstructions: null, - })).rejects.toThrow("PR issue resolver requires ADE PR tools"); - - expect(createSession).not.toHaveBeenCalled(); - expect(sendMessage).not.toHaveBeenCalled(); - }); - - it("rejects checks scope while checks are still running", async () => { - const runningCheck: PrCheck = { name: "ci / unit", status: "in_progress", conclusion: "failure", detailsUrl: null, startedAt: null, completedAt: null }; - const { pr, deps } = makeDeps({ checks: [runningCheck] }); - - await expect(launchPrIssueResolutionChat(deps as any, { - prId: pr.id, - scope: "checks", - modelId: "openai/gpt-5.4-codex", - })).rejects.toThrow("Failing checks are not currently actionable"); - }); - - it("launches Claude SDK resolver chats with subagents execution mode and detailed issue context", async () => { - const lane = makeLane(); - const pr = makePr(); - const createSession = vi.fn(async () => ({ id: "session-claude" })); - const sendMessage = vi.fn(async () => undefined); - - const deps = { - prService: { - listAll: () => [pr], - getDetail: vi.fn(async () => makeDetail()), - getFiles: vi.fn(async () => [] as PrFile[]), - getChecks: vi.fn(async () => [] as PrCheck[]), - getActionRuns: vi.fn(async () => [] as PrActionRun[]), - getReviewThreads: vi.fn(async () => [ - { - id: "thread-claude-1", - isResolved: false, - isOutdated: false, - path: "src/claude.ts", - line: 12, - originalLine: 12, - startLine: null, - originalStartLine: null, - diffSide: "RIGHT", - url: "https://example.com/thread/claude-1", - createdAt: null, - updatedAt: null, - comments: [ - { - id: "comment-claude-1", - author: "reviewer", - authorAvatarUrl: null, - body: "Please explain why this retry loop is safe.", - url: "https://example.com/comment/claude-1", - createdAt: null, - updatedAt: null, - }, - ], - } satisfies PrReviewThread, - ]), - getComments: vi.fn(async () => []), - } as any, - laneService: { - list: vi.fn(async () => [lane]), - getLaneBaseAndBranch: vi.fn(() => ({ baseRef: "main", branchRef: "feature/pr-80", worktreePath: lane.worktreePath, laneType: "worktree" })), - }, - agentChatService: { - createSession, - sendMessage, - previewSessionToolNames: vi.fn(() => WORKFLOW_PR_TOOL_NAMES), - }, - sessionService: { updateMeta: vi.fn() }, - issueInventoryService: { - syncFromPrData: vi.fn(() => ({ - items: [], - convergence: { currentRound: 0, status: "idle" }, - })), - getNewItems: vi.fn(() => []), - markSentToAgent: vi.fn(), - }, - }; - - await launchPrIssueResolutionChat(deps as any, { - prId: pr.id, - scope: "comments", - modelId: "anthropic/claude-sonnet-4-6", - reasoning: "high", - permissionMode: "guarded_edit", - additionalInstructions: null, - }); - - expect(sendMessage).toHaveBeenCalledWith(expect.objectContaining({ - sessionId: "session-claude", - executionMode: "subagents", - text: expect.stringContaining("Runtime: Claude chat via ADE CLI"), - })); - expect(sendMessage).toHaveBeenCalledWith(expect.objectContaining({ - text: expect.stringContaining("Current unresolved review threads (detailed context)"), - })); - expect(sendMessage).toHaveBeenCalledWith(expect.objectContaining({ - text: expect.stringContaining("Please explain why this retry loop is safe."), - })); - }); -}); - -// --------------------------------------------------------------------------- -// New: formatInventoryItemsSummary (tested via buildPrIssueResolutionPrompt -// with inventoryItems) -// --------------------------------------------------------------------------- - -function makeInventoryItem(overrides: Partial = {}): IssueInventoryItem { - return { - id: "inv-1", - prId: "pr-80", - source: "human", - type: "review_thread", - externalId: "thread:thread-1", - state: "new", - round: 0, - filePath: "src/main.ts", - line: 42, - severity: "major", - headline: "Fix the null check", - body: "This will crash at runtime.", - author: "reviewer", - url: "https://example.com/thread/1", - dismissReason: null, - agentSessionId: null, - createdAt: "2026-03-23T12:00:00.000Z", - updatedAt: "2026-03-23T12:00:00.000Z", - ...overrides, - }; -} - -function makeBasePromptArgs(overrides: Record = {}) { - return { - pr: makePr(), - lane: makeLane({ worktreePath: "/tmp/lane-pr-80" }), - detail: makeDetail(), - files: [], - checks: [ - { name: "ci / unit", status: "completed" as const, conclusion: "failure" as const, detailsUrl: null, startedAt: null, completedAt: null }, - ], - actionRuns: [], - reviewThreads: [], - issueComments: [], - scope: "both" as const, - additionalInstructions: null, - recentCommits: [], - ...overrides, - }; -} - -describe("buildPrIssueResolutionPrompt — inventory items", () => { - it("formats inventory items with severity, location, source, and author", () => { - const prompt = buildPrIssueResolutionPrompt(makeBasePromptArgs({ - inventoryItems: [ - makeInventoryItem({ - id: "inv-1", - source: "coderabbit", - severity: "major", - filePath: "src/prs.ts", - line: 55, - headline: "Handle the loading state", - externalId: "thread:t-1", - author: "coderabbitai", - url: "https://example.com/thread/t-1", - }), - makeInventoryItem({ - id: "inv-2", - type: "check_failure", - source: "unknown", - severity: null, - filePath: null, - line: null, - headline: 'CI check "ci / lint" failing', - externalId: "check:ci / lint", - author: null, - url: "https://example.com/check/1", - }), - ], - })); - - // Should use inventory section instead of raw threads/checks - expect(prompt).toContain("Current issues to address (from inventory"); - expect(prompt).toContain("[Major] Thread thread:t-1 at src/prs.ts:55"); - expect(prompt).toContain("source: coderabbit"); - expect(prompt).toContain("author: coderabbitai"); - expect(prompt).toContain("Summary: Handle the loading state"); - expect(prompt).toContain("Reference: https://example.com/thread/t-1"); - - // Check failure formatting - expect(prompt).toContain('Check check:ci / lint at unknown location'); - expect(prompt).toContain('Summary: CI check "ci / lint" failing'); - - // Should NOT contain the raw threads/checks sections - expect(prompt).not.toContain("Current failing checks"); - expect(prompt).not.toContain("Current unresolved review threads (summaries + references)"); - }); - - it("shows 'no new inventory items' when all items are non-new", () => { - const prompt = buildPrIssueResolutionPrompt(makeBasePromptArgs({ - inventoryItems: [ - makeInventoryItem({ state: "fixed" }), - makeInventoryItem({ id: "inv-2", state: "dismissed" }), - ], - })); - - expect(prompt).toContain("No new inventory items to address."); - }); - - it("falls back to raw threads/checks when inventoryItems is null", () => { - const prompt = buildPrIssueResolutionPrompt(makeBasePromptArgs({ - inventoryItems: null, - })); - - expect(prompt).toContain("Current failing checks"); - expect(prompt).toContain("Current unresolved review threads"); - expect(prompt).not.toContain("from inventory"); - }); - - it("falls back to raw threads/checks when inventoryItems is empty array", () => { - const prompt = buildPrIssueResolutionPrompt(makeBasePromptArgs({ - inventoryItems: [], - })); - - expect(prompt).toContain("Current failing checks"); - expect(prompt).not.toContain("from inventory"); - }); -}); - -describe("buildPrIssueResolutionPrompt — round and previouslyHandled", () => { - it("includes round number in PR context", () => { - const prompt = buildPrIssueResolutionPrompt(makeBasePromptArgs({ - round: 3, - })); - - expect(prompt).toContain("Resolution round: 3"); - }); - - it("does not include round line when round is null", () => { - const prompt = buildPrIssueResolutionPrompt(makeBasePromptArgs({ - round: null, - })); - - expect(prompt).not.toContain("Resolution round:"); - }); - - it("renders Previous rounds section with fixed and dismissed counts", () => { - const prompt = buildPrIssueResolutionPrompt(makeBasePromptArgs({ - round: 3, - previouslyHandled: { - fixedCount: 4, - dismissedCount: 2, - escalatedCount: 1, - fixedHeadlines: ["Fix null check", "Handle edge case", "Update imports"], - dismissedHeadlines: ["Cosmetic nit"], - }, - })); - - expect(prompt).toContain("Previous rounds"); - expect(prompt).toContain("Fixed 4 issues, dismissed 2, escalated 1"); - expect(prompt).toContain("Fixed: Fix null check, Handle edge case, Update imports"); - expect(prompt).toContain("Dismissed: Cosmetic nit"); - expect(prompt).toContain("Do not re-address items that are already fixed or dismissed"); - }); - - it("omits Previous rounds when all counts are zero", () => { - const prompt = buildPrIssueResolutionPrompt(makeBasePromptArgs({ - round: 1, - previouslyHandled: { - fixedCount: 0, - dismissedCount: 0, - escalatedCount: 0, - fixedHeadlines: [], - dismissedHeadlines: [], - }, - })); - - expect(prompt).not.toContain("Previous rounds"); - }); - - it("omits Previous rounds when previouslyHandled is null", () => { - const prompt = buildPrIssueResolutionPrompt(makeBasePromptArgs({ - round: 2, - previouslyHandled: null, - })); - - expect(prompt).not.toContain("Previous rounds"); - }); - - it("uses incremental goal text for round > 1", () => { - const prompt = buildPrIssueResolutionPrompt(makeBasePromptArgs({ - round: 2, - })); - - expect(prompt).toContain("This is continuation round 2"); - expect(prompt).toContain("Focus on the remaining NEW issues"); - expect(prompt).toContain("Do not re-address items from prior rounds"); - }); - - it("uses standard goal text for round 1", () => { - const prompt = buildPrIssueResolutionPrompt(makeBasePromptArgs({ - round: 1, - })); - - expect(prompt).toContain("Get the selected PR issue scope"); - expect(prompt).not.toContain("continuation round"); - }); - - it("uses standard goal text when round is not provided", () => { - const prompt = buildPrIssueResolutionPrompt(makeBasePromptArgs()); - - expect(prompt).toContain("Get the selected PR issue scope"); - expect(prompt).not.toContain("continuation round"); - }); - - it("truncates fixedHeadlines to 8 items", () => { - const prompt = buildPrIssueResolutionPrompt(makeBasePromptArgs({ - round: 3, - previouslyHandled: { - fixedCount: 10, - dismissedCount: 0, - escalatedCount: 0, - fixedHeadlines: ["H1", "H2", "H3", "H4", "H5", "H6", "H7", "H8", "H9", "H10"], - dismissedHeadlines: [], - }, - })); - - expect(prompt).toContain("Fixed: H1, H2, H3, H4, H5, H6, H7, H8"); - expect(prompt).not.toContain("H9"); - expect(prompt).not.toContain("H10"); - }); -}); diff --git a/apps/desktop/src/main/services/prs/queueLandingService.test.ts b/apps/desktop/src/main/services/prs/prMergeQueue.test.ts similarity index 81% rename from apps/desktop/src/main/services/prs/queueLandingService.test.ts rename to apps/desktop/src/main/services/prs/prMergeQueue.test.ts index 2c29cf451..ba6c1db6b 100644 --- a/apps/desktop/src/main/services/prs/queueLandingService.test.ts +++ b/apps/desktop/src/main/services/prs/prMergeQueue.test.ts @@ -3,9 +3,16 @@ import os from "node:os"; import path from "node:path"; import { spawnSync } from "node:child_process"; import { describe, expect, it, vi } from "vitest"; +import type { LaneSummary } from "../../../shared/types"; import { openKvDb } from "../state/kvDb"; +import { buildIntegrationPreflight, resolveIntegrationBaseLane } from "./integrationPlanning"; +import { hasMergeConflictMarkers, parseGitStatusPorcelain } from "./integrationValidation"; import { createQueueLandingService } from "./queueLandingService"; +// --------------------------------------------------------------------------- +// Shared helpers +// --------------------------------------------------------------------------- + function createLogger() { return { debug: () => {}, @@ -63,6 +70,99 @@ async function seedProject(db: any, projectId: string, repoRoot: string, laneId ); } +function makeLane(id: string, branch: string, overrides: Partial = {}): LaneSummary { + return { + id, + name: branch, + description: null, + laneType: "worktree", + baseRef: "refs/heads/main", + branchRef: `refs/heads/${branch}`, + worktreePath: `/tmp/${id}`, + attachedRootPath: null, + parentLaneId: null, + childCount: 0, + stackDepth: 0, + parentStatus: null, + isEditProtected: false, + status: { dirty: false, ahead: 0, behind: 0, remoteBehind: -1, rebaseInProgress: false }, + color: null, + icon: null, + tags: [], + folder: null, + createdAt: "2026-03-11T00:00:00.000Z", + archivedAt: null, + ...overrides, + }; +} + +// --------------------------------------------------------------------------- +// integrationPlanning — base-lane resolution + preflight dedup +// --------------------------------------------------------------------------- + +describe("integrationPlanning", () => { + it("resolves the base lane from the actual branch lane", () => { + const primary = makeLane("lane-main", "main", { laneType: "primary", baseRef: "refs/heads/main" }); + const child = makeLane("lane-feature", "feature/auth", { baseRef: "refs/heads/main" }); + + expect(resolveIntegrationBaseLane([child, primary], "main")?.id).toBe("lane-main"); + }); + + it("does not treat descendant lanes with matching baseRef as the base lane", () => { + const child = makeLane("lane-feature", "feature/auth", { baseRef: "refs/heads/main" }); + + expect(resolveIntegrationBaseLane([child], "main")).toBeNull(); + }); + + it("deduplicates source lanes and reports missing lanes", () => { + const lanes = [makeLane("lane-a", "feature/a"), makeLane("lane-main", "main", { laneType: "primary" })]; + + expect(buildIntegrationPreflight(lanes, ["lane-a", "lane-a", "lane-missing"], "main")).toEqual({ + baseLane: lanes[1], + uniqueSourceLaneIds: ["lane-a", "lane-missing"], + duplicateSourceLaneIds: ["lane-a"], + missingSourceLaneIds: ["lane-missing"], + }); + }); +}); + +// --------------------------------------------------------------------------- +// integrationValidation — porcelain parsing + conflict marker detection +// --------------------------------------------------------------------------- + +describe("integrationValidation", () => { + it("parses changed and unmerged files from porcelain output", () => { + expect( + parseGitStatusPorcelain([ + "UU src/conflicted.ts", + "M src/modified.ts", + "A src/added.ts", + ].join("\n")), + ).toEqual({ + unmergedPaths: ["src/conflicted.ts"], + changedPaths: ["src/conflicted.ts", "src/modified.ts", "src/added.ts"], + }); + }); + + it("normalizes renamed paths to the new filename", () => { + expect( + parseGitStatusPorcelain("R src/old-name.ts -> src/new-name.ts"), + ).toEqual({ + unmergedPaths: [], + changedPaths: ["src/new-name.ts"], + }); + }); + + it("detects merge conflict markers in file contents", () => { + expect(hasMergeConflictMarkers("<<<<<<< ours\nhello\n=======\nworld\n>>>>>>> theirs\n")).toBe(true); + expect(hasMergeConflictMarkers("function example() { return 'clean'; }\n")).toBe(false); + }); +}); + +// --------------------------------------------------------------------------- +// queueLandingService — ordering, auto-resolve, guard transitions, cancel +// --------------------------------------------------------------------------- + describe("queueLandingService", () => { it("preserves queue member order instead of re-sorting by PR creation time", async () => { const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-queue-order-")); @@ -269,8 +369,6 @@ describe("queueLandingService", () => { ["group-guard", projectId, "Queue Guard", "main", "2026-03-09T00:00:00.000Z"], ); - // First land call fails with a non-merge-conflict error (entry goes to "failed"). - // Second land call succeeds (for the second entry if it gets reached). const land = vi.fn() .mockResolvedValueOnce({ prId: "pr-fail", @@ -319,7 +417,6 @@ describe("queueLandingService", () => { emitEvent: () => {}, }); - // Start the queue; first entry will fail, queue pauses. await service.startQueue({ groupId: "group-guard", method: "squash" }); const paused = await waitFor( () => service.getQueueStateByGroup("group-guard"), @@ -329,11 +426,6 @@ describe("queueLandingService", () => { expect(paused.entries[0]!.state).toBe("failed"); expect(paused.entries[1]!.state).toBe("pending"); - // Set up entries so the loop will encounter a "failed" entry it cannot - // transition to "landing". entry[0] is "landed" (the loop skips it), - // entry[1] is "failed" (guardTransition rejects failed→landing). - // Put currentPosition at 0 so resumeQueue sees entry[0] = "landed" and - // does NOT reset it (resumeQueue only resets failed/paused/resolving/landing). const entriesForGuardTest = paused.entries.map((e, i) => ({ ...e, state: i === 0 ? "landed" : "failed", @@ -343,7 +435,6 @@ describe("queueLandingService", () => { [JSON.stringify(entriesForGuardTest), paused.queueId], ); - // Create a second service instance pointing at the same DB, then resume. const service2 = createQueueLandingService({ db, logger: createLogger(), @@ -369,24 +460,15 @@ describe("queueLandingService", () => { emitEvent: () => {}, }); - // resumeQueue sets the queue to "landing" and launches the loop. - // The loop skips entry[0] (landed), hits entry[1] (failed), and - // guardTransition rejects failed→landing so the loop exits immediately. const resumed = service2.resumeQueue({ queueId: paused.queueId }); expect(resumed).not.toBeNull(); expect(resumed!.state).toBe("landing"); - // The landing loop runs asynchronously. When guardTransition rejects the - // failed→landing transition, the loop returns silently without updating DB - // state — there is no observable state change to poll for. Yield to the - // event loop so the async loop body executes, then verify the invariants. await new Promise((resolve) => setTimeout(resolve, 100)); const finalState = service2.getQueueState(paused.queueId); expect(finalState).not.toBeNull(); - // entry[1] must still be "failed" — guardTransition prevented it from becoming "landing" expect(finalState!.entries[1]!.state).toBe("failed"); - // land was called exactly once (the original failure) — no additional calls expect(land).toHaveBeenCalledTimes(1); }); @@ -401,7 +483,6 @@ describe("queueLandingService", () => { ["group-cancel", projectId, "Queue Cancel", "main", "2026-03-09T00:00:00.000Z"], ); - // Controllable deferred promises so the test drives timing explicitly let resolveSlowLand!: () => void; const slowLandStarted = new Promise((resolve) => { resolveSlowLand = resolve; }); let releaseSlowLand!: () => void; @@ -409,7 +490,6 @@ describe("queueLandingService", () => { const land = vi.fn().mockImplementation(async ({ prId }: { prId: string }) => { if (prId === "pr-slow") { - // Signal that the slow land has started, then wait for the test to release resolveSlowLand(); await slowLandGate; return { @@ -464,27 +544,20 @@ describe("queueLandingService", () => { const queueState = await service.startQueue({ groupId: "group-cancel", method: "squash" }); const cancelQueueId = queueState.queueId; - // Wait for the land mock to actually be entered before cancelling await slowLandStarted; db.run( "update queue_landing_state set state = 'cancelled', completed_at = ? where id = ?", [new Date().toISOString(), cancelQueueId], ); - // Release the slow land so the loop can proceed and notice the cancellation releaseSlowLand(); - // Poll until the service reflects the cancelled state const finalState = await waitFor( () => service.getQueueStateByGroup("group-cancel"), (state) => state.state === "cancelled", ); expect(finalState).not.toBeNull(); - // The queue should be cancelled (as we set it externally). expect(finalState!.state).toBe("cancelled"); - // The second entry (pr-fast) should never have been processed. - // land was called once for pr-slow, but the loop should have bailed - // after noticing the cancellation via isQueueCancelledOrDone(). expect(land).toHaveBeenCalledTimes(1); expect(land.mock.calls[0]![0].prId).toBe("pr-slow"); }); diff --git a/apps/desktop/src/main/services/prs/prRebaseResolver.test.ts b/apps/desktop/src/main/services/prs/prRebase.test.ts similarity index 66% rename from apps/desktop/src/main/services/prs/prRebaseResolver.test.ts rename to apps/desktop/src/main/services/prs/prRebase.test.ts index b53587497..29698b973 100644 --- a/apps/desktop/src/main/services/prs/prRebaseResolver.test.ts +++ b/apps/desktop/src/main/services/prs/prRebase.test.ts @@ -1,9 +1,15 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; -import { afterAll, describe, expect, it, vi } from "vitest"; +import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; import type { LaneSummary, RebaseNeed } from "../../../shared/types"; -import { launchRebaseResolutionChat } from "./prRebaseResolver"; + +// --------------------------------------------------------------------------- +// Module mocks +// --------------------------------------------------------------------------- +// prRebaseResolver imports resolverUtils — we mock it with replica behavior so +// rebase-resolver tests stay decoupled from git. The resolverUtils describe +// block below uses `vi.importActual` to exercise the REAL implementation. vi.mock("./resolverUtils", () => ({ mapPermissionMode: (mode: string | undefined) => { @@ -34,6 +40,160 @@ vi.mock("./resolverUtils", () => ({ }), })); +vi.mock("../git/git", () => ({ + runGit: vi.fn(), +})); + +import { runGit } from "../git/git"; +import { launchRebaseResolutionChat } from "./prRebaseResolver"; + +const mockRunGit = vi.mocked(runGit); + +// --------------------------------------------------------------------------- +// resolverUtils — exercises the REAL module via vi.importActual +// --------------------------------------------------------------------------- + +describe("resolverUtils (real module)", () => { + // Lazy-loaded handles to the real module so the prRebaseResolver mock above + // does not interfere. + let mapPermissionMode: (mode: string | undefined) => string; + let mapPermissionModeForModelFamily: ( + mode: string | undefined, + family: string | undefined, + ) => string; + let readRecentCommits: (worktreePath: string, count?: number, ref?: string) => Promise>; + + beforeAll(async () => { + const real = await vi.importActual("./resolverUtils"); + mapPermissionMode = real.mapPermissionMode; + mapPermissionModeForModelFamily = real.mapPermissionModeForModelFamily; + readRecentCommits = real.readRecentCommits; + }); + + describe("mapPermissionMode", () => { + it("maps full_edit to full-auto", () => { + expect(mapPermissionMode("full_edit")).toBe("full-auto"); + }); + + it("maps read_only to plan", () => { + expect(mapPermissionMode("read_only")).toBe("plan"); + }); + + it("maps guarded_edit to edit", () => { + expect(mapPermissionMode("guarded_edit")).toBe("edit"); + }); + + it("maps undefined to edit", () => { + expect(mapPermissionMode(undefined)).toBe("edit"); + }); + + it("maps an unrecognized value to edit", () => { + expect(mapPermissionMode("some_other_value" as any)).toBe("edit"); + }); + }); + + describe("mapPermissionModeForModelFamily", () => { + it("maps guarded_edit to Codex default permissions for OpenAI CLI models", () => { + expect(mapPermissionModeForModelFamily("guarded_edit", "openai")).toBe("default"); + }); + + it("keeps guarded_edit as edit for non-OpenAI models", () => { + expect(mapPermissionModeForModelFamily("guarded_edit", "anthropic")).toBe("edit"); + }); + }); + + describe("readRecentCommits", () => { + it("parses git log output into sha/subject pairs", async () => { + mockRunGit.mockResolvedValueOnce({ + exitCode: 0, + stdout: "abc123def456\tAdd feature X\nbbb222ccc333\tFix tests\n", + stderr: "", + } as any); + + const commits = await readRecentCommits("/tmp/worktree", 8); + + expect(mockRunGit).toHaveBeenCalledWith( + ["log", "--format=%H%x09%s", "-n", "8", "HEAD"], + { cwd: "/tmp/worktree", timeoutMs: 10_000 }, + ); + expect(commits).toEqual([ + { sha: "abc123def456", subject: "Add feature X" }, + { sha: "bbb222ccc333", subject: "Fix tests" }, + ]); + }); + + it("defaults to 8 commits and HEAD ref", async () => { + mockRunGit.mockResolvedValueOnce({ + exitCode: 0, + stdout: "aaa111bbb222\tFirst commit\n", + stderr: "", + } as any); + + await readRecentCommits("/tmp/worktree"); + + expect(mockRunGit).toHaveBeenCalledWith( + ["log", "--format=%H%x09%s", "-n", "8", "HEAD"], + expect.objectContaining({ cwd: "/tmp/worktree" }), + ); + }); + + it("uses a custom ref when provided", async () => { + mockRunGit.mockResolvedValueOnce({ + exitCode: 0, + stdout: "aaa111bbb222\tRemote commit\n", + stderr: "", + } as any); + + await readRecentCommits("/tmp/worktree", 5, "origin/main"); + + expect(mockRunGit).toHaveBeenCalledWith( + ["log", "--format=%H%x09%s", "-n", "5", "origin/main"], + expect.objectContaining({ cwd: "/tmp/worktree" }), + ); + }); + + it("returns empty array when git exits with non-zero", async () => { + mockRunGit.mockResolvedValueOnce({ + exitCode: 128, + stdout: "", + stderr: "fatal: bad default revision 'HEAD'", + } as any); + + const commits = await readRecentCommits("/tmp/worktree"); + + expect(commits).toEqual([]); + }); + + it("filters out empty lines and entries with no sha or subject", async () => { + mockRunGit.mockResolvedValueOnce({ + exitCode: 0, + stdout: "abc123\tGood commit\n\n \n\t\n", + stderr: "", + } as any); + + const commits = await readRecentCommits("/tmp/worktree"); + + expect(commits).toEqual([{ sha: "abc123", subject: "Good commit" }]); + }); + + it("handles tab characters in the commit subject", async () => { + mockRunGit.mockResolvedValueOnce({ + exitCode: 0, + stdout: "abc123\tSubject\twith\ttabs\n", + stderr: "", + } as any); + + const commits = await readRecentCommits("/tmp/worktree"); + + expect(commits).toEqual([{ sha: "abc123", subject: "Subject\twith\ttabs" }]); + }); + }); +}); + +// --------------------------------------------------------------------------- +// launchRebaseResolutionChat — uses the mocked resolverUtils +// --------------------------------------------------------------------------- + const createdTempDirs: string[] = []; afterAll(() => { for (const dir of createdTempDirs) { diff --git a/apps/desktop/src/main/services/prs/prService.hotRefresh.test.ts b/apps/desktop/src/main/services/prs/prService.hotRefresh.test.ts deleted file mode 100644 index 86689f4f3..000000000 --- a/apps/desktop/src/main/services/prs/prService.hotRefresh.test.ts +++ /dev/null @@ -1,281 +0,0 @@ -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; -import { afterEach, describe, expect, it, vi } from "vitest"; -import type { LaneSummary } from "../../../shared/types"; -import { openKvDb } from "../state/kvDb"; -import { createPrService } from "./prService"; - -function createLogger() { - return { - debug: () => {}, - info: () => {}, - warn: () => {}, - error: () => {}, - } as const; -} - -function makeLane(id: string, name: string, branchRef: string, overrides: Partial = {}): LaneSummary { - return { - id, - name, - description: null, - laneType: "worktree", - baseRef: "refs/heads/main", - branchRef, - worktreePath: `/tmp/${id}`, - attachedRootPath: null, - parentLaneId: null, - childCount: 0, - stackDepth: 0, - parentStatus: null, - isEditProtected: false, - status: { dirty: false, ahead: 0, behind: 0, remoteBehind: -1, rebaseInProgress: false }, - color: null, - icon: null, - tags: [], - folder: null, - createdAt: "2026-03-24T00:00:00.000Z", - archivedAt: null, - ...overrides, - }; -} - -async function seedProject(db: any, projectId: string, repoRoot: string) { - const now = "2026-03-24T00:00:00.000Z"; - db.run( - "insert into projects(id, root_path, display_name, default_base_ref, created_at, last_opened_at) values (?, ?, ?, ?, ?, ?)", - [projectId, repoRoot, "ADE", "main", now, now], - ); -} - -async function seedLane(db: any, projectId: string, lane: LaneSummary) { - db.run( - ` - insert into lanes( - id, project_id, name, description, lane_type, base_ref, branch_ref, worktree_path, - attached_root_path, is_edit_protected, parent_lane_id, color, icon, tags_json, status, created_at, archived_at - ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - `, - [ - lane.id, - projectId, - lane.name, - lane.description, - lane.laneType, - lane.baseRef, - lane.branchRef, - lane.worktreePath, - lane.attachedRootPath, - lane.isEditProtected ? 1 : 0, - lane.parentLaneId, - lane.color, - lane.icon, - JSON.stringify(lane.tags), - "active", - lane.createdAt, - lane.archivedAt, - ], - ); -} - -async function seedPr(db: any, args: { - prId: string; - projectId: string; - laneId: string; - baseBranch: string; - headBranch: string; - title: string; -}) { - const now = "2026-03-24T00:00:00.000Z"; - db.run( - ` - insert into pull_requests( - id, project_id, lane_id, repo_owner, repo_name, github_pr_number, github_url, github_node_id, - title, state, base_branch, head_branch, checks_status, review_status, additions, deletions, - last_synced_at, created_at, updated_at - ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - `, - [ - args.prId, - args.projectId, - args.laneId, - "acme", - "ade", - 101, - "https://github.com/acme/ade/pull/101", - "node-101", - args.title, - "open", - args.baseBranch, - args.headBranch, - "passing", - "approved", - 0, - 0, - now, - now, - now, - ], - ); -} - -describe("prService hot refresh", () => { - afterEach(() => { - vi.useRealTimers(); - vi.restoreAllMocks(); - }); - - it("tracks hot windows and decays from 5s to 15s to idle", async () => { - vi.useFakeTimers(); - vi.setSystemTime(new Date("2026-03-24T12:00:00.000Z")); - - const root = fs.mkdtempSync(path.join(os.tmpdir(), "ade-pr-hot-delay-")); - const db = await openKvDb(path.join(root, ".ade.db"), createLogger()); - try { - const service = createPrService({ - db, - logger: createLogger() as any, - projectId: "proj-hot-delay", - projectRoot: root, - laneService: { list: async () => [] } as any, - operationService: {} as any, - githubService: { apiRequest: async () => ({ data: {} }), getStatus: async () => ({ tokenStored: false, repo: null, userLogin: null }) } as any, - aiIntegrationService: undefined, - projectConfigService: {} as any, - conflictService: undefined, - rebaseSuggestionService: null, - openExternal: async () => {}, - }); - - expect(service.getHotRefreshPrIds()).toEqual([]); - expect(service.getHotRefreshDelayMs()).toBeNull(); - - service.markHotRefresh(["pr-1"]); - expect(service.getHotRefreshPrIds()).toEqual(["pr-1"]); - expect(service.getHotRefreshDelayMs()).toBe(5_000); - - vi.setSystemTime(new Date("2026-03-24T12:01:01.000Z")); - expect(service.getHotRefreshDelayMs()).toBe(15_000); - - vi.setSystemTime(new Date("2026-03-24T12:03:01.000Z")); - expect(service.getHotRefreshPrIds()).toEqual([]); - expect(service.getHotRefreshDelayMs()).toBeNull(); - } finally { - db.close(); - fs.rmSync(root, { recursive: true, force: true }); - } - }); - - it("invalidates the GitHub snapshot cache on hot starts and summary changes", async () => { - const root = fs.mkdtempSync(path.join(os.tmpdir(), "ade-pr-hot-cache-")); - const db = await openKvDb(path.join(root, ".ade.db"), createLogger()); - try { - const projectId = "proj-hot-cache"; - const lane = makeLane("lane-1", "feature/pr-1", "refs/heads/feature/pr-1"); - await seedProject(db, projectId, root); - await seedLane(db, projectId, lane); - await seedPr(db, { - prId: "pr-1", - projectId, - laneId: lane.id, - baseBranch: "main", - headBranch: "feature/pr-1", - title: "Old title", - }); - - let snapshotTitle = "Old title"; - const apiRequest = vi.fn(async ({ path: requestPath }: { path: string }) => { - if (requestPath === "/repos/acme/ade/pulls") { - return { - data: [ - { - id: 101, - node_id: "node-101", - number: 101, - title: snapshotTitle, - state: "open", - draft: false, - html_url: "https://github.com/acme/ade/pull/101", - updated_at: "2026-03-24T12:00:00.000Z", - created_at: "2026-03-24T00:00:00.000Z", - base: { ref: "main", repo: { owner: { login: "acme" }, name: "ade" } }, - head: { ref: "feature/pr-1", sha: "head-sha-1", repo: { owner: { login: "acme" }, name: "ade" } }, - user: { login: "alice" }, - }, - ], - }; - } - if (requestPath === "/repos/acme/ade/pulls/101") { - return { - data: { - node_id: "node-101", - html_url: "https://github.com/acme/ade/pull/101", - title: snapshotTitle, - state: "open", - draft: false, - updated_at: "2026-03-24T12:00:00.000Z", - created_at: "2026-03-24T00:00:00.000Z", - additions: 3, - deletions: 1, - base: { ref: "main", sha: "base-sha-1" }, - head: { ref: "feature/pr-1", sha: "head-sha-1" }, - user: { login: "alice", avatar_url: null }, - labels: [], - assignees: [], - requested_reviewers: [], - milestone: null, - }, - }; - } - if (requestPath === "/repos/acme/ade/commits/head-sha-1/status") { - return { data: { state: "success", statuses: [] } }; - } - if (requestPath === "/repos/acme/ade/commits/head-sha-1/check-runs") { - return { data: { check_runs: [] } }; - } - if (requestPath === "/repos/acme/ade/pulls/101/reviews") { - return { data: [] }; - } - return { data: {} }; - }); - - const service = createPrService({ - db, - logger: createLogger() as any, - projectId, - projectRoot: root, - laneService: { list: async () => [lane] } as any, - operationService: {} as any, - githubService: { - apiRequest, - getStatus: async () => ({ tokenStored: true, repo: { owner: "acme", name: "ade" }, userLogin: null }), - } as any, - aiIntegrationService: undefined, - projectConfigService: {} as any, - conflictService: undefined, - rebaseSuggestionService: null, - openExternal: async () => {}, - }); - - const firstSnapshot = await service.getGithubSnapshot(); - expect(firstSnapshot.repoPullRequests).toHaveLength(1); - expect(firstSnapshot.repoPullRequests[0]?.title).toBe("Old title"); - const callsAfterFirstSnapshot = apiRequest.mock.calls.length; - - service.markHotRefresh(["pr-1"]); - const secondSnapshot = await service.getGithubSnapshot(); - expect(apiRequest.mock.calls.length).toBeGreaterThan(callsAfterFirstSnapshot); - expect(secondSnapshot.repoPullRequests[0]?.title).toBe("Old title"); - - snapshotTitle = "New title"; - await service.refresh({ prId: "pr-1" }); - const thirdSnapshot = await service.getGithubSnapshot(); - expect(apiRequest.mock.calls.length).toBeGreaterThan(callsAfterFirstSnapshot + 1); - expect(thirdSnapshot.repoPullRequests[0]?.title).toBe("New title"); - } finally { - db.close(); - fs.rmSync(root, { recursive: true, force: true }); - } - }); -}); diff --git a/apps/desktop/src/main/services/prs/prService.integrationCommit.test.ts b/apps/desktop/src/main/services/prs/prService.integrationCommit.test.ts deleted file mode 100644 index 5974ab5f1..000000000 --- a/apps/desktop/src/main/services/prs/prService.integrationCommit.test.ts +++ /dev/null @@ -1,532 +0,0 @@ -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; -import { beforeEach, describe, expect, it, vi } from "vitest"; -import type { LaneSummary } from "../../../shared/types"; -import { openKvDb } from "../state/kvDb"; - -const runGitMock = vi.fn(); -const runGitOrThrowMock = vi.fn(); -const runGitMergeTreeMock = vi.fn(); - -vi.mock("../git/git", () => ({ - runGit: (...args: unknown[]) => runGitMock(...args), - runGitOrThrow: (...args: unknown[]) => runGitOrThrowMock(...args), - runGitMergeTree: (...args: unknown[]) => runGitMergeTreeMock(...args), -})); - -async function createServiceModule() { - return await import("./prService"); -} - -function createLogger() { - return { - debug: () => {}, - info: () => {}, - warn: () => {}, - error: () => {}, - } as const; -} - -function makeLane(id: string, name: string, branchRef: string, worktreePath: string, overrides: Partial = {}): LaneSummary { - return { - id, - name, - description: null, - laneType: "worktree", - baseRef: "refs/heads/main", - branchRef, - worktreePath, - attachedRootPath: null, - parentLaneId: null, - childCount: 0, - stackDepth: 0, - parentStatus: null, - isEditProtected: false, - status: { dirty: false, ahead: 0, behind: 0, remoteBehind: -1, rebaseInProgress: false }, - color: null, - icon: null, - tags: [], - folder: null, - createdAt: "2026-03-12T00:00:00.000Z", - archivedAt: null, - ...overrides, - }; -} - -async function seedProject(db: any, projectId: string, repoRoot: string) { - const now = "2026-03-12T00:00:00.000Z"; - db.run( - "insert into projects(id, root_path, display_name, default_base_ref, created_at, last_opened_at) values (?, ?, ?, ?, ?, ?)", - [projectId, repoRoot, "ADE", "main", now, now], - ); -} - -async function seedLane(db: any, projectId: string, lane: LaneSummary) { - db.run( - ` - insert into lanes( - id, project_id, name, description, lane_type, base_ref, branch_ref, worktree_path, - attached_root_path, is_edit_protected, parent_lane_id, color, icon, tags_json, status, created_at, archived_at - ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - `, - [ - lane.id, - projectId, - lane.name, - lane.description, - lane.laneType, - lane.baseRef, - lane.branchRef, - lane.worktreePath, - lane.attachedRootPath, - lane.isEditProtected ? 1 : 0, - lane.parentLaneId, - lane.color, - lane.icon, - JSON.stringify(lane.tags), - "active", - lane.createdAt, - lane.archivedAt, - ], - ); -} - -describe("prService.commitIntegration", () => { - beforeEach(() => { - runGitMock.mockReset(); - runGitOrThrowMock.mockReset(); - runGitMergeTreeMock.mockReset(); - }); - - it("preserves the integration lane on sequential merge conflicts so the proposal can be resolved", async () => { - const root = fs.mkdtempSync(path.join(os.tmpdir(), "ade-pr-integration-commit-")); - const db = await openKvDb(path.join(root, ".ade.db"), createLogger()); - const projectId = "proj-integration-commit"; - - const baseLane = makeLane("lane-main", "main", "refs/heads/main", root, { - laneType: "primary", - }); - const cleanLane = makeLane("lane-clean", "clean-lane", "refs/heads/feature/clean", path.join(root, "clean")); - const conflictLane = makeLane("lane-conflict", "computer-use", "refs/heads/feature/computer-use", path.join(root, "conflict")); - - await seedProject(db, projectId, root); - await seedLane(db, projectId, baseLane); - await seedLane(db, projectId, cleanLane); - await seedLane(db, projectId, conflictLane); - - const proposalId = "12345678-abcd-4abc-8def-1234567890ab"; - db.run( - `insert into integration_proposals( - id, project_id, source_lane_ids_json, base_branch, steps_json, pairwise_results_json, lane_summaries_json, overall_outcome, created_at, status - ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, - [ - proposalId, - projectId, - JSON.stringify([cleanLane.id, conflictLane.id]), - "main", - JSON.stringify([ - { laneId: cleanLane.id, laneName: cleanLane.name, position: 0, outcome: "clean", conflictingFiles: [], diffStat: { insertions: 0, deletions: 0, filesChanged: 0 } }, - { laneId: conflictLane.id, laneName: conflictLane.name, position: 1, outcome: "clean", conflictingFiles: [], diffStat: { insertions: 0, deletions: 0, filesChanged: 0 } }, - ]), - JSON.stringify([]), - JSON.stringify([]), - "clean", - "2026-03-12T00:00:00.000Z", - "proposed", - ], - ); - - const laneState: LaneSummary[] = [baseLane, cleanLane, conflictLane]; - const archiveSpy = vi.fn(); - const createChildSpy = vi.fn(async ({ name, parentLaneId }: { name: string; parentLaneId: string }) => { - const integrationLane = makeLane( - "lane-int", - name, - `refs/heads/${name}`, - path.join(root, "integration-lane"), - { parentLaneId }, - ); - laneState.push(integrationLane); - return integrationLane; - }); - - runGitMock.mockImplementation(async (args: string[]) => { - if (args[0] === "merge" && args[1] === "--abort") { - return { exitCode: 0, stdout: "", stderr: "" }; - } - if (args[0] === "merge") { - const branch = args[args.length - 1]; - if (branch === "feature/clean") { - return { exitCode: 0, stdout: "", stderr: "" }; - } - if (branch === "feature/computer-use") { - return { exitCode: 1, stdout: "", stderr: "merge conflict" }; - } - } - return { exitCode: 0, stdout: "", stderr: "" }; - }); - - const { createPrService } = await createServiceModule(); - const service = createPrService({ - db, - logger: createLogger() as any, - projectId, - projectRoot: root, - laneService: { - list: async () => laneState, - createChild: createChildSpy, - archive: archiveSpy, - } as any, - operationService: {} as any, - githubService: { - getRepoOrThrow: vi.fn(), - apiRequest: vi.fn(), - } as any, - aiIntegrationService: undefined, - projectConfigService: { - get: () => ({ effective: { providerMode: "guest" } }), - } as any, - conflictService: undefined, - openExternal: async () => {}, - }); - - await expect( - service.commitIntegration({ - proposalId, - integrationLaneName: "integration/12345678", - title: "Integration PR", - body: "", - draft: false, - }), - ).rejects.toThrow("Integration merge blocked. Resolve conflicts for: computer-use."); - - expect(createChildSpy).toHaveBeenCalledOnce(); - expect(archiveSpy).not.toHaveBeenCalled(); - - const proposals = await service.listIntegrationProposals(); - expect(proposals).toHaveLength(1); - expect(proposals[0]).toMatchObject({ - proposalId, - integrationLaneId: "lane-int", - integrationLaneName: "integration/12345678", - }); - expect(proposals[0]?.resolutionState?.stepResolutions).toMatchObject({ - "lane-clean": "merged-clean", - "lane-conflict": "pending", - }); - }); - - it("marks sequential merge conflicts during simulation even when pairwise merge-tree reports clean", async () => { - const root = fs.mkdtempSync(path.join(os.tmpdir(), "ade-pr-integration-sim-")); - const db = await openKvDb(path.join(root, ".ade.db"), createLogger()); - const projectId = "proj-integration-sim"; - - const baseLane = makeLane("lane-main", "main", "refs/heads/main", root, { - laneType: "primary", - }); - const firstLane = makeLane("lane-a", "fixing linear flow", "refs/heads/feature/linear", path.join(root, "linear")); - const secondLane = makeLane("lane-b", "computer-use", "refs/heads/feature/computer-use", path.join(root, "computer")); - - await seedProject(db, projectId, root); - await seedLane(db, projectId, baseLane); - await seedLane(db, projectId, firstLane); - await seedLane(db, projectId, secondLane); - - const tempRoots: string[] = []; - const originalMkdtemp = fs.mkdtempSync; - const originalReadFile = fs.readFileSync; - vi.spyOn(fs, "mkdtempSync").mockImplementation((prefix, options) => { - const dir = originalMkdtemp(prefix as string, options as BufferEncoding | undefined); - tempRoots.push(dir); - return dir; - }); - vi.spyOn(fs, "readFileSync").mockImplementation(((filePath: fs.PathOrFileDescriptor, encoding?: any) => { - if (typeof filePath === "string" && filePath.endsWith(path.join("src", "conflicted.ts"))) { - return "<<<<<<< ours\nleft\n=======\nright\n>>>>>>> theirs\n"; - } - return originalReadFile(filePath as any, encoding); - }) as typeof fs.readFileSync); - - runGitOrThrowMock.mockImplementation(async (args: string[]) => { - if (args[0] === "rev-parse" && args[1] === "main") return "base-sha"; - if (args[0] === "rev-parse" && args[1] === "feature/linear") return "linear-sha"; - if (args[0] === "rev-parse" && args[1] === "feature/computer-use") return "computer-sha"; - return ""; - }); - - runGitMergeTreeMock.mockResolvedValue({ - exitCode: 0, - stdout: "", - stderr: "", - mergeBase: "base-sha", - branchA: "linear-sha", - branchB: "computer-sha", - conflicts: [], - treeOid: null, - usedMergeBaseFlag: true, - usedWriteTree: true, - }); - - runGitMock.mockImplementation(async (args: string[], options?: { cwd?: string }) => { - if (args[0] === "rev-list" || args[0] === "diff") { - return { exitCode: 0, stdout: "", stderr: "" }; - } - if (args[0] === "rev-parse" && args[1] === "--short") { - if (args[2] === "linear-sha") return { exitCode: 0, stdout: "linear12", stderr: "" }; - if (args[2] === "computer-sha") return { exitCode: 0, stdout: "computer", stderr: "" }; - } - if (args[0] === "worktree" && args[1] === "remove") { - return { exitCode: 0, stdout: "", stderr: "" }; - } - if (args[0] === "merge" && args[1] === "--abort") { - return { exitCode: 0, stdout: "", stderr: "" }; - } - if (args[0] === "merge") { - const branch = args[args.length - 1]; - if (branch === "feature/linear") { - return { exitCode: 0, stdout: "", stderr: "" }; - } - if (branch === "feature/computer-use") { - return { exitCode: 1, stdout: "", stderr: "merge conflict" }; - } - } - if (args[0] === "status" && options?.cwd?.includes(`${path.sep}worktree`)) { - return { exitCode: 0, stdout: "UU src/conflicted.ts\n", stderr: "" }; - } - return { exitCode: 0, stdout: "", stderr: "" }; - }); - - const { createPrService } = await createServiceModule(); - const service = createPrService({ - db, - logger: createLogger() as any, - projectId, - projectRoot: root, - laneService: { - list: async () => [baseLane, firstLane, secondLane], - } as any, - operationService: {} as any, - githubService: { - getRepoOrThrow: vi.fn(), - apiRequest: vi.fn(), - } as any, - aiIntegrationService: undefined, - projectConfigService: { - get: () => ({ effective: { providerMode: "guest" } }), - } as any, - conflictService: undefined, - openExternal: async () => {}, - }); - - const proposal = await service.simulateIntegration({ - sourceLaneIds: [firstLane.id, secondLane.id], - baseBranch: "main", - }); - - expect(proposal.overallOutcome).toBe("conflict"); - expect(proposal.pairwiseResults).toHaveLength(1); - expect(proposal.pairwiseResults[0]?.outcome).toBe("clean"); - expect(proposal.steps.find((step) => step.laneId === secondLane.id)).toMatchObject({ - outcome: "conflict", - }); - expect(proposal.steps.find((step) => step.laneId === secondLane.id)?.conflictingFiles[0]?.path).toBe("src/conflicted.ts"); - expect(runGitMergeTreeMock).toHaveBeenCalledOnce(); - }); - - it("does not read conflict previews through symlinked worktree escapes during simulation", async () => { - const root = fs.mkdtempSync(path.join(os.tmpdir(), "ade-pr-integration-symlink-preview-")); - const outsideDir = fs.mkdtempSync(path.join(os.tmpdir(), "ade-pr-integration-symlink-outside-")); - const db = await openKvDb(path.join(root, ".ade.db"), createLogger()); - const projectId = "proj-integration-symlink-preview"; - - try { - const baseLane = makeLane("lane-main", "main", "refs/heads/main", root, { - laneType: "primary", - }); - const conflictLane = makeLane("lane-conflict", "computer-use", "refs/heads/feature/computer-use", path.join(root, "conflict")); - - await seedProject(db, projectId, root); - await seedLane(db, projectId, baseLane); - await seedLane(db, projectId, conflictLane); - - runGitOrThrowMock.mockImplementation(async (args: string[]) => { - if (args[0] === "rev-parse" && args[1] === "main") return "base-sha"; - if (args[0] === "rev-parse" && args[1] === "feature/computer-use") return "computer-sha"; - return ""; - }); - - runGitMergeTreeMock.mockResolvedValue({ - exitCode: 0, - stdout: "", - stderr: "", - mergeBase: "base-sha", - branchA: "base-sha", - branchB: "computer-sha", - conflicts: [], - treeOid: null, - usedMergeBaseFlag: true, - usedWriteTree: true, - }); - - runGitMock.mockImplementation(async (args: string[], options?: { cwd?: string }) => { - if (args[0] === "rev-list" || args[0] === "diff") { - return { exitCode: 0, stdout: "", stderr: "" }; - } - if (args[0] === "rev-parse" && args[1] === "--short" && args[2] === "computer-sha") { - return { exitCode: 0, stdout: "computer", stderr: "" }; - } - if (args[0] === "merge" && args[1] === "--abort") { - return { exitCode: 0, stdout: "", stderr: "" }; - } - if (args[0] === "worktree" && args[1] === "remove") { - return { exitCode: 0, stdout: "", stderr: "" }; - } - if (args[0] === "merge") { - fs.writeFileSync(path.join(outsideDir, "secret.ts"), "<<<<<<< ours\nleft\n=======\nright\n>>>>>>> theirs\n", "utf8"); - fs.mkdirSync(options!.cwd!, { recursive: true }); - fs.symlinkSync(outsideDir, path.join(options!.cwd!, "linked")); - return { exitCode: 1, stdout: "", stderr: "merge conflict" }; - } - if (args[0] === "status" && options?.cwd?.includes(`${path.sep}worktree`)) { - return { exitCode: 0, stdout: "UU linked/secret.ts\n", stderr: "" }; - } - return { exitCode: 0, stdout: "", stderr: "" }; - }); - - const { createPrService } = await createServiceModule(); - const service = createPrService({ - db, - logger: createLogger() as any, - projectId, - projectRoot: root, - laneService: { - list: async () => [baseLane, conflictLane], - } as any, - operationService: {} as any, - githubService: { - getRepoOrThrow: vi.fn(), - apiRequest: vi.fn(), - } as any, - aiIntegrationService: undefined, - projectConfigService: { - get: () => ({ effective: { providerMode: "guest" } }), - } as any, - conflictService: undefined, - openExternal: async () => {}, - }); - - const proposal = await service.simulateIntegration({ - sourceLaneIds: [conflictLane.id], - baseBranch: "main", - }); - - expect(proposal.steps[0]?.conflictingFiles[0]).toMatchObject({ - path: "linked/secret.ts", - conflictType: null, - conflictMarkers: "", - }); - } finally { - db.close(); - fs.rmSync(root, { recursive: true, force: true }); - fs.rmSync(outsideDir, { recursive: true, force: true }); - } - }); - - it("ignores symlinked conflict marker files that escape the integration lane during recheck", async () => { - const root = fs.mkdtempSync(path.join(os.tmpdir(), "ade-pr-integration-symlink-recheck-")); - const outsideDir = fs.mkdtempSync(path.join(os.tmpdir(), "ade-pr-integration-recheck-outside-")); - let db: Awaited> | null = null; - try { - db = await openKvDb(path.join(root, ".ade.db"), createLogger()); - const projectId = "proj-integration-symlink-recheck"; - const now = "2026-03-12T00:00:00.000Z"; - - const baseLane = makeLane("lane-main", "main", "refs/heads/main", root, { - laneType: "primary", - }); - const sourceLane = makeLane("lane-source", "source", "refs/heads/feature/source", path.join(root, "source")); - const integrationLane = makeLane("lane-int", "integration", "refs/heads/integration/test", path.join(root, "integration")); - - fs.mkdirSync(integrationLane.worktreePath, { recursive: true }); - fs.writeFileSync(path.join(outsideDir, "secret.ts"), "<<<<<<< ours\nleft\n=======\nright\n>>>>>>> theirs\n", "utf8"); - fs.symlinkSync(outsideDir, path.join(integrationLane.worktreePath, "linked")); - - await seedProject(db, projectId, root); - await seedLane(db, projectId, baseLane); - await seedLane(db, projectId, sourceLane); - await seedLane(db, projectId, integrationLane); - - const proposalId = "proposal-symlink-recheck"; - db.run( - `insert into integration_proposals( - id, project_id, source_lane_ids_json, base_branch, steps_json, pairwise_results_json, lane_summaries_json, overall_outcome, created_at, status, integration_lane_id, resolution_state_json - ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, - [ - proposalId, - projectId, - JSON.stringify([sourceLane.id]), - "main", - JSON.stringify([ - { laneId: sourceLane.id, laneName: sourceLane.name, position: 0, outcome: "conflict", conflictingFiles: [{ path: "linked/secret.ts" }], diffStat: { insertions: 0, deletions: 0, filesChanged: 1 } }, - ]), - JSON.stringify([]), - JSON.stringify([]), - "conflict", - now, - "committed", - integrationLane.id, - JSON.stringify({ - integrationLaneId: integrationLane.id, - stepResolutions: { [sourceLane.id]: "pending" }, - activeWorkerStepId: null, - activeLaneId: null, - updatedAt: now, - }), - ], - ); - - runGitMock.mockImplementation(async (args: string[]) => { - if (args[0] === "status") { - return { exitCode: 0, stdout: " M linked/secret.ts\n", stderr: "" }; - } - return { exitCode: 0, stdout: "", stderr: "" }; - }); - - const { createPrService } = await createServiceModule(); - const service = createPrService({ - db, - logger: createLogger() as any, - projectId, - projectRoot: root, - laneService: { - list: async () => [baseLane, sourceLane, integrationLane], - } as any, - operationService: {} as any, - githubService: { - getRepoOrThrow: vi.fn(), - apiRequest: vi.fn(), - } as any, - aiIntegrationService: undefined, - projectConfigService: { - get: () => ({ effective: { providerMode: "guest" } }), - } as any, - conflictService: undefined, - openExternal: async () => {}, - }); - - const result = await service.recheckIntegrationStep({ proposalId, laneId: sourceLane.id }); - - expect(result).toMatchObject({ - resolution: "resolved", - remainingConflictFiles: [], - allResolved: true, - message: null, - }); - } finally { - db?.close(); - fs.rmSync(root, { recursive: true, force: true }); - fs.rmSync(outsideDir, { recursive: true, force: true }); - } - }); -}); diff --git a/apps/desktop/src/main/services/prs/prService.landAutoRebase.test.ts b/apps/desktop/src/main/services/prs/prService.landAutoRebase.test.ts deleted file mode 100644 index 3b900f0ae..000000000 --- a/apps/desktop/src/main/services/prs/prService.landAutoRebase.test.ts +++ /dev/null @@ -1,368 +0,0 @@ -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; -import { beforeEach, describe, expect, it, vi } from "vitest"; -import type { LaneSummary } from "../../../shared/types"; -import { openKvDb } from "../state/kvDb"; - -const runGitMock = vi.fn(); -const runGitOrThrowMock = vi.fn(); -const fetchRemoteTrackingBranchMock = vi.fn(); - -vi.mock("../git/git", () => ({ - runGit: (...args: unknown[]) => runGitMock(...args), - runGitOrThrow: (...args: unknown[]) => runGitOrThrowMock(...args), - runGitMergeTree: vi.fn(), -})); - -vi.mock("../shared/queueRebase", () => ({ - fetchRemoteTrackingBranch: (...args: unknown[]) => fetchRemoteTrackingBranchMock(...args), -})); - -async function createServiceModule() { - return await import("./prService"); -} - -function createLogger() { - return { - debug: () => {}, - info: () => {}, - warn: () => {}, - error: () => {}, - } as const; -} - -function makeLane(id: string, name: string, branchRef: string, worktreePath: string, overrides: Partial = {}): LaneSummary { - return { - id, - name, - description: null, - laneType: "worktree", - baseRef: "refs/heads/main", - branchRef, - worktreePath, - attachedRootPath: null, - parentLaneId: null, - childCount: 0, - stackDepth: 0, - parentStatus: null, - isEditProtected: false, - status: { dirty: false, ahead: 0, behind: 0, remoteBehind: -1, rebaseInProgress: false }, - color: null, - icon: null, - tags: [], - folder: null, - createdAt: "2026-03-30T00:00:00.000Z", - archivedAt: null, - ...overrides, - }; -} - -async function seedProject(db: any, projectId: string, repoRoot: string) { - const now = "2026-03-30T00:00:00.000Z"; - db.run( - "insert into projects(id, root_path, display_name, default_base_ref, created_at, last_opened_at) values (?, ?, ?, ?, ?, ?)", - [projectId, repoRoot, "ADE", "main", now, now], - ); -} - -async function seedPr(db: any, args: { - prId: string; - projectId: string; - laneId: string; - number: number; - baseBranch: string; - headBranch: string; - title: string; -}) { - const now = "2026-03-30T00:00:00.000Z"; - db.run( - ` - insert into pull_requests( - id, project_id, lane_id, repo_owner, repo_name, github_pr_number, github_url, github_node_id, - title, state, base_branch, head_branch, checks_status, review_status, additions, deletions, - last_synced_at, created_at, updated_at - ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - `, - [ - args.prId, - args.projectId, - args.laneId, - "acme", - "ade", - args.number, - `https://github.com/acme/ade/pull/${args.number}`, - `node-${args.number}`, - args.title, - "open", - args.baseBranch, - args.headBranch, - "passing", - "approved", - 0, - 0, - now, - now, - now, - ], - ); -} - -describe("prService.land auto-rebase follow-up", () => { - beforeEach(() => { - runGitMock.mockReset(); - runGitOrThrowMock.mockReset(); - fetchRemoteTrackingBranchMock.mockReset(); - fetchRemoteTrackingBranchMock.mockResolvedValue(undefined); - }); - - it("reparents, pushes, and retargets direct child lanes after a merged parent lane", async () => { - const root = fs.mkdtempSync(path.join(os.tmpdir(), "ade-pr-land-auto-rebase-")); - const db = await openKvDb(path.join(root, ".ade.db"), createLogger()); - try { - const { createPrService } = await createServiceModule(); - const projectId = "proj-land-auto-rebase"; - - const mainLane = makeLane("lane-main", "main", "main", root, { laneType: "primary" }); - const parentLane = makeLane("lane-parent", "feature/parent", "feature/parent", path.join(root, "parent"), { - parentLaneId: mainLane.id, - }); - const childLane = makeLane("lane-child", "feature/child", "feature/child", path.join(root, "child"), { - parentLaneId: parentLane.id, - baseRef: "refs/heads/feature/parent", - }); - const lanes = [mainLane, parentLane, childLane]; - - await seedProject(db, projectId, root); - await seedPr(db, { - prId: "pr-parent", - projectId, - laneId: parentLane.id, - number: 101, - baseBranch: "main", - headBranch: "feature/parent", - title: "Parent PR", - }); - await seedPr(db, { - prId: "pr-child", - projectId, - laneId: childLane.id, - number: 202, - baseBranch: "feature/parent", - headBranch: "feature/child", - title: "Child PR", - }); - - const laneService = { - list: vi.fn(async ({ includeArchived }: { includeArchived?: boolean } = {}) => - includeArchived ? lanes : lanes.filter((lane) => !lane.archivedAt) - ), - getChildren: vi.fn(async (laneId: string) => lanes.filter((lane) => lane.parentLaneId === laneId && !lane.archivedAt)), - reparent: vi.fn(async ({ laneId, newParentLaneId }: { laneId: string; newParentLaneId: string }) => { - const lane = lanes.find((entry) => entry.id === laneId)!; - const newParent = lanes.find((entry) => entry.id === newParentLaneId)!; - lane.parentLaneId = newParent.id; - lane.baseRef = newParent.branchRef; - return { - laneId, - previousParentLaneId: parentLane.id, - newParentLaneId, - previousBaseRef: "refs/heads/feature/parent", - newBaseRef: newParent.branchRef, - preHeadSha: "child-pre", - postHeadSha: "child-post", - }; - }), - archive: vi.fn(async ({ laneId }: { laneId: string }) => { - const lane = lanes.find((entry) => entry.id === laneId)!; - lane.archivedAt = "2026-03-30T01:00:00.000Z"; - }), - invalidateCache: vi.fn(), - }; - - runGitMock.mockResolvedValue({ exitCode: 0, stdout: "origin/feature/child\n", stderr: "" }); - runGitOrThrowMock.mockResolvedValue({ exitCode: 0, stdout: "", stderr: "" }); - - const apiRequest = vi.fn(async ({ method, path: requestPath, body }: { method: string; path: string; body?: any }) => { - if (method === "PUT" && requestPath === "/repos/acme/ade/pulls/101/merge") { - return { data: { sha: "merge-sha" } }; - } - if (method === "PATCH" && requestPath === "/repos/acme/ade/pulls/202") { - expect(body).toMatchObject({ base: "main" }); - return { data: {} }; - } - if (method === "DELETE" && requestPath === "/repos/acme/ade/git/refs/heads/feature/parent") { - return { data: {} }; - } - return { data: {} }; - }); - - const autoRebaseService = { - recordAttentionStatus: vi.fn(async () => undefined), - refreshActiveRebaseNeeds: vi.fn(async () => undefined), - }; - - const service = createPrService({ - db, - logger: createLogger() as any, - projectId, - projectRoot: root, - laneService: laneService as any, - operationService: { - start: () => ({ operationId: "op-1" }), - finish: vi.fn(), - } as any, - githubService: { apiRequest } as any, - aiIntegrationService: undefined, - projectConfigService: { - getEffective: () => ({ git: { autoRebaseOnHeadChange: true } }), - } as any, - conflictService: { scanRebaseNeeds: vi.fn(async () => []) } as any, - autoRebaseService: autoRebaseService as any, - rebaseSuggestionService: { refresh: vi.fn(async () => undefined) } as any, - openExternal: async () => {}, - }); - - const result = await service.land({ prId: "pr-parent", method: "squash", archiveLane: true }); - - expect(result).toMatchObject({ success: true, branchDeleted: true, laneArchived: true }); - expect(laneService.reparent).toHaveBeenCalledWith({ laneId: "lane-child", newParentLaneId: "lane-main" }); - expect(runGitOrThrowMock).toHaveBeenCalledWith( - ["push", "--force-with-lease"], - expect.objectContaining({ cwd: childLane.worktreePath }), - ); - expect(apiRequest).toHaveBeenCalledWith(expect.objectContaining({ - method: "PATCH", - path: "/repos/acme/ade/pulls/202", - })); - expect(laneService.archive).toHaveBeenCalledWith({ laneId: "lane-parent" }); - expect(autoRebaseService.recordAttentionStatus).toHaveBeenCalledWith(expect.objectContaining({ - laneId: "lane-child", - state: "autoRebased", - })); - expect(autoRebaseService.refreshActiveRebaseNeeds).toHaveBeenCalledWith("merge_completed"); - } finally { - db.close(); - fs.rmSync(root, { recursive: true, force: true }); - } - }); - - it("restores the child lane and skips cleanup when the auto-push fails", async () => { - const root = fs.mkdtempSync(path.join(os.tmpdir(), "ade-pr-land-auto-rebase-fail-")); - const db = await openKvDb(path.join(root, ".ade.db"), createLogger()); - try { - const { createPrService } = await createServiceModule(); - const projectId = "proj-land-auto-rebase-fail"; - - const mainLane = makeLane("lane-main", "main", "main", root, { laneType: "primary" }); - const parentLane = makeLane("lane-parent", "feature/parent", "feature/parent", path.join(root, "parent"), { - parentLaneId: mainLane.id, - }); - const childLane = makeLane("lane-child", "feature/child", "feature/child", path.join(root, "child"), { - parentLaneId: parentLane.id, - baseRef: "refs/heads/feature/parent", - }); - const lanes = [mainLane, parentLane, childLane]; - - await seedProject(db, projectId, root); - await seedPr(db, { - prId: "pr-parent", - projectId, - laneId: parentLane.id, - number: 101, - baseBranch: "main", - headBranch: "feature/parent", - title: "Parent PR", - }); - - const laneService = { - list: vi.fn(async ({ includeArchived }: { includeArchived?: boolean } = {}) => - includeArchived ? lanes : lanes.filter((lane) => !lane.archivedAt) - ), - getChildren: vi.fn(async () => [childLane]), - reparent: vi.fn(async ({ laneId, newParentLaneId }: { laneId: string; newParentLaneId: string }) => { - childLane.parentLaneId = newParentLaneId; - childLane.baseRef = "main"; - return { - laneId, - previousParentLaneId: parentLane.id, - newParentLaneId, - previousBaseRef: "refs/heads/feature/parent", - newBaseRef: "main", - preHeadSha: "child-pre", - postHeadSha: "child-post", - }; - }), - archive: vi.fn(async () => undefined), - invalidateCache: vi.fn(), - }; - - runGitMock.mockResolvedValue({ exitCode: 0, stdout: "origin/feature/child\n", stderr: "" }); - runGitOrThrowMock.mockImplementation(async (args: string[]) => { - if (args[0] === "push") { - throw new Error("remote rejected push"); - } - if (args[0] === "reset") { - return { exitCode: 0, stdout: "", stderr: "" }; - } - return { exitCode: 0, stdout: "", stderr: "" }; - }); - - const apiRequest = vi.fn(async ({ method, path: requestPath }: { method: string; path: string }) => { - if (method === "PUT" && requestPath === "/repos/acme/ade/pulls/101/merge") { - return { data: { sha: "merge-sha" } }; - } - if (method === "DELETE" && requestPath === "/repos/acme/ade/git/refs/heads/feature/parent") { - return { data: {} }; - } - return { data: {} }; - }); - - const autoRebaseService = { - recordAttentionStatus: vi.fn(async () => undefined), - refreshActiveRebaseNeeds: vi.fn(async () => undefined), - }; - - const service = createPrService({ - db, - logger: createLogger() as any, - projectId, - projectRoot: root, - laneService: laneService as any, - operationService: { - start: () => ({ operationId: "op-1" }), - finish: vi.fn(), - } as any, - githubService: { apiRequest } as any, - aiIntegrationService: undefined, - projectConfigService: { - getEffective: () => ({ git: { autoRebaseOnHeadChange: true } }), - } as any, - conflictService: { scanRebaseNeeds: vi.fn(async () => []) } as any, - autoRebaseService: autoRebaseService as any, - rebaseSuggestionService: { refresh: vi.fn(async () => undefined) } as any, - openExternal: async () => {}, - }); - - const result = await service.land({ prId: "pr-parent", method: "squash", archiveLane: true }); - - expect(result).toMatchObject({ success: true, branchDeleted: false, laneArchived: false }); - expect(runGitOrThrowMock).toHaveBeenCalledWith( - ["reset", "--hard", "child-pre"], - expect.objectContaining({ cwd: childLane.worktreePath }), - ); - expect(laneService.archive).not.toHaveBeenCalled(); - expect(apiRequest).not.toHaveBeenCalledWith(expect.objectContaining({ - method: "DELETE", - path: "/repos/acme/ade/git/refs/heads/feature/parent", - })); - expect(autoRebaseService.recordAttentionStatus).toHaveBeenCalledWith(expect.objectContaining({ - laneId: "lane-child", - state: "rebaseFailed", - })); - } finally { - db.close(); - fs.rmSync(root, { recursive: true, force: true }); - } - }); -}); diff --git a/apps/desktop/src/main/services/prs/prService.mergeContext.test.ts b/apps/desktop/src/main/services/prs/prService.mergeContext.test.ts deleted file mode 100644 index 08e3b0955..000000000 --- a/apps/desktop/src/main/services/prs/prService.mergeContext.test.ts +++ /dev/null @@ -1,283 +0,0 @@ -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; -import { describe, expect, it } from "vitest"; -import type { LaneSummary } from "../../../shared/types"; -import { openKvDb } from "../state/kvDb"; -import { createPrService } from "./prService"; - -function createLogger() { - return { - debug: () => {}, - info: () => {}, - warn: () => {}, - error: () => {}, - } as const; -} - -function makeLane(id: string, name: string, branchRef: string, overrides: Partial = {}): LaneSummary { - return { - id, - name, - description: null, - laneType: "worktree", - baseRef: "refs/heads/main", - branchRef, - worktreePath: `/tmp/${id}`, - attachedRootPath: null, - parentLaneId: null, - childCount: 0, - stackDepth: 0, - parentStatus: null, - isEditProtected: false, - status: { dirty: false, ahead: 0, behind: 0, remoteBehind: -1, rebaseInProgress: false }, - color: null, - icon: null, - tags: [], - folder: null, - createdAt: "2026-03-11T00:00:00.000Z", - archivedAt: null, - ...overrides, - }; -} - -async function seedProject(db: any, projectId: string, repoRoot: string) { - const now = "2026-03-11T00:00:00.000Z"; - db.run( - "insert into projects(id, root_path, display_name, default_base_ref, created_at, last_opened_at) values (?, ?, ?, ?, ?, ?)", - [projectId, repoRoot, "ADE", "main", now, now], - ); -} - -async function seedLane(db: any, projectId: string, lane: LaneSummary) { - db.run( - ` - insert into lanes( - id, project_id, name, description, lane_type, base_ref, branch_ref, worktree_path, - attached_root_path, is_edit_protected, parent_lane_id, color, icon, tags_json, status, created_at, archived_at - ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - `, - [ - lane.id, - projectId, - lane.name, - lane.description, - lane.laneType, - lane.baseRef, - lane.branchRef, - lane.worktreePath, - lane.attachedRootPath, - lane.isEditProtected ? 1 : 0, - lane.parentLaneId, - lane.color, - lane.icon, - JSON.stringify(lane.tags), - "active", - lane.createdAt, - lane.archivedAt, - ], - ); -} - -async function seedPr(db: any, args: { - prId: string; - projectId: string; - laneId: string; - baseBranch: string; - headBranch: string; - title: string; -}) { - const now = "2026-03-11T00:00:00.000Z"; - db.run( - ` - insert into pull_requests( - id, project_id, lane_id, repo_owner, repo_name, github_pr_number, github_url, github_node_id, - title, state, base_branch, head_branch, checks_status, review_status, additions, deletions, - last_synced_at, created_at, updated_at - ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - `, - [ - args.prId, - args.projectId, - args.laneId, - "acme", - "ade", - 101, - "https://example.com/pr/101", - null, - args.title, - "open", - args.baseBranch, - args.headBranch, - "passing", - "approved", - 0, - 0, - now, - now, - now, - ], - ); -} - -describe("prService.getMergeContext", () => { - it("returns base lane and integration lane separately for committed integration PRs", async () => { - const root = fs.mkdtempSync(path.join(os.tmpdir(), "ade-pr-merge-context-")); - const db = await openKvDb(path.join(root, ".ade.db"), createLogger()); - const projectId = "proj-merge-context"; - - const baseLane = makeLane("lane-main", "main", "refs/heads/main", { laneType: "primary", worktreePath: root }); - const sourceLaneA = makeLane("lane-a", "feature/a", "refs/heads/feature/a"); - const sourceLaneB = makeLane("lane-b", "feature/b", "refs/heads/feature/b"); - const integrationLane = makeLane("lane-int", "integration/search", "refs/heads/integration/search"); - - await seedProject(db, projectId, root); - await seedLane(db, projectId, baseLane); - await seedLane(db, projectId, sourceLaneA); - await seedLane(db, projectId, sourceLaneB); - await seedLane(db, projectId, integrationLane); - await seedPr(db, { - prId: "pr-int", - projectId, - laneId: integrationLane.id, - baseBranch: "main", - headBranch: "integration/search", - title: "Integration PR", - }); - - db.run(`insert into pr_groups(id, project_id, group_type, created_at) values (?, ?, 'integration', ?)`, [ - "group-int", - projectId, - "2026-03-11T00:00:00.000Z", - ]); - db.run( - `insert into pr_group_members(id, group_id, pr_id, lane_id, position, role) values (?, ?, ?, ?, ?, ?)`, - ["member-int", "group-int", "pr-int", integrationLane.id, 0, "integration"], - ); - db.run( - `insert into pr_group_members(id, group_id, pr_id, lane_id, position, role) values (?, ?, ?, ?, ?, ?)`, - ["member-a", "group-int", "pr-int", sourceLaneA.id, 1, "source"], - ); - db.run( - `insert into pr_group_members(id, group_id, pr_id, lane_id, position, role) values (?, ?, ?, ?, ?, ?)`, - ["member-b", "group-int", "pr-int", sourceLaneB.id, 2, "source"], - ); - - const service = createPrService({ - db, - logger: createLogger() as any, - projectId, - projectRoot: root, - laneService: { - list: async () => [sourceLaneA, sourceLaneB, integrationLane, baseLane], - } as any, - operationService: {} as any, - githubService: { apiRequest: async () => ({ data: {} }) } as any, - aiIntegrationService: undefined, - projectConfigService: {} as any, - conflictService: undefined, - openExternal: async () => {}, - }); - - await expect(service.getMergeContext("pr-int")).resolves.toMatchObject({ - prId: "pr-int", - groupId: "group-int", - groupType: "integration", - sourceLaneIds: ["lane-a", "lane-b"], - targetLaneId: "lane-main", - integrationLaneId: "lane-int", - }); - }); - - it("keeps integrationLaneId null for regular PRs", async () => { - const root = fs.mkdtempSync(path.join(os.tmpdir(), "ade-pr-merge-context-normal-")); - const db = await openKvDb(path.join(root, ".ade.db"), createLogger()); - const projectId = "proj-merge-context-normal"; - - const baseLane = makeLane("lane-main", "main", "refs/heads/main", { laneType: "primary", worktreePath: root }); - const sourceLane = makeLane("lane-auth", "feature/auth", "refs/heads/feature/auth"); - - await seedProject(db, projectId, root); - await seedLane(db, projectId, baseLane); - await seedLane(db, projectId, sourceLane); - await seedPr(db, { - prId: "pr-normal", - projectId, - laneId: sourceLane.id, - baseBranch: "main", - headBranch: "feature/auth", - title: "Normal PR", - }); - - const service = createPrService({ - db, - logger: createLogger() as any, - projectId, - projectRoot: root, - laneService: { - list: async () => [sourceLane, baseLane], - } as any, - operationService: {} as any, - githubService: { apiRequest: async () => ({ data: {} }) } as any, - aiIntegrationService: undefined, - projectConfigService: {} as any, - conflictService: undefined, - openExternal: async () => {}, - }); - - await expect(service.getMergeContext("pr-normal")).resolves.toMatchObject({ - prId: "pr-normal", - groupId: null, - groupType: null, - sourceLaneIds: ["lane-auth"], - targetLaneId: "lane-main", - integrationLaneId: null, - }); - }); - - it("does not infer a target lane from baseRef-only matches", async () => { - const root = fs.mkdtempSync(path.join(os.tmpdir(), "ade-pr-merge-context-base-ref-only-")); - const db = await openKvDb(path.join(root, ".ade.db"), createLogger()); - const projectId = "proj-merge-context-base-ref-only"; - - const sourceLane = makeLane("lane-auth", "feature/auth", "refs/heads/feature/auth"); - const siblingLane = makeLane("lane-other", "feature/other", "refs/heads/feature/other", { - baseRef: "refs/heads/main", - }); - - await seedProject(db, projectId, root); - await seedLane(db, projectId, sourceLane); - await seedLane(db, projectId, siblingLane); - await seedPr(db, { - prId: "pr-normal", - projectId, - laneId: sourceLane.id, - baseBranch: "main", - headBranch: "feature/auth", - title: "Normal PR", - }); - - const service = createPrService({ - db, - logger: createLogger() as any, - projectId, - projectRoot: root, - laneService: { - list: async () => [sourceLane, siblingLane], - } as any, - operationService: {} as any, - githubService: { apiRequest: async () => ({ data: {} }) } as any, - aiIntegrationService: undefined, - projectConfigService: {} as any, - conflictService: undefined, - openExternal: async () => {}, - }); - - await expect(service.getMergeContext("pr-normal")).resolves.toMatchObject({ - prId: "pr-normal", - sourceLaneIds: ["lane-auth"], - targetLaneId: null, - integrationLaneId: null, - }); - }); -}); diff --git a/apps/desktop/src/main/services/prs/prService.mergeInto.test.ts b/apps/desktop/src/main/services/prs/prService.mergeInto.test.ts deleted file mode 100644 index b4d91e935..000000000 --- a/apps/desktop/src/main/services/prs/prService.mergeInto.test.ts +++ /dev/null @@ -1,1257 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; - -// --------------------------------------------------------------------------- -// vi.hoisted mock state -// --------------------------------------------------------------------------- -const mockGit = vi.hoisted(() => ({ - runGit: vi.fn(), - runGitOrThrow: vi.fn(), - runGitMergeTree: vi.fn(), -})); - -// --------------------------------------------------------------------------- -// vi.mock — external dependencies -// --------------------------------------------------------------------------- - -vi.mock("../git/git", () => ({ - runGit: (...args: unknown[]) => mockGit.runGit(...args), - runGitOrThrow: (...args: unknown[]) => mockGit.runGitOrThrow(...args), - runGitMergeTree: (...args: unknown[]) => mockGit.runGitMergeTree(...args), -})); - -vi.mock("../ai/utils", () => ({ - extractFirstJsonObject: vi.fn(() => null), -})); - -vi.mock("./integrationPlanning", () => ({ - buildIntegrationPreflight: vi.fn(), -})); - -vi.mock("./integrationValidation", () => ({ - hasMergeConflictMarkers: vi.fn(() => false), - parseGitStatusPorcelain: vi.fn(() => ({ unmergedPaths: [], modifiedPaths: [] })), -})); - -vi.mock("../shared/queueRebase", () => ({ - fetchRemoteTrackingBranch: vi.fn(), -})); - -import { buildIntegrationPreflight } from "./integrationPlanning"; -import { createPrService } from "./prService"; - -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- - -function makeLogger() { - return { - info: vi.fn(), - warn: vi.fn(), - error: vi.fn(), - debug: vi.fn(), - } as any; -} - -function makeMockDb() { - return { - get: vi.fn(() => null), - all: vi.fn(() => []), - run: vi.fn(), - getJson: vi.fn(() => null), - setJson: vi.fn(), - sync: { getSiteId: vi.fn(), getDbVersion: vi.fn(), exportChangesSince: vi.fn(), applyChanges: vi.fn() }, - flushNow: vi.fn(), - close: vi.fn(), - } as any; -} - -const BASE_LANE_ID = "lane-base"; -const SOURCE_LANE_A_ID = "lane-a"; -const SOURCE_LANE_B_ID = "lane-b"; -const MERGE_INTO_LANE_ID = "lane-merge-into"; - -function makeFakeLane(overrides?: Partial>) { - return { - id: "lane-42", - name: "my-feature", - laneType: "worktree", - baseRef: "refs/heads/main", - branchRef: "refs/heads/my-feature", - worktreePath: "/tmp/lane-wt", - parentLaneId: null, - childCount: 0, - stackDepth: 0, - parentStatus: null, - isEditProtected: false, - status: { dirty: false }, - color: null, - icon: null, - tags: [], - createdAt: "2026-01-01T00:00:00Z", - ...overrides, - }; -} - -const baseLane = makeFakeLane({ - id: BASE_LANE_ID, - name: "main", - laneType: "primary", - branchRef: "refs/heads/main", - worktreePath: "/tmp/lane-base-wt", -}); - -const sourceLaneA = makeFakeLane({ - id: SOURCE_LANE_A_ID, - name: "feature-a", - branchRef: "refs/heads/feature-a", - worktreePath: "/tmp/lane-a-wt", - status: { dirty: false }, -}); - -const sourceLaneB = makeFakeLane({ - id: SOURCE_LANE_B_ID, - name: "feature-b", - branchRef: "refs/heads/feature-b", - worktreePath: "/tmp/lane-b-wt", - status: { dirty: false }, -}); - -const mergeIntoLane = makeFakeLane({ - id: MERGE_INTO_LANE_ID, - name: "develop", - branchRef: "refs/heads/develop", - worktreePath: "/tmp/lane-merge-into-wt", - status: { dirty: false }, -}); - -const integrationLane = makeFakeLane({ - id: "lane-integration", - name: "integration/test", - branchRef: "refs/heads/integration/test", - worktreePath: "/tmp/lane-integration-wt", -}); - -function makeGithubService(overrides?: Record) { - return { - getRepoOrThrow: vi.fn(async () => ({ owner: "test-owner", name: "test-repo" })), - apiRequest: vi.fn(), - getStatus: vi.fn(), - setToken: vi.fn(), - clearToken: vi.fn(), - getTokenOrThrow: vi.fn(() => "ghp_mock"), - ...overrides, - } as any; -} - -function makeLaneService(lanes?: unknown[]) { - return { - list: vi.fn(async () => lanes ?? [baseLane, sourceLaneA, sourceLaneB]), - getLaneBaseAndBranch: vi.fn(), - createChild: vi.fn(async () => integrationLane), - archive: vi.fn(async () => {}), - delete: vi.fn(async () => {}), - } as any; -} - -function makeOperationService() { - return { - start: vi.fn(() => ({ operationId: "op-1" })), - finish: vi.fn(), - } as any; -} - -function makeProjectConfigService() { - return { - get: vi.fn(() => ({ effective: { ai: {} } })), - } as any; -} - -interface BuildServiceOpts { - githubService?: any; - laneService?: any; - db?: any; - logger?: any; -} - -function buildService(opts: BuildServiceOpts = {}) { - const db = opts.db ?? makeMockDb(); - const logger = opts.logger ?? makeLogger(); - const githubService = opts.githubService ?? makeGithubService(); - const laneService = opts.laneService ?? makeLaneService(); - - mockGit.runGit.mockResolvedValue({ exitCode: 0, stdout: "", stderr: "" }); - mockGit.runGitOrThrow.mockResolvedValue(""); - mockGit.runGitMergeTree.mockResolvedValue({ exitCode: 0, treeOid: null, conflicts: [] }); - - const service = createPrService({ - db, - logger, - projectId: "proj-1", - projectRoot: "/tmp/test-project", - laneService, - operationService: makeOperationService(), - githubService, - projectConfigService: makeProjectConfigService(), - openExternal: vi.fn(async () => {}), - }); - - return { service, db, githubService, laneService, logger }; -} - -// --------------------------------------------------------------------------- -// Test Suite 1: updateIntegrationProposal with new fields -// --------------------------------------------------------------------------- - -describe("updateIntegrationProposal with new fields", () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - it("persists preferredIntegrationLaneId to DB", () => { - const { service, db } = buildService(); - - service.updateIntegrationProposal({ - proposalId: "prop-1", - preferredIntegrationLaneId: "lane-xyz", - }); - - expect(db.run).toHaveBeenCalledTimes(1); - const [sql, params] = db.run.mock.calls[0] as [string, unknown[]]; - expect(sql).toContain("preferred_integration_lane_id = ?"); - expect(params).toContain("lane-xyz"); - expect(params[params.length - 1]).toBe("prop-1"); - }); - - it("persists mergeIntoHeadSha to DB", () => { - const { service, db } = buildService(); - - service.updateIntegrationProposal({ - proposalId: "prop-1", - mergeIntoHeadSha: "abc123sha", - }); - - expect(db.run).toHaveBeenCalledTimes(1); - const [sql, params] = db.run.mock.calls[0] as [string, unknown[]]; - expect(sql).toContain("merge_into_head_sha = ?"); - expect(params).toContain("abc123sha"); - }); - - it("keeps merge-into previews out of the single-source proposal cleanup query", async () => { - const { service, db } = buildService(); - - await service.listIntegrationProposals(); - - const [sql] = db.run.mock.calls[0] as [string, unknown[]]; - expect(sql).toContain("preferred_integration_lane_id"); - expect(sql).toContain("merge_into_head_sha"); - }); - - it("trims whitespace from preferredIntegrationLaneId", () => { - const { service, db } = buildService(); - - service.updateIntegrationProposal({ - proposalId: "prop-1", - preferredIntegrationLaneId: " lane-xyz ", - }); - - const [, params] = db.run.mock.calls[0] as [string, unknown[]]; - expect(params).toContain("lane-xyz"); - }); - - it("sets preferredIntegrationLaneId to null when given empty string", () => { - const { service, db } = buildService(); - - service.updateIntegrationProposal({ - proposalId: "prop-1", - preferredIntegrationLaneId: "", - }); - - const [sql, params] = db.run.mock.calls[0] as [string, unknown[]]; - expect(sql).toContain("preferred_integration_lane_id = ?"); - expect(params[0]).toBeNull(); - }); - - it("clearIntegrationBinding sets integration_lane_id and resolution_state_json to null", () => { - const { service, db } = buildService(); - - service.updateIntegrationProposal({ - proposalId: "prop-1", - clearIntegrationBinding: true, - }); - - expect(db.run).toHaveBeenCalledTimes(1); - const [sql, params] = db.run.mock.calls[0] as [string, unknown[]]; - expect(sql).toContain("integration_lane_id = ?"); - expect(sql).toContain("resolution_state_json = ?"); - // Both values should be null - const nullCount = params.filter((p: unknown) => p === null).length; - expect(nullCount).toBe(2); - }); - - it("does nothing when no fields are set", () => { - const { service, db } = buildService(); - - service.updateIntegrationProposal({ - proposalId: "prop-1", - }); - - expect(db.run).not.toHaveBeenCalled(); - }); - - it("combines multiple new fields in a single update", () => { - const { service, db } = buildService(); - - service.updateIntegrationProposal({ - proposalId: "prop-1", - preferredIntegrationLaneId: "lane-xyz", - mergeIntoHeadSha: "sha-456", - clearIntegrationBinding: true, - }); - - expect(db.run).toHaveBeenCalledTimes(1); - const [sql, params] = db.run.mock.calls[0] as [string, unknown[]]; - expect(sql).toContain("preferred_integration_lane_id = ?"); - expect(sql).toContain("merge_into_head_sha = ?"); - expect(sql).toContain("integration_lane_id = ?"); - expect(sql).toContain("resolution_state_json = ?"); - // proposalId is the last param - expect(params[params.length - 1]).toBe("prop-1"); - }); -}); - -// --------------------------------------------------------------------------- -// Test Suite 2: createIntegrationPr with existingIntegrationLaneId -// --------------------------------------------------------------------------- - -describe("createIntegrationPr with existingIntegrationLaneId", () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - function setupPreflight() { - vi.mocked(buildIntegrationPreflight).mockReturnValue({ - baseLane: baseLane as any, - uniqueSourceLaneIds: [SOURCE_LANE_A_ID, SOURCE_LANE_B_ID], - duplicateSourceLaneIds: [], - missingSourceLaneIds: [], - }); - } - - it("reuses existing lane instead of calling createChild", async () => { - setupPreflight(); - const allLanes = [baseLane, sourceLaneA, sourceLaneB, mergeIntoLane]; - const laneService = makeLaneService(allLanes); - - mockGit.runGit.mockResolvedValue({ exitCode: 0, stdout: "", stderr: "" }); - const ghService = makeGithubService({ - apiRequest: vi.fn().mockResolvedValue({ - data: { - number: 42, - html_url: "https://github.com/test-owner/test-repo/pull/42", - node_id: "PR_node42", - title: "Integration PR", - state: "open", - draft: false, - merged_at: null, - head: { ref: "develop", sha: "abc123" }, - base: { ref: "main" }, - additions: 5, - deletions: 1, - }, - response: { status: 201, headers: new Headers() }, - }), - }); - const db = makeMockDb(); - let getCallCount = 0; - db.get.mockImplementation(() => { - getCallCount++; - if (getCallCount <= 1) return null; - return { - id: "fake-uuid", - lane_id: MERGE_INTO_LANE_ID, - project_id: "proj-1", - repo_owner: "test-owner", - repo_name: "test-repo", - github_pr_number: 42, - github_url: "https://github.com/test-owner/test-repo/pull/42", - github_node_id: "PR_node42", - title: "Integration PR", - state: "open", - base_branch: "main", - head_branch: "develop", - checks_status: "none", - review_status: "none", - additions: 5, - deletions: 1, - last_synced_at: null, - created_at: "2026-01-01T00:00:00Z", - updated_at: "2026-01-01T00:00:00Z", - }; - }); - - const { service: svc2 } = buildService({ - laneService, - githubService: ghService, - db, - }); - - await svc2.createIntegrationPr({ - sourceLaneIds: [SOURCE_LANE_A_ID, SOURCE_LANE_B_ID], - integrationLaneName: "integration/test", - baseBranch: "main", - title: "Integration PR", - existingIntegrationLaneId: MERGE_INTO_LANE_ID, - allowDirtyWorktree: true, - }); - - expect(laneService.createChild).not.toHaveBeenCalled(); - }); - - it("throws when existingIntegrationLaneId matches a source lane", async () => { - setupPreflight(); - const laneService = makeLaneService([baseLane, sourceLaneA, sourceLaneB]); - const { service } = buildService({ laneService }); - - await expect( - service.createIntegrationPr({ - sourceLaneIds: [SOURCE_LANE_A_ID, SOURCE_LANE_B_ID], - integrationLaneName: "integration/test", - baseBranch: "main", - title: "Integration PR", - existingIntegrationLaneId: SOURCE_LANE_A_ID, - allowDirtyWorktree: true, - }), - ).rejects.toThrow("Integration lane cannot be one of the source lanes."); - }); - - it("throws when existingIntegrationLaneId is not found among lanes", async () => { - setupPreflight(); - const laneService = makeLaneService([baseLane, sourceLaneA, sourceLaneB]); - const { service } = buildService({ laneService }); - - await expect( - service.createIntegrationPr({ - sourceLaneIds: [SOURCE_LANE_A_ID, SOURCE_LANE_B_ID], - integrationLaneName: "integration/test", - baseBranch: "main", - title: "Integration PR", - existingIntegrationLaneId: "nonexistent-lane", - allowDirtyWorktree: true, - }), - ).rejects.toThrow("Integration lane not found: nonexistent-lane"); - }); - - it("throws when existingIntegrationLaneId points at the primary lane", async () => { - setupPreflight(); - const laneService = makeLaneService([baseLane, sourceLaneA, sourceLaneB]); - const { service } = buildService({ laneService }); - - await expect( - service.createIntegrationPr({ - sourceLaneIds: [SOURCE_LANE_A_ID, SOURCE_LANE_B_ID], - integrationLaneName: "integration/test", - baseBranch: "main", - title: "Integration PR", - existingIntegrationLaneId: BASE_LANE_ID, - allowDirtyWorktree: true, - }), - ).rejects.toThrow("Integration lane cannot be the primary lane."); - }); - - it("does NOT archive integration lane on cleanup when it was adopted (not newly created)", async () => { - setupPreflight(); - const allLanes = [baseLane, sourceLaneA, sourceLaneB, mergeIntoLane]; - const laneService = makeLaneService(allLanes); - const { service } = buildService({ laneService }); - - // Force merge to throw so we enter the catch block - mockGit.runGit.mockRejectedValue(new Error("git merge crashed")); - - await expect( - service.createIntegrationPr({ - sourceLaneIds: [SOURCE_LANE_A_ID, SOURCE_LANE_B_ID], - integrationLaneName: "integration/test", - baseBranch: "main", - title: "Integration PR", - existingIntegrationLaneId: MERGE_INTO_LANE_ID, - allowDirtyWorktree: true, - }), - ).rejects.toThrow("git merge crashed"); - - // archive should NOT be called since we adopted an existing lane - expect(laneService.archive).not.toHaveBeenCalled(); - }); - - it("DOES archive integration lane on cleanup when it was newly created", async () => { - setupPreflight(); - const laneService = makeLaneService([baseLane, sourceLaneA, sourceLaneB]); - const { service } = buildService({ laneService }); - - // Force merge to throw - mockGit.runGit.mockRejectedValue(new Error("git merge crashed")); - - await expect( - service.createIntegrationPr({ - sourceLaneIds: [SOURCE_LANE_A_ID, SOURCE_LANE_B_ID], - integrationLaneName: "integration/test", - baseBranch: "main", - title: "Integration PR", - // no existingIntegrationLaneId — will create a new lane - allowDirtyWorktree: true, - }), - ).rejects.toThrow("git merge crashed"); - - // archive SHOULD be called since a new lane was created - expect(laneService.archive).toHaveBeenCalledWith({ laneId: "lane-integration" }); - }); - - it("includes existingIntegrationLaneId in dirty worktree checks", async () => { - setupPreflight(); - const dirtyMergeIntoLane = { - ...mergeIntoLane, - status: { dirty: true }, - }; - const allLanes = [baseLane, sourceLaneA, sourceLaneB, dirtyMergeIntoLane]; - const laneService = makeLaneService(allLanes); - const { service } = buildService({ laneService }); - - await expect( - service.createIntegrationPr({ - sourceLaneIds: [SOURCE_LANE_A_ID, SOURCE_LANE_B_ID], - integrationLaneName: "integration/test", - baseBranch: "main", - title: "Integration PR", - existingIntegrationLaneId: MERGE_INTO_LANE_ID, - // allowDirtyWorktree intentionally omitted - }), - ).rejects.toThrow(/Uncommitted changes/); - }); -}); - -// --------------------------------------------------------------------------- -// Test Suite 3: simulateIntegration with mergeIntoLaneId -// --------------------------------------------------------------------------- - -describe("simulateIntegration with mergeIntoLaneId", () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - function setupSimulationPreflight(sourceLaneIds: string[] = [SOURCE_LANE_A_ID, SOURCE_LANE_B_ID]) { - vi.mocked(buildIntegrationPreflight).mockReturnValue({ - baseLane: baseLane as any, - uniqueSourceLaneIds: sourceLaneIds, - duplicateSourceLaneIds: [], - missingSourceLaneIds: [], - }); - } - - function setupGitShaResolution() { - // rev-parse calls: baseSha, mergeIntoHeadSha, per-lane HEAD SHAs, rev-list, diff --shortstat - mockGit.runGitOrThrow.mockImplementation(async (args: string[]) => { - if (args[0] === "rev-parse") { - if (args[1] === "main") return "base-sha-000\n"; - if (args[1] === "develop") return "merge-into-sha-111\n"; - if (args[1] === "feature-a") return "head-sha-aaa\n"; - if (args[1] === "feature-b") return "head-sha-bbb\n"; - if (args[1] === "HEAD") return "head-sha-999\n"; - return "unknown-sha\n"; - } - if (args[0] === "rev-list") return "1\n"; - if (args[0] === "diff" && args[1] === "--shortstat") return " 1 file changed, 1 insertion(+)\n"; - // worktree add/remove - if (args[0] === "worktree") return ""; - return ""; - }); - } - - it("throws when mergeIntoLaneId matches a source lane", async () => { - setupSimulationPreflight(); - const allLanes = [baseLane, sourceLaneA, sourceLaneB, mergeIntoLane]; - const laneService = makeLaneService(allLanes); - const { service } = buildService({ laneService }); - setupGitShaResolution(); - - await expect( - service.simulateIntegration({ - sourceLaneIds: [SOURCE_LANE_A_ID, SOURCE_LANE_B_ID], - baseBranch: "main", - mergeIntoLaneId: SOURCE_LANE_A_ID, - }), - ).rejects.toThrow("Merge-into lane cannot be one of the source lanes."); - }); - - it("throws when mergeIntoLaneId is not found among lanes", async () => { - setupSimulationPreflight(); - const allLanes = [baseLane, sourceLaneA, sourceLaneB]; - const laneService = makeLaneService(allLanes); - const { service } = buildService({ laneService }); - setupGitShaResolution(); - - await expect( - service.simulateIntegration({ - sourceLaneIds: [SOURCE_LANE_A_ID, SOURCE_LANE_B_ID], - baseBranch: "main", - mergeIntoLaneId: "nonexistent-lane", - }), - ).rejects.toThrow("Merge-into lane not found: nonexistent-lane"); - }); - - it("throws when mergeIntoLaneId points at the primary lane", async () => { - setupSimulationPreflight(); - const allLanes = [baseLane, sourceLaneA, sourceLaneB]; - const laneService = makeLaneService(allLanes); - const { service } = buildService({ laneService }); - setupGitShaResolution(); - - await expect( - service.simulateIntegration({ - sourceLaneIds: [SOURCE_LANE_A_ID], - baseBranch: "main", - mergeIntoLaneId: BASE_LANE_ID, - }), - ).rejects.toThrow("Merge-into lane cannot be the primary lane."); - }); - - it("merge-into conflicts factor into lane outcomes", async () => { - setupSimulationPreflight([SOURCE_LANE_A_ID]); - const allLanes = [baseLane, sourceLaneA, mergeIntoLane]; - const laneService = makeLaneService(allLanes); - const db = makeMockDb(); - const { service } = buildService({ laneService, db }); - setupGitShaResolution(); - - mockGit.runGitMergeTree.mockImplementation(async (args: any) => { - if (args.branchA === "merge-into-sha-111") { - return { - exitCode: 1, - treeOid: "tree-oid-conflict", - conflicts: [{ path: "conflicting-file.ts" }], - }; - } - return { exitCode: 0, treeOid: null, conflicts: [] }; - }); - - mockGit.runGit.mockImplementation(async (args: string[]) => { - if (args[0] === "diff") { - return { exitCode: 0, stdout: "diff content", stderr: "" }; - } - if (args[0] === "merge") { - return { exitCode: 0, stdout: "", stderr: "" }; - } - if (args[0] === "show") { - return { exitCode: 0, stdout: "file content", stderr: "" }; - } - return { exitCode: 0, stdout: "", stderr: "" }; - }); - - const proposal = await service.simulateIntegration({ - sourceLaneIds: [SOURCE_LANE_A_ID], - baseBranch: "main", - mergeIntoLaneId: MERGE_INTO_LANE_ID, - persist: false, - }); - - const laneASummary = proposal.laneSummaries.find((s) => s.laneId === SOURCE_LANE_A_ID); - expect(laneASummary).toBeDefined(); - expect(laneASummary!.outcome).toBe("conflict"); - }); - - it("sequentialStartSha uses merge-into HEAD when provided", async () => { - setupSimulationPreflight([SOURCE_LANE_A_ID]); - const allLanes = [baseLane, sourceLaneA, mergeIntoLane]; - const laneService = makeLaneService(allLanes); - const { service } = buildService({ laneService }); - setupGitShaResolution(); - - // Track the worktree add call to verify sequentialStartSha - const worktreeAddCalls: string[][] = []; - mockGit.runGitOrThrow.mockImplementation(async (args: string[]) => { - if (args[0] === "worktree" && args[1] === "add") { - worktreeAddCalls.push(args); - } - if (args[0] === "rev-parse") { - if (args[1] === "main") return "base-sha-000\n"; - if (args[1] === "develop") return "merge-into-sha-111\n"; - if (args[1] === "feature-a") return "head-sha-aaa\n"; - return "unknown-sha\n"; - } - if (args[0] === "rev-list") return "1\n"; - if (args[0] === "diff" && args[1] === "--shortstat") return " 1 file changed, 1 insertion(+)\n"; - return ""; - }); - mockGit.runGitMergeTree.mockResolvedValue({ exitCode: 0, treeOid: null, conflicts: [] }); - mockGit.runGit.mockResolvedValue({ exitCode: 0, stdout: "", stderr: "" }); - - await service.simulateIntegration({ - sourceLaneIds: [SOURCE_LANE_A_ID], - baseBranch: "main", - mergeIntoLaneId: MERGE_INTO_LANE_ID, - persist: false, - }); - - // The worktree add should use merge-into HEAD sha, not base sha - const addCall = worktreeAddCalls.find((args) => args[1] === "add" && args[2] === "--detach"); - expect(addCall).toBeDefined(); - // The last arg in "worktree add --detach " is the sha - expect(addCall![addCall!.length - 1]).toBe("merge-into-sha-111"); - }); - - it("persists preferred_integration_lane_id and merge_into_head_sha in DB insert", async () => { - setupSimulationPreflight([SOURCE_LANE_A_ID]); - const allLanes = [baseLane, sourceLaneA, mergeIntoLane]; - const laneService = makeLaneService(allLanes); - const db = makeMockDb(); - const { service } = buildService({ laneService, db }); - setupGitShaResolution(); - mockGit.runGitMergeTree.mockResolvedValue({ exitCode: 0, treeOid: null, conflicts: [] }); - mockGit.runGit.mockResolvedValue({ exitCode: 0, stdout: "", stderr: "" }); - - await service.simulateIntegration({ - sourceLaneIds: [SOURCE_LANE_A_ID], - baseBranch: "main", - mergeIntoLaneId: MERGE_INTO_LANE_ID, - persist: true, - }); - - // Find the insert into integration_proposals - const insertCall = db.run.mock.calls.find( - (call: unknown[]) => typeof call[0] === "string" && (call[0] as string).includes("insert into integration_proposals"), - ); - expect(insertCall).toBeDefined(); - const [sql, params] = insertCall as [string, unknown[]]; - expect(sql).toContain("preferred_integration_lane_id"); - expect(sql).toContain("merge_into_head_sha"); - // The mergeIntoLaneId should be in the params - expect(params).toContain(MERGE_INTO_LANE_ID); - // The merge-into HEAD sha should be in the params - expect(params).toContain("merge-into-sha-111"); - }); - - it("returns preferredIntegrationLaneId and mergeIntoHeadSha in proposal object", async () => { - setupSimulationPreflight([SOURCE_LANE_A_ID]); - const allLanes = [baseLane, sourceLaneA, mergeIntoLane]; - const laneService = makeLaneService(allLanes); - const { service } = buildService({ laneService }); - setupGitShaResolution(); - mockGit.runGitMergeTree.mockResolvedValue({ exitCode: 0, treeOid: null, conflicts: [] }); - mockGit.runGit.mockResolvedValue({ exitCode: 0, stdout: "", stderr: "" }); - - const proposal = await service.simulateIntegration({ - sourceLaneIds: [SOURCE_LANE_A_ID], - baseBranch: "main", - mergeIntoLaneId: MERGE_INTO_LANE_ID, - persist: false, - }); - - expect(proposal.preferredIntegrationLaneId).toBe(MERGE_INTO_LANE_ID); - expect(proposal.mergeIntoHeadSha).toBe("merge-into-sha-111"); - }); - - it("sets preferredIntegrationLaneId and mergeIntoHeadSha to null when mergeIntoLaneId is not provided", async () => { - setupSimulationPreflight([SOURCE_LANE_A_ID]); - const allLanes = [baseLane, sourceLaneA]; - const laneService = makeLaneService(allLanes); - const { service } = buildService({ laneService }); - setupGitShaResolution(); - mockGit.runGitMergeTree.mockResolvedValue({ exitCode: 0, treeOid: null, conflicts: [] }); - mockGit.runGit.mockResolvedValue({ exitCode: 0, stdout: "", stderr: "" }); - - const proposal = await service.simulateIntegration({ - sourceLaneIds: [SOURCE_LANE_A_ID], - baseBranch: "main", - persist: false, - }); - - expect(proposal.preferredIntegrationLaneId).toBeNull(); - expect(proposal.mergeIntoHeadSha).toBeNull(); - }); - - it("clean outcome when merge-into has no conflicts", async () => { - setupSimulationPreflight([SOURCE_LANE_A_ID]); - const allLanes = [baseLane, sourceLaneA, mergeIntoLane]; - const laneService = makeLaneService(allLanes); - const { service } = buildService({ laneService }); - setupGitShaResolution(); - - // All merge-tree checks are clean - mockGit.runGitMergeTree.mockResolvedValue({ exitCode: 0, treeOid: null, conflicts: [] }); - mockGit.runGit.mockResolvedValue({ exitCode: 0, stdout: "", stderr: "" }); - - const proposal = await service.simulateIntegration({ - sourceLaneIds: [SOURCE_LANE_A_ID], - baseBranch: "main", - mergeIntoLaneId: MERGE_INTO_LANE_ID, - persist: false, - }); - - const laneASummary = proposal.laneSummaries.find((s) => s.laneId === SOURCE_LANE_A_ID); - expect(laneASummary).toBeDefined(); - expect(laneASummary!.outcome).toBe("clean"); - }); -}); - -// --------------------------------------------------------------------------- -// Test Suite 4: createIntegrationLaneForProposal with preferred lane adoption -// --------------------------------------------------------------------------- - -describe("createIntegrationLaneForProposal with preferred lane adoption", () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - function setupProposalPreflight() { - vi.mocked(buildIntegrationPreflight).mockReturnValue({ - baseLane: baseLane as any, - uniqueSourceLaneIds: [SOURCE_LANE_A_ID], - duplicateSourceLaneIds: [], - missingSourceLaneIds: [], - }); - } - - function makeProposalRow(overrides?: Record) { - return { - id: "prop-1", - source_lane_ids_json: JSON.stringify([SOURCE_LANE_A_ID]), - base_branch: "main", - steps_json: JSON.stringify([ - { laneId: SOURCE_LANE_A_ID, outcome: "clean", conflictingFiles: [], diffStat: { insertions: 0, deletions: 0, filesChanged: 0 } }, - ]), - overall_outcome: "clean", - integration_lane_name: "integration/test", - integration_lane_id: null, - preferred_integration_lane_id: null, - merge_into_head_sha: null, - resolution_state_json: null, - created_at: "2026-01-01T00:00:00Z", - ...overrides, - }; - } - - it("adopts preferred lane instead of creating a new child lane", async () => { - setupProposalPreflight(); - const allLanes = [baseLane, sourceLaneA, mergeIntoLane]; - const laneService = makeLaneService(allLanes); - const db = makeMockDb(); - db.get.mockReturnValue(makeProposalRow({ - preferred_integration_lane_id: MERGE_INTO_LANE_ID, - merge_into_head_sha: "stored-sha-111", - })); - - const logger = makeLogger(); - const { service } = buildService({ laneService, db, logger }); - - // rev-parse HEAD for drift check - mockGit.runGitOrThrow.mockResolvedValue("stored-sha-111\n"); - // Merges succeed - mockGit.runGit.mockResolvedValue({ exitCode: 0, stdout: "", stderr: "" }); - - const result = await service.createIntegrationLaneForProposal({ - proposalId: "prop-1", - allowDirtyWorktree: true, - }); - - expect(result.integrationLaneId).toBe(MERGE_INTO_LANE_ID); - expect(laneService.createChild).not.toHaveBeenCalled(); - }); - - it("warns about HEAD drift when stored sha differs from current", async () => { - setupProposalPreflight(); - const allLanes = [baseLane, sourceLaneA, mergeIntoLane]; - const laneService = makeLaneService(allLanes); - const db = makeMockDb(); - db.get.mockReturnValue(makeProposalRow({ - preferred_integration_lane_id: MERGE_INTO_LANE_ID, - merge_into_head_sha: "stored-sha-111", - })); - - const logger = makeLogger(); - const { service } = buildService({ laneService, db, logger }); - - // Current HEAD differs from stored - mockGit.runGitOrThrow.mockResolvedValue("different-sha-222\n"); - mockGit.runGit.mockResolvedValue({ exitCode: 0, stdout: "", stderr: "" }); - - await service.createIntegrationLaneForProposal({ - proposalId: "prop-1", - allowDirtyWorktree: true, - }); - - expect(logger.warn).toHaveBeenCalledWith( - "prs.integration_merge_into_head_drift", - expect.objectContaining({ - proposalId: "prop-1", - preferredIntegrationLaneId: MERGE_INTO_LANE_ID, - storedHead: "stored-sha-111", - currentHead: "different-sha-222", - }), - ); - }); - - it("does not warn when stored sha matches current HEAD", async () => { - setupProposalPreflight(); - const allLanes = [baseLane, sourceLaneA, mergeIntoLane]; - const laneService = makeLaneService(allLanes); - const db = makeMockDb(); - db.get.mockReturnValue(makeProposalRow({ - preferred_integration_lane_id: MERGE_INTO_LANE_ID, - merge_into_head_sha: "same-sha-111", - })); - - const logger = makeLogger(); - const { service } = buildService({ laneService, db, logger }); - - mockGit.runGitOrThrow.mockResolvedValue("same-sha-111\n"); - mockGit.runGit.mockResolvedValue({ exitCode: 0, stdout: "", stderr: "" }); - - await service.createIntegrationLaneForProposal({ - proposalId: "prop-1", - allowDirtyWorktree: true, - }); - - expect(logger.warn).not.toHaveBeenCalledWith( - "prs.integration_merge_into_head_drift", - expect.anything(), - ); - }); - - it("warns gracefully when HEAD read fails for preferred lane", async () => { - setupProposalPreflight(); - const allLanes = [baseLane, sourceLaneA, mergeIntoLane]; - const laneService = makeLaneService(allLanes); - const db = makeMockDb(); - db.get.mockReturnValue(makeProposalRow({ - preferred_integration_lane_id: MERGE_INTO_LANE_ID, - merge_into_head_sha: "stored-sha-111", - })); - - const logger = makeLogger(); - const { service } = buildService({ laneService, db, logger }); - - // rev-parse HEAD fails - mockGit.runGitOrThrow.mockRejectedValue(new Error("fatal: not a git repository")); - mockGit.runGit.mockResolvedValue({ exitCode: 0, stdout: "", stderr: "" }); - - await service.createIntegrationLaneForProposal({ - proposalId: "prop-1", - allowDirtyWorktree: true, - }); - - expect(logger.warn).toHaveBeenCalledWith( - "prs.integration_merge_into_head_read_failed", - expect.objectContaining({ - proposalId: "prop-1", - preferredIntegrationLaneId: MERGE_INTO_LANE_ID, - error: "fatal: not a git repository", - }), - ); - }); - - it("throws when preferred lane is not found", async () => { - setupProposalPreflight(); - const allLanes = [baseLane, sourceLaneA]; // mergeIntoLane NOT in allLanes - const laneService = makeLaneService(allLanes); - const db = makeMockDb(); - db.get.mockReturnValue(makeProposalRow({ - preferred_integration_lane_id: MERGE_INTO_LANE_ID, - })); - - const { service } = buildService({ laneService, db }); - - await expect( - service.createIntegrationLaneForProposal({ - proposalId: "prop-1", - allowDirtyWorktree: true, - }), - ).rejects.toThrow(`Preferred integration lane not found: ${MERGE_INTO_LANE_ID}`); - }); - - it("throws when preferred lane is one of the source lanes", async () => { - vi.mocked(buildIntegrationPreflight).mockReturnValue({ - baseLane: baseLane as any, - uniqueSourceLaneIds: [SOURCE_LANE_A_ID, MERGE_INTO_LANE_ID], - duplicateSourceLaneIds: [], - missingSourceLaneIds: [], - }); - const allLanes = [baseLane, sourceLaneA, mergeIntoLane]; - const laneService = makeLaneService(allLanes); - const db = makeMockDb(); - db.get.mockReturnValue(makeProposalRow({ - source_lane_ids_json: JSON.stringify([SOURCE_LANE_A_ID, MERGE_INTO_LANE_ID]), - steps_json: JSON.stringify([ - { laneId: SOURCE_LANE_A_ID, outcome: "clean", conflictingFiles: [], diffStat: { insertions: 0, deletions: 0, filesChanged: 0 } }, - { laneId: MERGE_INTO_LANE_ID, outcome: "clean", conflictingFiles: [], diffStat: { insertions: 0, deletions: 0, filesChanged: 0 } }, - ]), - preferred_integration_lane_id: MERGE_INTO_LANE_ID, - })); - - const { service } = buildService({ laneService, db }); - - await expect( - service.createIntegrationLaneForProposal({ - proposalId: "prop-1", - allowDirtyWorktree: true, - }), - ).rejects.toThrow("Preferred integration lane cannot be one of the source lanes."); - }); - - it("throws when preferred lane points at the primary lane", async () => { - setupProposalPreflight(); - const allLanes = [baseLane, sourceLaneA]; - const laneService = makeLaneService(allLanes); - const db = makeMockDb(); - db.get.mockReturnValue(makeProposalRow({ - preferred_integration_lane_id: BASE_LANE_ID, - })); - - const { service } = buildService({ laneService, db }); - - await expect( - service.createIntegrationLaneForProposal({ - proposalId: "prop-1", - allowDirtyWorktree: true, - }), - ).rejects.toThrow("Preferred integration lane cannot be the primary lane."); - }); - - it("creates child lane when no preferred lane is set", async () => { - setupProposalPreflight(); - const allLanes = [baseLane, sourceLaneA]; - const laneService = makeLaneService(allLanes); - const db = makeMockDb(); - db.get.mockReturnValue(makeProposalRow({ - preferred_integration_lane_id: null, - })); - - const { service } = buildService({ laneService, db }); - - mockGit.runGitOrThrow.mockResolvedValue(""); - mockGit.runGit.mockResolvedValue({ exitCode: 0, stdout: "", stderr: "" }); - - const result = await service.createIntegrationLaneForProposal({ - proposalId: "prop-1", - allowDirtyWorktree: true, - }); - - // createChild SHOULD be called since no preferred lane - expect(laneService.createChild).toHaveBeenCalledWith( - expect.objectContaining({ - parentLaneId: BASE_LANE_ID, - name: "integration/test", - }), - ); - expect(result.integrationLaneId).toBe("lane-integration"); - }); - - it("includes preferred lane in dirty worktree checks", async () => { - setupProposalPreflight(); - const dirtyMergeIntoLane = { - ...mergeIntoLane, - status: { dirty: true }, - }; - const allLanes = [baseLane, sourceLaneA, dirtyMergeIntoLane]; - const laneService = makeLaneService(allLanes); - const db = makeMockDb(); - db.get.mockReturnValue(makeProposalRow({ - preferred_integration_lane_id: MERGE_INTO_LANE_ID, - })); - - const { service } = buildService({ laneService, db }); - - await expect( - service.createIntegrationLaneForProposal({ - proposalId: "prop-1", - // allowDirtyWorktree intentionally omitted - }), - ).rejects.toThrow(/Uncommitted changes/); - }); -}); - -describe("commitIntegration dirty-worktree retries", () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - it("propagates allowDirtyWorktree when preparing a new integration lane", async () => { - vi.mocked(buildIntegrationPreflight).mockReturnValue({ - baseLane: baseLane as any, - uniqueSourceLaneIds: [SOURCE_LANE_A_ID], - duplicateSourceLaneIds: [], - missingSourceLaneIds: [], - }); - - const dirtySourceLane = { - ...sourceLaneA, - status: { dirty: true }, - }; - const laneService = makeLaneService([baseLane, dirtySourceLane]); - laneService.list.mockResolvedValue([baseLane, dirtySourceLane, integrationLane]); - const db = makeMockDb(); - db.get.mockImplementation((sql: string) => { - if (sql.includes("from integration_proposals")) { - return { - id: "prop-dirty", - source_lane_ids_json: JSON.stringify([SOURCE_LANE_A_ID]), - base_branch: "main", - steps_json: JSON.stringify([ - { laneId: SOURCE_LANE_A_ID, laneName: dirtySourceLane.name, position: 0, outcome: "clean", conflictingFiles: [], diffStat: { insertions: 0, deletions: 0, filesChanged: 0 } }, - ]), - integration_lane_id: null, - integration_lane_name: "integration/test", - preferred_integration_lane_id: null, - overall_outcome: "clean", - merge_into_head_sha: null, - resolution_state_json: null, - created_at: "2026-01-01T00:00:00Z", - }; - } - if (sql.includes("from pull_requests where id")) { - return { - id: "pr-integration", - lane_id: integrationLane.id, - repo_owner: "test-owner", - repo_name: "test-repo", - github_pr_number: 42, - github_url: "https://github.com/test-owner/test-repo/pull/42", - github_node_id: "PR_node42", - title: "Integration PR", - state: "open", - base_branch: "main", - head_branch: "integration/test", - checks_status: "none", - review_status: "none", - additions: 5, - deletions: 1, - last_synced_at: null, - created_at: "2026-01-01T00:00:00Z", - updated_at: "2026-01-01T00:00:00Z", - }; - } - if (sql.includes("from pull_requests where lane_id")) { - return null; - } - return null; - }); - - const githubService = makeGithubService({ - apiRequest: vi.fn().mockResolvedValue({ - data: { - number: 42, - html_url: "https://github.com/test-owner/test-repo/pull/42", - node_id: "PR_node42", - title: "Integration PR", - state: "open", - draft: false, - merged_at: null, - additions: 5, - deletions: 1, - }, - response: { status: 201, headers: new Headers() }, - }), - }); - - mockGit.runGit.mockResolvedValue({ exitCode: 0, stdout: "", stderr: "" }); - - const { service } = buildService({ laneService, db, githubService }); - - await expect( - service.commitIntegration({ - proposalId: "prop-dirty", - integrationLaneName: "integration/test", - title: "Integration PR", - body: "", - draft: false, - allowDirtyWorktree: true, - }), - ).resolves.toMatchObject({ - integrationLaneId: "lane-integration", - pr: expect.objectContaining({ - laneId: "lane-integration", - githubPrNumber: 42, - }), - }); - - expect(laneService.createChild).toHaveBeenCalledOnce(); - }); -}); - -describe("adopted integration lane cleanup", () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - it("does not delete an adopted merge-into lane when deleting a proposal", async () => { - const laneService = makeLaneService([baseLane, sourceLaneA, mergeIntoLane]); - const db = makeMockDb(); - db.get.mockReturnValue({ - id: "prop-adopted", - integration_lane_id: MERGE_INTO_LANE_ID, - preferred_integration_lane_id: MERGE_INTO_LANE_ID, - }); - - const { service } = buildService({ laneService, db }); - - await expect( - service.deleteIntegrationProposal({ - proposalId: "prop-adopted", - deleteIntegrationLane: true, - }), - ).resolves.toMatchObject({ - proposalId: "prop-adopted", - integrationLaneId: MERGE_INTO_LANE_ID, - deletedIntegrationLane: false, - }); - - expect(laneService.delete).not.toHaveBeenCalled(); - }); - - it("skips archiving an adopted merge-into lane during workflow cleanup", async () => { - const laneService = makeLaneService([baseLane, sourceLaneA, mergeIntoLane]); - const db = makeMockDb(); - db.get.mockReturnValue({ - id: "prop-adopted", - project_id: "proj-1", - source_lane_ids_json: JSON.stringify([SOURCE_LANE_A_ID]), - base_branch: "main", - steps_json: JSON.stringify([]), - overall_outcome: "clean", - created_at: "2026-01-01T00:00:00Z", - title: "", - body: "", - draft: 0, - integration_lane_name: mergeIntoLane.name, - status: "committed", - integration_lane_id: MERGE_INTO_LANE_ID, - preferred_integration_lane_id: MERGE_INTO_LANE_ID, - resolution_state_json: null, - pairwise_results_json: "[]", - lane_summaries_json: "[]", - linked_group_id: null, - linked_pr_id: null, - workflow_display_state: "active", - cleanup_state: "required", - closed_at: null, - merged_at: null, - completed_at: null, - cleanup_declined_at: null, - cleanup_completed_at: null, - merge_into_head_sha: "sha-merge-into", - }); - - const { service } = buildService({ laneService, db }); - - await expect( - service.cleanupIntegrationWorkflow({ - proposalId: "prop-adopted", - }), - ).resolves.toMatchObject({ - proposalId: "prop-adopted", - archivedLaneIds: [], - skippedLaneIds: [MERGE_INTO_LANE_ID], - workflowDisplayState: "history", - cleanupState: "completed", - }); - - expect(laneService.archive).not.toHaveBeenCalled(); - }); -}); diff --git a/apps/desktop/src/main/services/prs/prService.mobileSnapshot.test.ts b/apps/desktop/src/main/services/prs/prService.mobileSnapshot.test.ts deleted file mode 100644 index fb2f221e4..000000000 --- a/apps/desktop/src/main/services/prs/prService.mobileSnapshot.test.ts +++ /dev/null @@ -1,488 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; - -// --------------------------------------------------------------------------- -// vi.hoisted mock state -// --------------------------------------------------------------------------- -const mockGit = vi.hoisted(() => ({ - runGit: vi.fn(), - runGitOrThrow: vi.fn(), - runGitMergeTree: vi.fn(), -})); - -// --------------------------------------------------------------------------- -// vi.mock — external dependencies -// --------------------------------------------------------------------------- - -vi.mock("../git/git", () => ({ - runGit: (...args: unknown[]) => mockGit.runGit(...args), - runGitOrThrow: (...args: unknown[]) => mockGit.runGitOrThrow(...args), - runGitMergeTree: (...args: unknown[]) => mockGit.runGitMergeTree(...args), -})); - -vi.mock("../ai/utils", () => ({ - extractFirstJsonObject: vi.fn(() => null), -})); - -vi.mock("./integrationPlanning", () => ({ - buildIntegrationPreflight: vi.fn(), -})); - -vi.mock("./integrationValidation", () => ({ - hasMergeConflictMarkers: vi.fn(() => false), - parseGitStatusPorcelain: vi.fn(() => []), -})); - -vi.mock("../shared/queueRebase", () => ({ - fetchRemoteTrackingBranch: vi.fn(), -})); - -import { createPrService } from "./prService"; - -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- - -function makeLogger() { - return { - info: vi.fn(), - warn: vi.fn(), - error: vi.fn(), - debug: vi.fn(), - } as any; -} - -function makeLane(overrides: Partial>) { - return { - id: "lane-default", - name: "lane-default", - description: null, - laneType: "worktree", - baseRef: "refs/heads/main", - branchRef: "refs/heads/lane-default", - worktreePath: "/tmp/lane-default", - parentLaneId: null, - childCount: 0, - stackDepth: 0, - parentStatus: null, - isEditProtected: false, - status: { dirty: false, ahead: 0, behind: 0, remoteBehind: 0, rebaseInProgress: false }, - color: null, - icon: null, - tags: [], - createdAt: "2026-04-01T00:00:00Z", - ...overrides, - }; -} - -function makePrRow(overrides: Partial>) { - return { - id: "pr-1", - lane_id: "lane-1", - project_id: "proj-1", - repo_owner: "owner", - repo_name: "repo", - github_pr_number: 42, - github_url: "https://github.com/owner/repo/pull/42", - github_node_id: "PR_node1", - title: "Feature A", - state: "open", - base_branch: "main", - head_branch: "feat-a", - checks_status: "passing", - review_status: "approved", - additions: 10, - deletions: 2, - last_synced_at: "2026-04-01T00:00:00Z", - created_at: "2026-04-01T00:00:00Z", - updated_at: "2026-04-01T00:00:00Z", - creation_strategy: null as string | null, - ...overrides, - }; -} - -function buildService(opts: { - prRows?: ReturnType[]; - lanes?: ReturnType[]; - queueRows?: unknown[]; - rebaseSuggestions?: unknown[]; -}) { - const prRows = opts.prRows ?? []; - const lanes = opts.lanes ?? []; - const queueRows = opts.queueRows ?? []; - const rebaseSuggestions = opts.rebaseSuggestions ?? []; - - const db = { - get: vi.fn((sql: string, params?: unknown[]) => { - // Collapse whitespace so multi-line SQL still matches substring checks. - const sqlLower = sql.toLowerCase().replace(/\s+/g, " ").trim(); - if (sqlLower.includes("from lanes where id =")) { - const laneId = (params?.[0] ?? "") as string; - const lane = lanes.find((l) => l.id === laneId); - return lane ? { name: lane.name } : null; - } - if ( - sqlLower.includes("select creation_strategy from pull_requests where lane_id =") - ) { - const laneId = (params?.[0] ?? "") as string; - const row = prRows.find( - (r) => r.lane_id === laneId && (r.state === "open" || r.state === "draft"), - ); - return row ? { creation_strategy: row.creation_strategy ?? null } : null; - } - if (sqlLower.includes("from pull_requests where lane_id =")) { - const laneId = (params?.[0] ?? "") as string; - const row = prRows.find((r) => r.lane_id === laneId); - return row ?? null; - } - if (sqlLower.includes("from pull_requests where id =")) { - const prId = (params?.[0] ?? "") as string; - const row = prRows.find((r) => r.id === prId); - return row ?? null; - } - return null; - }), - all: vi.fn((sql: string) => { - const sqlLower = sql.toLowerCase(); - if (sqlLower.includes("from pull_requests")) return prRows; - if (sqlLower.includes("from queue_landing_state")) return queueRows; - if (sqlLower.includes("from integration_proposals")) return []; - return []; - }), - run: vi.fn(), - getJson: vi.fn(() => null), - setJson: vi.fn(), - sync: { getSiteId: vi.fn(), getDbVersion: vi.fn(), exportChangesSince: vi.fn(), applyChanges: vi.fn() }, - flushNow: vi.fn(), - close: vi.fn(), - } as any; - - const laneService = { - list: vi.fn(async () => lanes), - getLaneBaseAndBranch: vi.fn(), - getStackChain: vi.fn(), - } as any; - - const rebaseSuggestionService = { - listSuggestions: vi.fn(async () => rebaseSuggestions), - } as any; - - const service = createPrService({ - db, - logger: makeLogger(), - projectId: "proj-1", - projectRoot: "/tmp/test", - laneService, - operationService: { start: vi.fn(() => ({ operationId: "op-1" })), finish: vi.fn() } as any, - githubService: { - getRepoOrThrow: vi.fn(async () => ({ owner: "owner", name: "repo" })), - apiRequest: vi.fn(), - getStatus: vi.fn(), - setToken: vi.fn(), - clearToken: vi.fn(), - getTokenOrThrow: vi.fn(() => "ghp_mock"), - } as any, - projectConfigService: { get: vi.fn(() => ({ effective: { ai: {} } })) } as any, - rebaseSuggestionService, - openExternal: vi.fn(async () => {}), - }); - - return { service, db, laneService }; -} - -// --------------------------------------------------------------------------- -// Tests -// --------------------------------------------------------------------------- - -describe("prService.getMobileSnapshot", () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - it("returns an empty snapshot when there are no PRs or lanes", async () => { - const { service } = buildService({}); - const snapshot = await service.getMobileSnapshot(); - - expect(snapshot.prs).toEqual([]); - expect(snapshot.stacks).toEqual([]); - expect(snapshot.workflowCards).toEqual([]); - expect(snapshot.createCapabilities.canCreateAny).toBe(false); - expect(snapshot.createCapabilities.lanes).toEqual([]); - expect(snapshot.live).toBe(true); - expect(snapshot.generatedAt).toMatch(/^\d{4}-\d{2}-\d{2}T/); - }); - - it("emits ordered stack members with role and PR metadata", async () => { - const rootLane = makeLane({ id: "lane-root", name: "root", parentLaneId: null }); - const childLane = makeLane({ - id: "lane-child", - name: "child", - parentLaneId: "lane-root", - status: { dirty: true, ahead: 1, behind: 0, remoteBehind: 0, rebaseInProgress: false }, - }); - const rootPr = makePrRow({ id: "pr-root", lane_id: "lane-root", github_pr_number: 1, title: "root pr" }); - const childPr = makePrRow({ - id: "pr-child", - lane_id: "lane-child", - github_pr_number: 2, - title: "child pr", - state: "draft", - checks_status: "failing", - }); - - const { service } = buildService({ - lanes: [rootLane, childLane], - prRows: [rootPr, childPr], - }); - - const snapshot = await service.getMobileSnapshot(); - - expect(snapshot.stacks).toHaveLength(1); - const stack = snapshot.stacks[0]; - expect(stack.rootLaneId).toBe("lane-root"); - expect(stack.prCount).toBe(2); - expect(stack.size).toBe(2); - expect(stack.members.map((m) => m.laneId)).toEqual(["lane-root", "lane-child"]); - expect(stack.members[0].role).toBe("root"); - expect(stack.members[0].depth).toBe(0); - expect(stack.members[0].prNumber).toBe(1); - expect(stack.members[0].dirty).toBe(false); - expect(stack.members[1].role).toBe("leaf"); - expect(stack.members[1].depth).toBe(1); - expect(stack.members[1].dirty).toBe(true); - expect(stack.members[1].checksStatus).toBe("failing"); - expect(stack.members[1].prState).toBe("draft"); - }); - - it("computes per-PR action capability gates with block reasons", async () => { - const lane = makeLane({ id: "lane-1" }); - const openPr = makePrRow({ id: "pr-open", lane_id: "lane-1", state: "open", checks_status: "passing" }); - const draftPr = makePrRow({ id: "pr-draft", lane_id: "lane-2", state: "draft" }); - const failingPr = makePrRow({ id: "pr-fail", lane_id: "lane-3", state: "open", checks_status: "failing" }); - const mergedPr = makePrRow({ id: "pr-merged", lane_id: "lane-4", state: "merged" }); - - const { service } = buildService({ - lanes: [lane], - prRows: [openPr, draftPr, failingPr, mergedPr], - }); - - const snapshot = await service.getMobileSnapshot(); - - expect(snapshot.capabilities["pr-open"].canMerge).toBe(true); - expect(snapshot.capabilities["pr-open"].mergeBlockedReason).toBeNull(); - - expect(snapshot.capabilities["pr-draft"].canMerge).toBe(false); - expect(snapshot.capabilities["pr-draft"].mergeBlockedReason).toMatch(/Draft/); - - expect(snapshot.capabilities["pr-fail"].canMerge).toBe(false); - expect(snapshot.capabilities["pr-fail"].mergeBlockedReason).toMatch(/failing/); - - expect(snapshot.capabilities["pr-merged"].canMerge).toBe(false); - expect(snapshot.capabilities["pr-merged"].mergeBlockedReason).toMatch(/merged/); - expect(snapshot.capabilities["pr-merged"].canReopen).toBe(false); - - for (const cap of Object.values(snapshot.capabilities)) { - expect(cap.requiresLive).toBe(true); - } - }); - - it("surfaces create-PR eligibility and flags lanes that already have a PR", async () => { - const primary = makeLane({ - id: "lane-primary", - name: "main", - laneType: "primary", - baseRef: "refs/heads/main", - branchRef: "refs/heads/main", - }); - const eligible = makeLane({ id: "lane-feat", name: "feat", parentLaneId: null }); - const blocked = makeLane({ id: "lane-blocked", name: "blocked", parentLaneId: null }); - const existingPr = makePrRow({ id: "pr-b", lane_id: "lane-blocked", state: "open", github_pr_number: 99 }); - - const { service } = buildService({ - lanes: [primary, eligible, blocked], - prRows: [existingPr], - }); - - const snapshot = await service.getMobileSnapshot(); - - expect(snapshot.createCapabilities.canCreateAny).toBe(true); - expect(snapshot.createCapabilities.defaultBaseBranch).toBe("main"); - const laneIds = snapshot.createCapabilities.lanes.map((lane) => lane.laneId).sort(); - expect(laneIds).toEqual(["lane-blocked", "lane-feat"]); - - const blockedEntry = snapshot.createCapabilities.lanes.find((lane) => lane.laneId === "lane-blocked")!; - expect(blockedEntry.canCreate).toBe(false); - expect(blockedEntry.hasExistingPr).toBe(true); - expect(blockedEntry.blockedReason).toMatch(/#99/); - - const eligibleEntry = snapshot.createCapabilities.lanes.find((lane) => lane.laneId === "lane-feat")!; - expect(eligibleEntry.canCreate).toBe(true); - expect(eligibleEntry.blockedReason).toBeNull(); - expect(eligibleEntry.commitsAheadOfBase).toBe(0); - }); - - it("includes queue and rebase workflow cards and skips completed queues", async () => { - const lane = makeLane({ id: "lane-1", name: "my-feature" }); - const parent = makeLane({ id: "lane-parent", name: "release", laneType: "primary" }); - const queueActive = { - id: "queue-1", - group_id: "group-1", - state: "landing", - entries_json: JSON.stringify([{ prId: "a" }, { prId: "b" }]), - current_position: 0, - started_at: "2026-04-01T00:00:00Z", - completed_at: null, - active_pr_id: "pr-a", - wait_reason: null, - last_error: null, - updated_at: "2026-04-01T00:05:00Z", - }; - const queueCompleted = { - id: "queue-2", - group_id: "group-2", - state: "completed", - entries_json: "[]", - current_position: 0, - started_at: "2026-04-01T00:00:00Z", - completed_at: "2026-04-01T01:00:00Z", - active_pr_id: null, - wait_reason: null, - last_error: null, - updated_at: null, - }; - - const rebaseSuggestion = { - laneId: "lane-1", - parentLaneId: "lane-parent", - parentHeadSha: "abc", - behindCount: 3, - baseLabel: "release", - lastSuggestedAt: "2026-04-01T00:00:00Z", - deferredUntil: null, - dismissedAt: null, - hasPr: false, - }; - - const { service } = buildService({ - lanes: [lane, parent], - queueRows: [queueActive, queueCompleted], - rebaseSuggestions: [rebaseSuggestion], - }); - - const snapshot = await service.getMobileSnapshot(); - - const queueCards = snapshot.workflowCards.filter((card) => card.kind === "queue"); - expect(queueCards).toHaveLength(1); - expect(queueCards[0]).toMatchObject({ - kind: "queue", - groupId: "group-1", - state: "landing", - totalEntries: 2, - activePrId: "pr-a", - }); - - const rebaseCards = snapshot.workflowCards.filter((card) => card.kind === "rebase"); - expect(rebaseCards).toHaveLength(1); - expect(rebaseCards[0]).toMatchObject({ - kind: "rebase", - laneId: "lane-1", - laneName: "my-feature", - behindBy: 3, - baseBranch: "release", - }); - // No linked PR → default rebaseMode is "auto" and creationStrategy is null. - expect((rebaseCards[0] as any).rebaseMode).toBe("auto"); - expect((rebaseCards[0] as any).creationStrategy).toBeNull(); - }); - - it("marks rebase card rebaseMode as auto when the linked PR uses pr_target strategy", async () => { - const lane = makeLane({ id: "lane-1", name: "my-feature" }); - const parent = makeLane({ id: "lane-parent", name: "release", laneType: "primary" }); - const pr = makePrRow({ - id: "pr-target", - lane_id: "lane-1", - state: "open", - creation_strategy: "pr_target", - }); - const rebaseSuggestion = { - laneId: "lane-1", - parentLaneId: "lane-parent", - parentHeadSha: "abc", - behindCount: 2, - baseLabel: "release", - lastSuggestedAt: "2026-04-01T00:00:00Z", - deferredUntil: null, - dismissedAt: null, - hasPr: true, - }; - - const { service } = buildService({ - lanes: [lane, parent], - prRows: [pr], - rebaseSuggestions: [rebaseSuggestion], - }); - - const snapshot = await service.getMobileSnapshot(); - const rebaseCards = snapshot.workflowCards.filter((card) => card.kind === "rebase"); - expect(rebaseCards).toHaveLength(1); - expect((rebaseCards[0] as any).rebaseMode).toBe("auto"); - expect((rebaseCards[0] as any).creationStrategy).toBe("pr_target"); - }); - - it("marks rebase card rebaseMode as manual when the linked PR uses lane_base strategy", async () => { - const lane = makeLane({ id: "lane-1", name: "my-feature" }); - const parent = makeLane({ id: "lane-parent", name: "release", laneType: "primary" }); - const pr = makePrRow({ - id: "pr-lane-base", - lane_id: "lane-1", - state: "open", - creation_strategy: "lane_base", - }); - const rebaseSuggestion = { - laneId: "lane-1", - parentLaneId: "lane-parent", - parentHeadSha: "abc", - behindCount: 4, - baseLabel: "release", - lastSuggestedAt: "2026-04-01T00:00:00Z", - deferredUntil: null, - dismissedAt: null, - hasPr: true, - }; - - const { service } = buildService({ - lanes: [lane, parent], - prRows: [pr], - rebaseSuggestions: [rebaseSuggestion], - }); - - const snapshot = await service.getMobileSnapshot(); - const rebaseCards = snapshot.workflowCards.filter((card) => card.kind === "rebase"); - expect(rebaseCards).toHaveLength(1); - expect((rebaseCards[0] as any).rebaseMode).toBe("manual"); - expect((rebaseCards[0] as any).creationStrategy).toBe("lane_base"); - }); - - it("skips dismissed rebase suggestions", async () => { - const lane = makeLane({ id: "lane-1", name: "my-feature" }); - const parent = makeLane({ id: "lane-parent", name: "release", laneType: "primary" }); - const dismissed = { - laneId: "lane-1", - parentLaneId: "lane-parent", - parentHeadSha: "abc", - behindCount: 3, - baseLabel: "release", - lastSuggestedAt: "2026-04-01T00:00:00Z", - deferredUntil: null, - dismissedAt: "2026-04-02T00:00:00Z", - hasPr: false, - }; - - const { service } = buildService({ - lanes: [lane, parent], - rebaseSuggestions: [dismissed], - }); - - const snapshot = await service.getMobileSnapshot(); - expect(snapshot.workflowCards.filter((card) => card.kind === "rebase")).toHaveLength(0); - }); -}); diff --git a/apps/desktop/src/main/services/prs/prService.reviewPublication.test.ts b/apps/desktop/src/main/services/prs/prService.reviewPublication.test.ts deleted file mode 100644 index 261e1f6f7..000000000 --- a/apps/desktop/src/main/services/prs/prService.reviewPublication.test.ts +++ /dev/null @@ -1,221 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; -import { createPrService } from "./prService"; - -function makeLogger() { - return { - info: vi.fn(), - warn: vi.fn(), - error: vi.fn(), - debug: vi.fn(), - } as any; -} - -function makeMockDb() { - return { - get: vi.fn((sql: string) => { - if (!sql.includes("from pull_requests")) return null; - return { - id: "pr-80", - lane_id: "lane-1", - project_id: "proj-1", - repo_owner: "test-owner", - repo_name: "test-repo", - github_pr_number: 80, - github_url: "https://github.com/test-owner/test-repo/pull/80", - github_node_id: "PR_kwDOExample", - title: "Review publication", - state: "open", - base_branch: "main", - head_branch: "feature/pr-80", - checks_status: "passing", - review_status: "commented", - additions: 2, - deletions: 0, - last_synced_at: "2026-04-06T10:00:00.000Z", - created_at: "2026-04-06T09:55:00.000Z", - updated_at: "2026-04-06T10:00:00.000Z", - }; - }), - all: vi.fn(() => []), - run: vi.fn(), - } as any; -} - -function makeLaneService() { - return { - list: vi.fn(async () => []), - getLaneBaseAndBranch: vi.fn(), - } as any; -} - -describe("prService.publishReviewPublication", () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - it("posts anchored findings inline and routes unanchored findings into the review summary", async () => { - const apiRequest = vi.fn(async ({ method, path, body }: { method: string; path: string; body?: Record }) => { - if (method === "GET" && path === "/repos/test-owner/test-repo/pulls/80") { - return { - data: { - number: 80, - html_url: "https://github.com/test-owner/test-repo/pull/80", - node_id: "PR_kwDOExample", - title: "Review publication", - state: "open", - draft: false, - merged_at: null, - head: { ref: "feature/pr-80", sha: "def456789012" }, - base: { ref: "main", sha: "abc123456789" }, - additions: 2, - deletions: 0, - }, - }; - } - if (method === "GET" && path === "/repos/test-owner/test-repo/pulls/80/files") { - return { - data: [ - { - filename: "src/review.ts", - status: "modified", - additions: 2, - deletions: 0, - patch: "@@ -10,1 +10,3 @@\n context\n+anchored\n+summary only\n", - previous_filename: null, - }, - ], - }; - } - if (method === "GET" && path === "/repos/test-owner/test-repo/commits/def456789012/status") { - return { data: { state: "success", statuses: [] } }; - } - if (method === "GET" && path === "/repos/test-owner/test-repo/commits/def456789012/check-runs") { - return { data: { check_runs: [] } }; - } - if (method === "GET" && path === "/repos/test-owner/test-repo/pulls/80/reviews") { - return { data: [] }; - } - if (method === "POST" && path === "/repos/test-owner/test-repo/pulls/80/reviews") { - return { - data: { - id: 123, - html_url: "https://github.com/test-owner/test-repo/pull/80#pullrequestreview-123", - }, - }; - } - throw new Error(`Unexpected request: ${method} ${path} ${JSON.stringify(body ?? {})}`); - }); - - const service = createPrService({ - db: makeMockDb(), - logger: makeLogger(), - projectId: "proj-1", - projectRoot: "/tmp/test-project", - laneService: makeLaneService(), - operationService: {} as any, - githubService: { - apiRequest, - getRepoOrThrow: vi.fn(), - getStatus: vi.fn(), - setToken: vi.fn(), - clearToken: vi.fn(), - getTokenOrThrow: vi.fn(() => "ghp_mock"), - } as any, - projectConfigService: { get: vi.fn(() => ({ effective: { ai: {} } })) } as any, - openExternal: vi.fn(async () => undefined), - }); - - const publication = await service.publishReviewPublication({ - runId: "run-1", - destination: { - kind: "github_pr_review", - prId: "pr-80", - repoOwner: "test-owner", - repoName: "test-repo", - prNumber: 80, - githubUrl: "https://github.com/test-owner/test-repo/pull/80", - }, - targetLabel: "PR #80 feature/pr-80 -> main", - summary: "One finding can anchor, one cannot.", - findings: [ - { - id: "finding-inline", - runId: "run-1", - title: "Anchored finding", - severity: "high", - body: "This should post inline.", - confidence: 0.9, - evidence: [], - filePath: "src/review.ts", - line: 11, - anchorState: "anchored", - sourcePass: "adjudicated", - publicationState: "local_only", - originatingPasses: ["diff-risk", "cross-file-impact"], - adjudication: { - score: 8.2, - candidateCount: 2, - mergedFindingIds: ["raw-1", "raw-2"], - rationale: "Merged overlapping findings from diff-risk and cross-file-impact.", - publicationEligible: true, - }, - }, - { - id: "finding-summary", - runId: "run-1", - title: "Summary finding", - severity: "medium", - body: "This should stay in the top-level review body.", - confidence: 0.6, - evidence: [], - filePath: "src/review.ts", - line: 200, - anchorState: "file_only", - sourcePass: "adjudicated", - publicationState: "local_only", - originatingPasses: ["checks-and-tests"], - adjudication: { - score: 5.7, - candidateCount: 1, - mergedFindingIds: ["raw-3"], - rationale: "Accepted because the finding carried concrete evidence and cleared the adjudication threshold.", - publicationEligible: true, - }, - }, - ], - changedFiles: [ - { - filePath: "src/review.ts", - diffPositionsByLine: { 11: 2 }, - }, - ], - }); - - expect(publication.status).toBe("published"); - expect(publication.inlineComments).toEqual([ - expect.objectContaining({ - findingId: "finding-inline", - path: "src/review.ts", - line: 11, - position: 2, - }), - ]); - expect(publication.summaryFindingIds).toEqual(["finding-summary"]); - - const postCall = apiRequest.mock.calls.find( - ([request]: [{ method: string; path: string }]) => request.method === "POST" && request.path.endsWith("/reviews"), - )?.[0]; - expect(postCall?.body).toEqual(expect.objectContaining({ - event: "COMMENT", - commit_id: "def456789012", - comments: [ - expect.objectContaining({ - path: "src/review.ts", - position: 2, - }), - ], - })); - expect(String(postCall?.body?.body ?? "")).toContain("Summary finding"); - expect(String(postCall?.body?.body ?? "")).toContain("Anchored inline comments posted: 1."); - }); -}); diff --git a/apps/desktop/src/main/services/prs/prService.reviewThreads.test.ts b/apps/desktop/src/main/services/prs/prService.reviewThreads.test.ts deleted file mode 100644 index 3fd9a4be3..000000000 --- a/apps/desktop/src/main/services/prs/prService.reviewThreads.test.ts +++ /dev/null @@ -1,246 +0,0 @@ -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; -import { describe, expect, it, vi } from "vitest"; -import type { LaneSummary } from "../../../shared/types"; -import { openKvDb } from "../state/kvDb"; -import { createPrService } from "./prService"; - -function createLogger() { - return { - debug: () => {}, - info: () => {}, - warn: () => {}, - error: () => {}, - } as const; -} - -function makeLane(id: string, name: string, branchRef: string, overrides: Partial = {}): LaneSummary { - return { - id, - name, - description: null, - laneType: "worktree", - baseRef: "refs/heads/main", - branchRef, - worktreePath: `/tmp/${id}`, - attachedRootPath: null, - parentLaneId: null, - childCount: 0, - stackDepth: 0, - parentStatus: null, - isEditProtected: false, - status: { dirty: false, ahead: 0, behind: 0, remoteBehind: -1, rebaseInProgress: false }, - color: null, - icon: null, - tags: [], - folder: null, - createdAt: "2026-03-23T00:00:00.000Z", - archivedAt: null, - ...overrides, - }; -} - -async function seedProject(db: any, projectId: string, repoRoot: string) { - const now = "2026-03-23T00:00:00.000Z"; - db.run( - "insert into projects(id, root_path, display_name, default_base_ref, created_at, last_opened_at) values (?, ?, ?, ?, ?, ?)", - [projectId, repoRoot, "ADE", "main", now, now], - ); -} - -async function seedLane(db: any, projectId: string, lane: LaneSummary) { - db.run( - ` - insert into lanes( - id, project_id, name, description, lane_type, base_ref, branch_ref, worktree_path, - attached_root_path, is_edit_protected, parent_lane_id, color, icon, tags_json, status, created_at, archived_at - ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - `, - [ - lane.id, - projectId, - lane.name, - lane.description, - lane.laneType, - lane.baseRef, - lane.branchRef, - lane.worktreePath, - lane.attachedRootPath, - lane.isEditProtected ? 1 : 0, - lane.parentLaneId, - lane.color, - lane.icon, - JSON.stringify(lane.tags), - "active", - lane.createdAt, - lane.archivedAt, - ], - ); -} - -async function seedPr(db: any, args: { - prId: string; - projectId: string; - laneId: string; - baseBranch: string; - headBranch: string; - title: string; -}) { - const now = "2026-03-23T00:00:00.000Z"; - db.run( - ` - insert into pull_requests( - id, project_id, lane_id, repo_owner, repo_name, github_pr_number, github_url, github_node_id, - title, state, base_branch, head_branch, checks_status, review_status, additions, deletions, - last_synced_at, created_at, updated_at - ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - `, - [ - args.prId, - args.projectId, - args.laneId, - "arul28", - "ADE", - 80, - "https://github.com/arul28/ADE/pull/80", - null, - args.title, - "open", - args.baseBranch, - args.headBranch, - "failing", - "changes_requested", - 0, - 0, - now, - now, - now, - ], - ); -} - -describe("prService.getReviewThreads", () => { - it("fetches review threads without querying unsupported thread timestamps", async () => { - const root = fs.mkdtempSync(path.join(os.tmpdir(), "ade-pr-review-threads-")); - const db = await openKvDb(path.join(root, ".ade.db"), createLogger()); - try { - const projectId = "proj-review-threads"; - const lane = makeLane("lane-80", "feature/pr-80", "refs/heads/feature/pr-80", { worktreePath: root }); - - await seedProject(db, projectId, root); - await seedLane(db, projectId, lane); - await seedPr(db, { - prId: "pr-80", - projectId, - laneId: lane.id, - baseBranch: "main", - headBranch: "feature/pr-80", - title: "Fix PR review thread loading", - }); - - const apiRequest = vi.fn(async ({ path: requestPath, body }: { path: string; body?: { query?: string } }) => { - if (requestPath !== "/graphql") return { data: {} }; - const query = body?.query ?? ""; - const commentsSection = query.slice(query.indexOf("comments")); - expect(commentsSection).toMatch(/\bcreatedAt\b/); - expect(commentsSection).toMatch(/\bupdatedAt\b/); - const beforeComments = query.slice(0, query.indexOf("comments")); - expect(beforeComments).not.toMatch(/\bcreatedAt\b/); - expect(beforeComments).not.toMatch(/\bupdatedAt\b/); - return { - data: { - data: { - repository: { - pullRequest: { - reviewThreads: { - pageInfo: { - hasNextPage: false, - endCursor: null, - }, - nodes: [ - { - id: "thread-1", - isResolved: false, - isOutdated: false, - path: "apps/desktop/src/main/services/prs/prService.ts", - line: 1097, - originalLine: 1097, - startLine: null, - originalStartLine: null, - diffSide: "RIGHT", - comments: { - nodes: [ - { - id: "comment-1", - body: "Please load CodeRabbit review threads correctly.", - url: "https://github.com/arul28/ADE/pull/80#discussion_r1", - createdAt: "2026-03-23T01:00:00.000Z", - updatedAt: "2026-03-23T01:05:00.000Z", - author: { - login: "coderabbitai", - avatarUrl: "https://example.com/avatar.png", - }, - }, - ], - }, - }, - ], - }, - }, - }, - }, - }, - }; - }); - - const service = createPrService({ - db, - logger: createLogger() as any, - projectId, - projectRoot: root, - laneService: { - list: async () => [lane], - } as any, - operationService: {} as any, - githubService: { apiRequest } as any, - aiIntegrationService: undefined, - projectConfigService: {} as any, - conflictService: undefined, - openExternal: async () => {}, - }); - - await expect(service.getReviewThreads("pr-80")).resolves.toEqual([ - { - id: "thread-1", - isResolved: false, - isOutdated: false, - path: "apps/desktop/src/main/services/prs/prService.ts", - line: 1097, - originalLine: 1097, - startLine: 0, - originalStartLine: 0, - diffSide: "RIGHT", - url: "https://github.com/arul28/ADE/pull/80#discussion_r1", - createdAt: "2026-03-23T01:00:00.000Z", - updatedAt: "2026-03-23T01:05:00.000Z", - comments: [ - { - id: "comment-1", - author: "coderabbitai", - authorAvatarUrl: "https://example.com/avatar.png", - body: "Please load CodeRabbit review threads correctly.", - url: "https://github.com/arul28/ADE/pull/80#discussion_r1", - createdAt: "2026-03-23T01:00:00.000Z", - updatedAt: "2026-03-23T01:05:00.000Z", - }, - ], - }, - ]); - expect(apiRequest).toHaveBeenCalledTimes(1); - } finally { - db.close(); - fs.rmSync(root, { recursive: true, force: true }); - } - }); -}); diff --git a/apps/desktop/src/main/services/prs/prService.timelineRails.test.ts b/apps/desktop/src/main/services/prs/prService.timelineRails.test.ts deleted file mode 100644 index f68b00682..000000000 --- a/apps/desktop/src/main/services/prs/prService.timelineRails.test.ts +++ /dev/null @@ -1,529 +0,0 @@ -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; -import { describe, expect, it, vi } from "vitest"; -import type { LaneSummary } from "../../../shared/types"; -import { openKvDb } from "../state/kvDb"; -import { createPrService } from "./prService"; - -function createLogger() { - return { - debug: () => {}, - info: () => {}, - warn: () => {}, - error: () => {}, - } as const; -} - -function makeLane(id: string, name: string, branchRef: string, overrides: Partial = {}): LaneSummary { - return { - id, - name, - description: null, - laneType: "worktree", - baseRef: "refs/heads/main", - branchRef, - worktreePath: `/tmp/${id}`, - attachedRootPath: null, - parentLaneId: null, - childCount: 0, - stackDepth: 0, - parentStatus: null, - isEditProtected: false, - status: { dirty: false, ahead: 0, behind: 0, remoteBehind: -1, rebaseInProgress: false }, - color: null, - icon: null, - tags: [], - folder: null, - createdAt: "2026-04-14T00:00:00.000Z", - archivedAt: null, - ...overrides, - }; -} - -async function seedProject(db: any, projectId: string, repoRoot: string) { - const now = "2026-04-14T00:00:00.000Z"; - db.run( - "insert into projects(id, root_path, display_name, default_base_ref, created_at, last_opened_at) values (?, ?, ?, ?, ?, ?)", - [projectId, repoRoot, "ADE", "main", now, now], - ); -} - -async function seedLane(db: any, projectId: string, lane: LaneSummary) { - db.run( - ` - insert into lanes( - id, project_id, name, description, lane_type, base_ref, branch_ref, worktree_path, - attached_root_path, is_edit_protected, parent_lane_id, color, icon, tags_json, status, created_at, archived_at - ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - `, - [ - lane.id, - projectId, - lane.name, - lane.description, - lane.laneType, - lane.baseRef, - lane.branchRef, - lane.worktreePath, - lane.attachedRootPath, - lane.isEditProtected ? 1 : 0, - lane.parentLaneId, - lane.color, - lane.icon, - JSON.stringify(lane.tags), - "active", - lane.createdAt, - lane.archivedAt, - ], - ); -} - -async function seedPr(db: any, args: { prId: string; projectId: string; laneId: string; prNumber: number }) { - const now = "2026-04-14T00:00:00.000Z"; - db.run( - ` - insert into pull_requests( - id, project_id, lane_id, repo_owner, repo_name, github_pr_number, github_url, github_node_id, - title, state, base_branch, head_branch, checks_status, review_status, additions, deletions, - last_synced_at, created_at, updated_at - ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - `, - [ - args.prId, - args.projectId, - args.laneId, - "arul28", - "ADE", - args.prNumber, - `https://github.com/arul28/ADE/pull/${args.prNumber}`, - null, - "Timeline + Rails", - "open", - "main", - "feature/timeline", - "passing", - "approved", - 10, - 0, - now, - now, - now, - ], - ); -} - -function mockReviewThreadsResponse() { - return { - data: { - data: { - repository: { - pullRequest: { - reviewThreads: { - pageInfo: { hasNextPage: false, endCursor: null }, - nodes: [ - { - id: "thread-abc", - isResolved: false, - isOutdated: false, - path: "src/app.ts", - line: 10, - originalLine: 10, - startLine: null, - originalStartLine: null, - diffSide: "RIGHT", - comments: { - nodes: [ - { - id: "comment-1", - body: "Please fix.", - url: "https://github.com/x/y/pull/1#r1", - createdAt: "2026-04-14T01:00:00.000Z", - updatedAt: "2026-04-14T01:00:00.000Z", - author: { login: "rev", avatarUrl: null }, - }, - ], - }, - }, - ], - }, - }, - }, - }, - }, - }; -} - -async function buildService(root: string) { - const db = await openKvDb(path.join(root, ".ade.db"), createLogger()); - const projectId = "proj-timeline"; - const lane = makeLane("lane-1", "feature/timeline", "refs/heads/feature/timeline", { worktreePath: root }); - await seedProject(db, projectId, root); - await seedLane(db, projectId, lane); - await seedPr(db, { prId: "pr-1", projectId, laneId: lane.id, prNumber: 42 }); - return { db, projectId, lane }; -} - -describe("prService.postReviewComment", () => { - it("issues an addPullRequestReviewThreadReply GraphQL mutation and maps the response", async () => { - const root = fs.mkdtempSync(path.join(os.tmpdir(), "ade-prs-post-")); - const { db, projectId, lane } = await buildService(root); - try { - const apiRequest = vi.fn(async ({ path: requestPath, body }: any) => { - if (requestPath !== "/graphql") return { data: {} }; - const query: string = body?.query ?? ""; - if (query.includes("reviewThreads(first:")) { - return mockReviewThreadsResponse(); - } - expect(query).toContain("addPullRequestReviewThreadReply"); - expect(body.variables).toMatchObject({ threadId: "thread-abc", body: "Thanks!" }); - return { - data: { - data: { - addPullRequestReviewThreadReply: { - comment: { - id: "comment-2", - body: "Thanks!", - url: "https://github.com/x/y/pull/1#r2", - createdAt: "2026-04-14T02:00:00.000Z", - updatedAt: "2026-04-14T02:00:00.000Z", - author: { login: "me", avatarUrl: "avatar.png" }, - }, - }, - }, - }, - }; - }); - const service = createPrService({ - db, - logger: createLogger() as any, - projectId, - projectRoot: root, - laneService: { list: async () => [lane] } as any, - operationService: {} as any, - githubService: { apiRequest } as any, - aiIntegrationService: undefined, - projectConfigService: {} as any, - conflictService: undefined, - openExternal: async () => {}, - }); - await expect( - service.postReviewComment({ prId: "pr-1", threadId: "thread-abc", body: "Thanks!" }), - ).resolves.toEqual({ - id: "comment-2", - author: "me", - authorAvatarUrl: "avatar.png", - body: "Thanks!", - url: "https://github.com/x/y/pull/1#r2", - createdAt: "2026-04-14T02:00:00.000Z", - updatedAt: "2026-04-14T02:00:00.000Z", - }); - } finally { - db.close(); - fs.rmSync(root, { recursive: true, force: true }); - } - }); - - it("rejects when the thread does not belong to the PR", async () => { - const root = fs.mkdtempSync(path.join(os.tmpdir(), "ade-prs-post-wrong-")); - const { db, projectId, lane } = await buildService(root); - try { - const apiRequest = vi.fn(async ({ path: p }: any) => { - if (p === "/graphql") return mockReviewThreadsResponse(); - return { data: {} }; - }); - const service = createPrService({ - db, - logger: createLogger() as any, - projectId, - projectRoot: root, - laneService: { list: async () => [lane] } as any, - operationService: {} as any, - githubService: { apiRequest } as any, - aiIntegrationService: undefined, - projectConfigService: {} as any, - conflictService: undefined, - openExternal: async () => {}, - }); - await expect( - service.postReviewComment({ prId: "pr-1", threadId: "not-mine", body: "..." }), - ).rejects.toThrow(/does not belong/); - } finally { - db.close(); - fs.rmSync(root, { recursive: true, force: true }); - } - }); -}); - -describe("prService.setReviewThreadResolved", () => { - it("uses resolveReviewThread mutation when resolved=true", async () => { - const root = fs.mkdtempSync(path.join(os.tmpdir(), "ade-prs-resolve-")); - const { db, projectId, lane } = await buildService(root); - try { - const apiRequest = vi.fn(async ({ path: p, body }: any) => { - if (p !== "/graphql") return { data: {} }; - const q: string = body?.query ?? ""; - if (q.includes("reviewThreads(first:")) return mockReviewThreadsResponse(); - expect(q).toContain("resolveReviewThread("); - expect(q).not.toContain("unresolveReviewThread"); - return { - data: { data: { resolveReviewThread: { thread: { id: "thread-abc", isResolved: true } } } }, - }; - }); - const service = createPrService({ - db, - logger: createLogger() as any, - projectId, - projectRoot: root, - laneService: { list: async () => [lane] } as any, - operationService: {} as any, - githubService: { apiRequest } as any, - aiIntegrationService: undefined, - projectConfigService: {} as any, - conflictService: undefined, - openExternal: async () => {}, - }); - await expect( - service.setReviewThreadResolved({ prId: "pr-1", threadId: "thread-abc", resolved: true }), - ).resolves.toEqual({ threadId: "thread-abc", isResolved: true }); - } finally { - db.close(); - fs.rmSync(root, { recursive: true, force: true }); - } - }); - - it("uses unresolveReviewThread mutation when resolved=false", async () => { - const root = fs.mkdtempSync(path.join(os.tmpdir(), "ade-prs-unresolve-")); - const { db, projectId, lane } = await buildService(root); - try { - const apiRequest = vi.fn(async ({ path: p, body }: any) => { - if (p !== "/graphql") return { data: {} }; - const q: string = body?.query ?? ""; - if (q.includes("reviewThreads(first:")) return mockReviewThreadsResponse(); - expect(q).toContain("unresolveReviewThread("); - return { - data: { data: { unresolveReviewThread: { thread: { id: "thread-abc", isResolved: false } } } }, - }; - }); - const service = createPrService({ - db, - logger: createLogger() as any, - projectId, - projectRoot: root, - laneService: { list: async () => [lane] } as any, - operationService: {} as any, - githubService: { apiRequest } as any, - aiIntegrationService: undefined, - projectConfigService: {} as any, - conflictService: undefined, - openExternal: async () => {}, - }); - await expect( - service.setReviewThreadResolved({ prId: "pr-1", threadId: "thread-abc", resolved: false }), - ).resolves.toEqual({ threadId: "thread-abc", isResolved: false }); - } finally { - db.close(); - fs.rmSync(root, { recursive: true, force: true }); - } - }); -}); - -describe("prService.reactToComment", () => { - it("issues addReaction with the correct enum value", async () => { - const root = fs.mkdtempSync(path.join(os.tmpdir(), "ade-prs-react-")); - const { db, projectId, lane } = await buildService(root); - try { - const apiRequest = vi.fn(async ({ path: p, body }: any) => { - if (p !== "/graphql") return { data: {} }; - const q: string = body?.query ?? ""; - expect(q).toContain("addReaction("); - expect(body.variables).toEqual({ subjectId: "comment-1", content: "ROCKET" }); - return { data: { data: { addReaction: { reaction: { id: "r1", content: "ROCKET" } } } } }; - }); - const service = createPrService({ - db, - logger: createLogger() as any, - projectId, - projectRoot: root, - laneService: { list: async () => [lane] } as any, - operationService: {} as any, - githubService: { apiRequest } as any, - aiIntegrationService: undefined, - projectConfigService: {} as any, - conflictService: undefined, - openExternal: async () => {}, - }); - await expect( - service.reactToComment({ prId: "pr-1", commentId: "comment-1", content: "rocket" }), - ).resolves.toBeUndefined(); - } finally { - db.close(); - fs.rmSync(root, { recursive: true, force: true }); - } - }); -}); - -describe("prService.getDeployments", () => { - it("maps GitHub deployments + latest status into PrDeployment shape", async () => { - const root = fs.mkdtempSync(path.join(os.tmpdir(), "ade-prs-deploy-")); - const { db, projectId, lane } = await buildService(root); - try { - const apiRequest = vi.fn(async ({ path: p, query }: any) => { - if (p === "/repos/arul28/ADE/pulls/42") { - return { data: { head: { sha: "deadbeef" }, base: { sha: "baseabc" }, state: "open" } }; - } - if (p === "/repos/arul28/ADE/deployments") { - expect(query).toMatchObject({ sha: "deadbeef" }); - return { - data: [ - { - id: 1001, - environment: "staging", - description: "deploy-desc", - payload: { web_url: "https://payload.example" }, - sha: "deadbeef", - ref: "feature/timeline", - creator: { login: "arul" }, - created_at: "2026-04-14T01:00:00.000Z", - updated_at: "2026-04-14T01:00:00.000Z", - }, - ], - }; - } - if (p === "/repos/arul28/ADE/deployments/1001/statuses") { - return { - data: [ - { - state: "success", - environment_url: "https://preview.example", - log_url: "https://logs.example", - target_url: "https://target.example", - updated_at: "2026-04-14T02:00:00.000Z", - }, - ], - }; - } - return { data: [] }; - }); - const service = createPrService({ - db, - logger: createLogger() as any, - projectId, - projectRoot: root, - laneService: { list: async () => [lane] } as any, - operationService: {} as any, - githubService: { apiRequest } as any, - aiIntegrationService: undefined, - projectConfigService: {} as any, - conflictService: undefined, - openExternal: async () => {}, - }); - const deployments = await service.getDeployments("pr-1"); - expect(deployments).toHaveLength(1); - expect(deployments[0]).toMatchObject({ - environment: "staging", - state: "success", - environmentUrl: "https://preview.example", - logUrl: "https://logs.example", - sha: "deadbeef", - ref: "feature/timeline", - creator: "arul", - }); - } finally { - db.close(); - fs.rmSync(root, { recursive: true, force: true }); - } - }); - - it("returns an empty array when the PR has no head SHA", async () => { - const root = fs.mkdtempSync(path.join(os.tmpdir(), "ade-prs-deploy-empty-")); - const { db, projectId, lane } = await buildService(root); - try { - const apiRequest = vi.fn(async ({ path: p }: any) => { - if (p === "/repos/arul28/ADE/pulls/42") { - return { data: { head: {}, base: {} } }; - } - return { data: [] }; - }); - const service = createPrService({ - db, - logger: createLogger() as any, - projectId, - projectRoot: root, - laneService: { list: async () => [lane] } as any, - operationService: {} as any, - githubService: { apiRequest } as any, - aiIntegrationService: undefined, - projectConfigService: {} as any, - conflictService: undefined, - openExternal: async () => {}, - }); - await expect(service.getDeployments("pr-1")).resolves.toEqual([]); - } finally { - db.close(); - fs.rmSync(root, { recursive: true, force: true }); - } - }); -}); - -describe("prService.refreshSnapshots commits", () => { - it("stores the newest PR commits when GitHub returns more than 30", async () => { - const root = fs.mkdtempSync(path.join(os.tmpdir(), "ade-prs-commits-")); - const { db, projectId, lane } = await buildService(root); - try { - const commits = Array.from({ length: 35 }, (_, index) => { - const n = String(index + 1).padStart(2, "0"); - return { - sha: `sha-${n}`, - commit: { - message: `commit ${n}\n\nbody`, - author: { - name: `Author ${n}`, - email: `a${n}@example.test`, - date: `2026-04-14T00:${n}:00.000Z`, - }, - }, - author: { login: `author-${n}` }, - }; - }); - const apiRequest = vi.fn(async ({ path: p }: any) => { - if (p === "/repos/arul28/ADE/pulls/42") { - return { data: { head: { sha: "sha-35" }, base: { sha: "base" }, state: "open" } }; - } - if (p === "/repos/arul28/ADE/pulls/42/commits") { - return { data: commits }; - } - if (p.endsWith("/check-runs")) { - return { data: { check_runs: [] } }; - } - if (p.endsWith("/status")) { - return { data: { state: "success", statuses: [] } }; - } - return { data: [] }; - }); - const service = createPrService({ - db, - logger: createLogger() as any, - projectId, - projectRoot: root, - laneService: { list: async () => [lane] } as any, - operationService: {} as any, - githubService: { apiRequest } as any, - aiIntegrationService: undefined, - projectConfigService: {} as any, - conflictService: undefined, - openExternal: async () => {}, - }); - - await service.refreshSnapshots({ prId: "pr-1" }); - const [snapshot] = service.listSnapshots({ prId: "pr-1" }); - - expect(snapshot.commits).toHaveLength(30); - expect(snapshot.commits[0].sha).toBe("sha-06"); - expect(snapshot.commits.at(-1)?.sha).toBe("sha-35"); - } finally { - db.close(); - fs.rmSync(root, { recursive: true, force: true }); - } - }); -}); diff --git a/apps/desktop/src/main/services/prs/prSummaryService.test.ts b/apps/desktop/src/main/services/prs/prSummaryService.test.ts deleted file mode 100644 index 5fafd2de8..000000000 --- a/apps/desktop/src/main/services/prs/prSummaryService.test.ts +++ /dev/null @@ -1,283 +0,0 @@ -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; -import { describe, expect, it, vi } from "vitest"; -import { openKvDb } from "../state/kvDb"; -import { buildPrSummaryPrompt, createPrSummaryService, parsePrSummaryJson } from "./prSummaryService"; - -function createLogger() { - return { - debug: () => {}, - info: () => {}, - warn: () => {}, - error: () => {}, - } as const; -} - -async function seed(db: any, prId: string, headSha: string | null) { - const now = "2026-04-14T00:00:00.000Z"; - db.run( - "insert into projects(id, root_path, display_name, default_base_ref, created_at, last_opened_at) values (?, ?, ?, ?, ?, ?)", - ["proj", "/tmp", "ADE", "main", now, now], - ); - db.run( - ` - insert into pull_requests( - id, project_id, lane_id, repo_owner, repo_name, github_pr_number, github_url, github_node_id, - title, state, base_branch, head_branch, checks_status, review_status, additions, deletions, - last_synced_at, created_at, updated_at, head_sha - ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - `, - [ - prId, - "proj", - "lane-1", - "arul28", - "ADE", - 1, - "https://github.com/arul28/ADE/pull/1", - null, - "Test", - "open", - "main", - "feat", - "passing", - "approved", - 0, - 0, - now, - now, - now, - headSha, - ], - ); -} - -describe("buildPrSummaryPrompt", () => { - it("includes title, body, file list, unresolved count, and bot summaries", () => { - const prompt = buildPrSummaryPrompt({ - title: "Add feature", - body: "Body content", - changedFiles: [ - { filename: "a.ts", status: "modified", additions: 1, deletions: 0, patch: null, previousFilename: null }, - { filename: "b.ts", status: "added", additions: 10, deletions: 0, patch: null, previousFilename: null }, - ], - issueComments: [ - { - id: "c1", - author: "greptile-bot", - authorAvatarUrl: null, - body: "Looks risky", - source: "issue", - url: null, - path: null, - line: null, - createdAt: null, - updatedAt: null, - }, - ], - reviews: [ - { - reviewer: "coderabbitai[bot]", - reviewerAvatarUrl: null, - state: "commented", - body: "Formal bot review body", - submittedAt: null, - }, - ], - unresolvedThreadCount: 3, - }); - expect(prompt).toContain("Add feature"); - expect(prompt).toContain("Body content"); - expect(prompt).toContain("modified a.ts"); - expect(prompt).toContain("added b.ts"); - expect(prompt).toContain("Unresolved review threads: 3"); - expect(prompt).toContain("@greptile-bot"); - }); -}); - -describe("parsePrSummaryJson", () => { - it("returns fields when valid JSON provided", () => { - const result = parsePrSummaryJson( - '```json\n{"summary":"x","riskAreas":["a"],"reviewerHotspots":["b"],"unresolvedConcerns":[]}\n```', - ); - expect(result).toEqual({ - summary: "x", - riskAreas: ["a"], - reviewerHotspots: ["b"], - unresolvedConcerns: [], - }); - }); - - it("filters non-string array entries", () => { - const result = parsePrSummaryJson( - '{"summary":"s","riskAreas":["ok", 5, null],"reviewerHotspots":[],"unresolvedConcerns":[]}', - ); - expect(result?.riskAreas).toEqual(["ok"]); - }); - - it("returns null on missing JSON", () => { - expect(parsePrSummaryJson("no json here")).toBeNull(); - }); - - it("returns null on invalid JSON", () => { - expect(parsePrSummaryJson("{ not json }")).toBeNull(); - }); -}); - -describe("createPrSummaryService", () => { - it("returns null from getSummary when no cache entry exists", async () => { - const root = fs.mkdtempSync(path.join(os.tmpdir(), "ade-prs-sum-get-")); - const db = await openKvDb(path.join(root, ".ade.db"), createLogger()); - try { - await seed(db, "pr-1", "headA"); - const svc = createPrSummaryService({ - db, - logger: createLogger() as any, - projectRoot: root, - prService: {} as any, - }); - await expect(svc.getSummary("pr-1")).resolves.toBeNull(); - } finally { - db.close(); - fs.rmSync(root, { recursive: true, force: true }); - } - }); - - it("regenerateSummary caches the result keyed by (prId, headSha) and parses JSON", async () => { - const root = fs.mkdtempSync(path.join(os.tmpdir(), "ade-prs-sum-regen-")); - const db = await openKvDb(path.join(root, ".ade.db"), createLogger()); - try { - await seed(db, "pr-1", "headA"); - - const prService = { - listAll: () => [ - { - id: "pr-1", - laneId: "lane-1", - projectId: "proj", - repoOwner: "arul28", - repoName: "ADE", - githubPrNumber: 1, - githubUrl: "", - githubNodeId: null, - title: "PR title", - state: "open", - baseBranch: "main", - headBranch: "feat", - checksStatus: "passing", - reviewStatus: "approved", - additions: 0, - deletions: 0, - lastSyncedAt: null, - createdAt: "", - updatedAt: "", - }, - ], - getDetail: vi.fn(async () => ({ - prId: "pr-1", - body: "Detail body", - labels: [], - assignees: [], - requestedReviewers: [], - author: { login: "arul", avatarUrl: null }, - isDraft: false, - milestone: null, - linkedIssues: [], - })), - getFiles: vi.fn(async () => [ - { filename: "x.ts", status: "modified", additions: 1, deletions: 0, patch: null, previousFilename: null }, - ]), - getComments: vi.fn(async () => []), - getReviewThreads: vi.fn(async () => [ - { - id: "t1", - isResolved: false, - isOutdated: false, - path: "x.ts", - line: 1, - originalLine: 1, - startLine: 0, - originalStartLine: 0, - diffSide: "RIGHT", - url: null, - createdAt: null, - updatedAt: null, - comments: [], - }, - ]), - getReviews: vi.fn(async () => []), - }; - - const aiIntegrationService = { - draftPrDescription: vi.fn(async () => ({ - text: '{"summary":"ok","riskAreas":["a"],"reviewerHotspots":["b"],"unresolvedConcerns":["c"]}', - durationMs: 10, - executedAt: "x", - model: "m", - provider: "openai" as const, - reasoningEffort: null, - promptTokens: null, - completionTokens: null, - totalTokens: null, - budgetState: null, - taskType: "pr_description" as const, - feature: "pr_descriptions" as const, - })), - }; - - const svc = createPrSummaryService({ - db, - logger: createLogger() as any, - projectRoot: root, - prService: prService as any, - aiIntegrationService: aiIntegrationService as any, - }); - - const result = await svc.regenerateSummary("pr-1"); - expect(result.summary).toBe("ok"); - expect(result.riskAreas).toEqual(["a"]); - expect(result.headSha).toBe("headA"); - expect(aiIntegrationService.draftPrDescription).toHaveBeenCalledTimes(1); - - const cached = await svc.getSummary("pr-1"); - expect(cached?.summary).toBe("ok"); - expect(cached?.headSha).toBe("headA"); - - // regenerateSummary bypasses cache and always calls AI - const again = await svc.regenerateSummary("pr-1"); - expect(again.summary).toBe("ok"); - expect(aiIntegrationService.draftPrDescription).toHaveBeenCalledTimes(2); - } finally { - db.close(); - fs.rmSync(root, { recursive: true, force: true }); - } - }); - - it("falls back gracefully when aiIntegrationService is missing", async () => { - const root = fs.mkdtempSync(path.join(os.tmpdir(), "ade-prs-sum-fallback-")); - const db = await openKvDb(path.join(root, ".ade.db"), createLogger()); - try { - await seed(db, "pr-1", "headA"); - const prService = { - listAll: () => [{ id: "pr-1", title: "t" } as any], - getDetail: async () => null, - getFiles: async () => [], - getComments: async () => [], - getReviewThreads: async () => [], - getReviews: async () => [], - }; - const svc = createPrSummaryService({ - db, - logger: createLogger() as any, - projectRoot: root, - prService: prService as any, - }); - const result = await svc.regenerateSummary("pr-1"); - expect(result.summary).toMatch(/0 file/); - } finally { - db.close(); - fs.rmSync(root, { recursive: true, force: true }); - } - }); -}); diff --git a/apps/desktop/src/main/services/prs/resolverUtils.test.ts b/apps/desktop/src/main/services/prs/resolverUtils.test.ts deleted file mode 100644 index 2bd060caf..000000000 --- a/apps/desktop/src/main/services/prs/resolverUtils.test.ts +++ /dev/null @@ -1,129 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; -import { mapPermissionMode, mapPermissionModeForModelFamily, readRecentCommits } from "./resolverUtils"; - -vi.mock("../git/git", () => ({ - runGit: vi.fn(), -})); - -import { runGit } from "../git/git"; - -const mockRunGit = vi.mocked(runGit); - -describe("mapPermissionMode", () => { - it("maps full_edit to full-auto", () => { - expect(mapPermissionMode("full_edit")).toBe("full-auto"); - }); - - it("maps read_only to plan", () => { - expect(mapPermissionMode("read_only")).toBe("plan"); - }); - - it("maps guarded_edit to edit", () => { - expect(mapPermissionMode("guarded_edit")).toBe("edit"); - }); - - it("maps undefined to edit", () => { - expect(mapPermissionMode(undefined)).toBe("edit"); - }); - - it("maps an unrecognized value to edit", () => { - expect(mapPermissionMode("some_other_value" as any)).toBe("edit"); - }); -}); - -describe("mapPermissionModeForModelFamily", () => { - it("maps guarded_edit to Codex default permissions for OpenAI CLI models", () => { - expect(mapPermissionModeForModelFamily("guarded_edit", "openai")).toBe("default"); - }); - - it("keeps guarded_edit as edit for non-OpenAI models", () => { - expect(mapPermissionModeForModelFamily("guarded_edit", "anthropic")).toBe("edit"); - }); -}); - -describe("readRecentCommits", () => { - it("parses git log output into sha/subject pairs", async () => { - mockRunGit.mockResolvedValueOnce({ - exitCode: 0, - stdout: "abc123def456\tAdd feature X\nbbb222ccc333\tFix tests\n", - stderr: "", - } as any); - - const commits = await readRecentCommits("/tmp/worktree", 8); - - expect(mockRunGit).toHaveBeenCalledWith( - ["log", "--format=%H%x09%s", "-n", "8", "HEAD"], - { cwd: "/tmp/worktree", timeoutMs: 10_000 }, - ); - expect(commits).toEqual([ - { sha: "abc123def456", subject: "Add feature X" }, - { sha: "bbb222ccc333", subject: "Fix tests" }, - ]); - }); - - it("defaults to 8 commits and HEAD ref", async () => { - mockRunGit.mockResolvedValueOnce({ - exitCode: 0, - stdout: "aaa111bbb222\tFirst commit\n", - stderr: "", - } as any); - - await readRecentCommits("/tmp/worktree"); - - expect(mockRunGit).toHaveBeenCalledWith( - ["log", "--format=%H%x09%s", "-n", "8", "HEAD"], - expect.objectContaining({ cwd: "/tmp/worktree" }), - ); - }); - - it("uses a custom ref when provided", async () => { - mockRunGit.mockResolvedValueOnce({ - exitCode: 0, - stdout: "aaa111bbb222\tRemote commit\n", - stderr: "", - } as any); - - await readRecentCommits("/tmp/worktree", 5, "origin/main"); - - expect(mockRunGit).toHaveBeenCalledWith( - ["log", "--format=%H%x09%s", "-n", "5", "origin/main"], - expect.objectContaining({ cwd: "/tmp/worktree" }), - ); - }); - - it("returns empty array when git exits with non-zero", async () => { - mockRunGit.mockResolvedValueOnce({ - exitCode: 128, - stdout: "", - stderr: "fatal: bad default revision 'HEAD'", - } as any); - - const commits = await readRecentCommits("/tmp/worktree"); - - expect(commits).toEqual([]); - }); - - it("filters out empty lines and entries with no sha or subject", async () => { - mockRunGit.mockResolvedValueOnce({ - exitCode: 0, - stdout: "abc123\tGood commit\n\n \n\t\n", - stderr: "", - } as any); - - const commits = await readRecentCommits("/tmp/worktree"); - - expect(commits).toEqual([{ sha: "abc123", subject: "Good commit" }]); - }); - - it("handles tab characters in the commit subject", async () => { - mockRunGit.mockResolvedValueOnce({ - exitCode: 0, - stdout: "abc123\tSubject\twith\ttabs\n", - stderr: "", - } as any); - - const commits = await readRecentCommits("/tmp/worktree"); - - expect(commits).toEqual([{ sha: "abc123", subject: "Subject\twith\ttabs" }]); - }); -}); diff --git a/apps/desktop/src/main/services/state/onConflictAudit.test.ts b/apps/desktop/src/main/services/state/onConflictAudit.test.ts deleted file mode 100644 index 74ddd46da..000000000 --- a/apps/desktop/src/main/services/state/onConflictAudit.test.ts +++ /dev/null @@ -1,202 +0,0 @@ -import fs from "node:fs"; -import path from "node:path"; -import ts from "typescript"; -import { describe, expect, it } from "vitest"; - -type ConflictTarget = { - file: string; - table: string; - columns: string; -}; - -const APPROVED_CONFLICT_TARGETS: ConflictTarget[] = [ - { - file: "src/main/services/automations/automationService.ts", - table: "automation_ingress_cursors", - columns: "project_id,source", - }, - { - file: "src/main/services/conflicts/conflictService.ts", - table: "rebase_deferred", - columns: "lane_id,project_id", - }, - { - file: "src/main/services/conflicts/conflictService.ts", - table: "rebase_dismissed", - columns: "lane_id,project_id", - }, - { - file: "src/main/services/cto/ctoStateService.ts", - table: "cto_core_memory_state", - columns: "project_id", - }, - { - file: "src/main/services/cto/ctoStateService.ts", - table: "cto_identity_state", - columns: "project_id", - }, - { - file: "src/main/services/cto/flowPolicyService.ts", - table: "cto_flow_policies", - columns: "project_id", - }, - { - file: "src/main/services/cto/linearIngressService.ts", - table: "linear_ingress_state", - columns: "project_id", - }, - { - file: "src/main/services/cto/linearSyncService.ts", - table: "linear_sync_state", - columns: "project_id", - }, - { - file: "src/main/services/cto/workerAgentService.ts", - table: "worker_agents", - columns: "id", - }, - { - file: "src/main/services/lanes/laneService.ts", - table: "lane_state_snapshots", - columns: "lane_id", - }, - { - file: "src/main/services/memory/proceduralLearningService.ts", - table: "memory_procedure_details", - columns: "memory_id", - }, - { - file: "src/main/services/orchestrator/chatMessageService.ts", - table: "orchestrator_chat_threads", - columns: "id", - }, - { - file: "src/main/services/orchestrator/metricsAndUsage.ts", - table: "mission_metrics_config", - columns: "mission_id", - }, - { - file: "src/main/services/orchestrator/recoveryService.ts", - table: "orchestrator_attempt_runtime", - columns: "attempt_id", - }, - { - file: "src/main/services/orchestrator/teamRuntimeState.ts", - table: "orchestrator_run_state", - columns: "run_id", - }, - { - file: "src/main/services/processes/processService.ts", - table: "process_runtime", - columns: "project_id,lane_id,process_key", - }, - { - file: "src/main/services/prs/issueInventoryService.ts", - table: "pr_convergence_state", - columns: "pr_id", - }, - { - file: "src/main/services/prs/issueInventoryService.ts", - table: "pr_pipeline_settings", - columns: "pr_id", - }, - { - file: "src/main/services/prs/prService.ts", - table: "pull_request_snapshots", - columns: "pr_id", - }, - { - file: "src/main/services/prs/queueLandingService.ts", - table: "queue_landing_state", - columns: "id", - }, - { - file: "src/main/services/sessions/sessionDeltaService.ts", - table: "session_deltas", - columns: "session_id", - }, - { - file: "src/main/services/sync/deviceRegistryService.ts", - table: "devices", - columns: "device_id", - }, - { - file: "src/main/services/sync/deviceRegistryService.ts", - table: "sync_cluster_state", - columns: "cluster_id", - }, -].sort((a, b) => - a.file.localeCompare(b.file) - || a.table.localeCompare(b.table) - || a.columns.localeCompare(b.columns), -); - -function listTsFiles(dir: string): string[] { - const files: string[] = []; - for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { - const fullPath = path.join(dir, entry.name); - if (entry.isDirectory()) { - files.push(...listTsFiles(fullPath)); - continue; - } - if (entry.isFile() && fullPath.endsWith(".ts") && !fullPath.endsWith(".test.ts")) { - files.push(fullPath); - } - } - return files; -} - -function readStaticSql(node: ts.Expression | undefined): string | null { - if (!node) return null; - if (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) { - return node.text; - } - if (ts.isTemplateExpression(node)) { - return null; - } - return null; -} - -function scanConflictTargets(): ConflictTarget[] { - const entries: ConflictTarget[] = []; - const root = path.resolve(process.cwd(), "src/main"); - for (const filePath of listTsFiles(root)) { - const sourceText = fs.readFileSync(filePath, "utf8"); - const sourceFile = ts.createSourceFile(filePath, sourceText, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS); - - const visit = (node: ts.Node): void => { - if (ts.isCallExpression(node)) { - const callee = node.expression; - if (ts.isPropertyAccessExpression(callee) && callee.name.text === "run") { - const sql = readStaticSql(node.arguments[0]); - if (sql) { - const match = sql.match(/insert\s+into\s+([a-zA-Z0-9_]+)\s*\([\s\S]*?on\s+conflict\s*\(([^)]+)\)/i); - if (match) { - entries.push({ - file: path.relative(process.cwd(), filePath).replace(/\\/g, "/"), - table: match[1], - columns: match[2].split(",").map((value) => value.trim()).join(","), - }); - } - } - } - } - ts.forEachChild(node, visit); - }; - - visit(sourceFile); - } - - return entries.sort((a, b) => - a.file.localeCompare(b.file) - || a.table.localeCompare(b.table) - || a.columns.localeCompare(b.columns), - ); -} - -describe("ON CONFLICT audit", () => { - it("only uses audited upsert targets in main-process code", () => { - const discovered = scanConflictTargets(); - expect(discovered).toEqual(APPROVED_CONFLICT_TARGETS); - }); -}); diff --git a/apps/desktop/src/main/services/usage/usageTrackingService.test.ts b/apps/desktop/src/main/services/usage/usageTrackingService.test.ts index 09769ad08..e8d2bb191 100644 --- a/apps/desktop/src/main/services/usage/usageTrackingService.test.ts +++ b/apps/desktop/src/main/services/usage/usageTrackingService.test.ts @@ -24,6 +24,8 @@ import { createUsageTrackingService, _testing } from "./usageTrackingService"; const { aggregateCosts, calculatePacing, + MIN_POLL_INTERVAL_MS, + MAX_POLL_INTERVAL_MS, isCodexTokenStale, isTokenExpiredOrExpiring, parseClaudeWindows, @@ -607,19 +609,22 @@ describe("createUsageTrackingService", () => { service.dispose(); }); - it("clamps poll interval to min/max bounds", () => { + it("clamps out-of-range poll intervals internally", () => { const logger = createLogger(); + const dependencies = createFastDependencies(); + const setIntervalSpy = vi.spyOn(globalThis, "setInterval"); - // Too low — should clamp to 1 min - const service1 = createUsageTrackingService({ logger, pollIntervalMs: 100 }); + const service1 = createUsageTrackingService({ logger, pollIntervalMs: 100, dependencies }); + service1.start(); + expect(setIntervalSpy).toHaveBeenLastCalledWith(expect.any(Function), MIN_POLL_INTERVAL_MS); service1.dispose(); - // Too high — should clamp to 15 min - const service2 = createUsageTrackingService({ logger, pollIntervalMs: 60 * 60 * 1000 }); + const service2 = createUsageTrackingService({ logger, pollIntervalMs: 60 * 60 * 1000, dependencies }); + service2.start(); + expect(setIntervalSpy).toHaveBeenLastCalledWith(expect.any(Function), MAX_POLL_INTERVAL_MS); service2.dispose(); - // No crash means the clamping worked - expect(true).toBe(true); + setIntervalSpy.mockRestore(); }); it("calls onUpdate when poll completes", async () => { diff --git a/apps/desktop/src/main/services/usage/usageTrackingService.ts b/apps/desktop/src/main/services/usage/usageTrackingService.ts index 4c365155a..9cde3b1da 100644 --- a/apps/desktop/src/main/services/usage/usageTrackingService.ts +++ b/apps/desktop/src/main/services/usage/usageTrackingService.ts @@ -990,6 +990,8 @@ export function createUsageTrackingService({ // ── Exported for testing ───────────────────────────────────────── export const _testing = { + MIN_POLL_INTERVAL_MS, + MAX_POLL_INTERVAL_MS, readClaudeCredentials, readCodexCredentials, isCodexTokenStale, diff --git a/apps/desktop/src/renderer/components/automations/ActionList.tsx b/apps/desktop/src/renderer/components/automations/ActionList.tsx index f65052d6f..151b73d98 100644 --- a/apps/desktop/src/renderer/components/automations/ActionList.tsx +++ b/apps/desktop/src/renderer/components/automations/ActionList.tsx @@ -1,7 +1,6 @@ import { useRef, useState } from "react"; import { Code, - GitBranch, Lightning, Plus, Rocket, @@ -11,11 +10,15 @@ import { } from "@phosphor-icons/react"; import type { ElementType } from "react"; import type { ModelConfig, TestSuiteDefinition } from "../../../shared/types"; +import { Button } from "../ui/Button"; import { cn } from "../ui/cn"; import { ActionRow, type ActionRowKind, type ActionRowValue } from "./ActionRow"; +// `create-lane` is intentionally absent here: lane creation is now a +// first-class EXECUTION setting (`laneMode: "create"`) rather than an action a +// user has to chain manually. Legacy rules carrying a leading `create-lane` +// action are migrated server-side on read. const ADD_OPTIONS: Array<{ kind: ActionRowKind; label: string; icon: ElementType; disabled?: boolean; hint?: string }> = [ - { kind: "create-lane", label: "Create lane", icon: GitBranch }, { kind: "agent-session", label: "Agent session", icon: Lightning }, { kind: "ade-action", label: "Run ADE action", icon: Code }, { kind: "run-tests", label: "Run tests", icon: TestTube }, @@ -111,7 +114,7 @@ export function ActionList({ return (
{actions.length === 0 ? ( -
+
No actions yet. Add at least one step below.
) : ( @@ -135,17 +138,13 @@ export function ActionList({ )}
- + {menuOpen ? (
setMenuOpen(false)} > {ADD_OPTIONS.map((option) => { @@ -157,17 +156,17 @@ export function ActionList({ disabled={option.disabled} onClick={() => !option.disabled && addAction(option.kind)} className={cn( - "flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-[12px]", + "flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-xs transition-colors", option.disabled - ? "text-[#7E8A9A] cursor-not-allowed opacity-60" - : "text-[#D8E3F2] hover:bg-white/[0.04]", + ? "cursor-not-allowed text-muted-fg/40 opacity-60" + : "text-fg/80 hover:bg-white/[0.04] hover:text-fg", )} title={option.hint} > {option.label} {option.hint ? ( - + {option.hint} ) : null} diff --git a/apps/desktop/src/renderer/components/automations/ActionRow.tsx b/apps/desktop/src/renderer/components/automations/ActionRow.tsx index bf4a7296f..b234c8d3d 100644 --- a/apps/desktop/src/renderer/components/automations/ActionRow.tsx +++ b/apps/desktop/src/renderer/components/automations/ActionRow.tsx @@ -21,7 +21,7 @@ import { Chip } from "../ui/Chip"; import { cn } from "../ui/cn"; import { ModelSelector } from "../missions/ModelSelector"; import { permissionControlsForModel, patchPermissionConfig } from "./permissionControls"; -import { INPUT_CLS, INPUT_STYLE } from "./shared"; +import { inputCls, labelCls, selectCls, textareaCls } from "./designTokens"; import { AdeActionEditor, type AdeActionValue } from "./AdeActionEditor"; export type ActionRowKind = @@ -99,20 +99,20 @@ export function ActionRow({ return (
-
+
- + {index + 1}. {meta.label} {value.kind === "create-lane" ? ( - sets lane + legacy · now in Execution ) : null} {value.kind === "agent-session" && value.modelConfig ? ( - custom model + custom model ) : null}
@@ -120,7 +120,7 @@ export function ActionRow({ type="button" onClick={() => onMove(-1)} disabled={index === 0} - className="rounded p-1 text-[#8FA1B8] hover:text-[#F5FAFF] disabled:opacity-30 disabled:cursor-not-allowed" + className="rounded p-1 text-muted-fg/60 transition-colors hover:text-fg disabled:cursor-not-allowed disabled:opacity-30" title="Move up" > @@ -129,7 +129,7 @@ export function ActionRow({ type="button" onClick={() => onMove(1)} disabled={index === total - 1} - className="rounded p-1 text-[#8FA1B8] hover:text-[#F5FAFF] disabled:opacity-30 disabled:cursor-not-allowed" + className="rounded p-1 text-muted-fg/60 transition-colors hover:text-fg disabled:cursor-not-allowed disabled:opacity-30" title="Move down" > @@ -137,7 +137,7 @@ export function ActionRow({