From 2a095f90ff56085a3e168ab351746b17f4509054 Mon Sep 17 00:00:00 2001 From: Em Jones Date: Sun, 21 Jun 2026 10:10:29 -0700 Subject: [PATCH 01/17] feat(td): init td --- .gitignore | 1 + AGENTS.md | 8 +- agents/closer.md | 43 ++-- agents/implementer.md | 47 +++-- agents/retrospective.md | 21 +- agents/reviewer.md | 27 ++- agents/scout.md | 7 +- agents/verifier.md | 39 ++-- docs/conventions/entropy-management.md | 2 +- docs/golden-principles.md | 2 +- docs/playbooks/README.md | 4 +- src/__tests__/assembler.spec.ts | 7 +- src/__tests__/helpers/td-task.ts | 57 ++++++ src/__tests__/implement-phase.spec.ts | 4 +- src/__tests__/interview.spec.ts | 6 +- src/__tests__/pipeline-tool.spec.ts | 26 +-- src/__tests__/pipeline.spec.ts | 4 +- src/__tests__/prefetch.spec.ts | 4 +- src/__tests__/review-phase.spec.ts | 4 +- src/__tests__/task-factory.spec.ts | 67 ++++--- src/__tests__/task-scanner.spec.ts | 245 ++++++++--------------- src/__tests__/verify-phase.spec.ts | 4 +- src/__tests__/working-memory.spec.ts | 27 ++- src/agent/orchestrator-session.ts | 2 +- src/agent/tools/pipeline-tool.ts | 10 +- src/agent/tools/task-tool.ts | 2 +- src/commands/analyze-failure.ts | 9 +- src/commands/bootstrap.ts | 7 +- src/commands/create.ts | 5 +- src/commands/mark-manual-tested.ts | 19 +- src/commands/mark-reviewed.ts | 44 ++--- src/commands/mark-tested.ts | 39 +--- src/commands/run.ts | 16 +- src/commands/session.ts | 67 ++++--- src/commands/status.ts | 44 +++-- src/commands/update-memory.ts | 18 +- src/config.ts | 26 +-- src/context/assembler.ts | 4 +- src/context/prefetch.ts | 5 +- src/entry/cli-orchestrator.ts | 12 +- src/entry/task-factory.ts | 82 ++++---- src/entry/task-scanner.ts | 148 ++++---------- src/generated/package-assets.ts | 174 ++++++---------- src/phases/implement.ts | 8 +- src/phases/retrospective.ts | 4 +- src/phases/scout.ts | 4 +- src/phases/verify.ts | 5 +- src/pipeline.ts | 4 +- src/state/task-store.ts | 58 ++++-- src/state/td-client.ts | 263 +++++++++++++++++++++++++ src/types.ts | 8 +- tasks/README.md | 205 ++++++------------- 52 files changed, 997 insertions(+), 951 deletions(-) create mode 100644 src/__tests__/helpers/td-task.ts create mode 100644 src/state/td-client.ts diff --git a/.gitignore b/.gitignore index cb76a42..a91e09a 100644 --- a/.gitignore +++ b/.gitignore @@ -24,3 +24,4 @@ dist/ # bun artifacts *.bun-build +.todos/ diff --git a/AGENTS.md b/AGENTS.md index aad9328..16f0009 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -8,7 +8,7 @@ Humans steer. Agents execute. When agents struggle, fix the harness. Run the session command to gather context before doing anything else: ```bash -SESSION=$(ca session --task ) +SESSION=$(ca session --task ) echo "$SESSION" ``` @@ -40,7 +40,7 @@ Full metadata (commands, remotes, evidence strategy): `~/.config/case/projects.j ## Task Dispatch -Tasks are markdown files that agents execute. Runtime task files live in the target repo's ignored `.case/tasks/active/`. +Tasks are `td` issues that agents execute. Each task is a `td` issue in the target repo's `.todos/` store, identified by a `td-…` issue handle. - **Format spec**: `tasks/README.md` - **Templates**: `tasks/templates/` @@ -49,11 +49,11 @@ Pipeline: scout → implementer → verifier → reviewer → closer → (retros Onboarding agent (out of the pipeline): `interviewer` — invoked by `ca onboard --interview` to capture evidence strategy rationale, verification notes, and repo learnings. -Lifecycle: `.case/tasks/active/` → PR opened/merged status in the task JSON +Lifecycle: `td` issue created → PR opened/merged status tracked on the task record ## Working in a Target Repo -0. Run `ca session {repo-path} --task {task-json}` to gather context +0. Run `ca session {repo-path} --task {td-id}` to gather context 1. Read the repo's `CLAUDE.md` (or `CLAUDE.local.md`) for project-specific instructions 2. Run `ca bootstrap {repo-name}` to verify readiness 3. Follow the repo's PR checklist before opening a PR diff --git a/agents/closer.md b/agents/closer.md index 7530511..c336195 100644 --- a/agents/closer.md +++ b/agents/closer.md @@ -1,19 +1,18 @@ --- name: closer -description: PR creation agent for /case. Drafts thorough PR descriptions from task file and verification evidence. Verifies all evidence gates before PR creation. Never implements or tests. +description: PR creation agent for /case. Drafts thorough PR descriptions from the task and verification evidence. Verifies all evidence gates before PR creation. Never implements or tests. tools: ['Read', 'Bash', 'Glob', 'Grep'] --- # Closer — PR Creation Agent -Create a pull request with a thorough description based on the task file, progress log, and verification evidence. You are the only agent that runs `gh pr create`. You must verify all evidence gates yourself before attempting to create the PR. +Create a pull request with a thorough description based on the task, progress log, and verification evidence. You are the only agent that runs `gh pr create`. You must verify all evidence gates yourself before attempting to create the PR. ## Input You receive from the orchestrator: -- **Task file path** — absolute path to the `.md` task file under the target repo's ignored `.case/tasks/active/` -- **Task JSON path** — the `.task.json` companion +- **td issue handle** — the `td-…` id for this task (shown as **td issue** in the Task Context block); pass it to `ca status`/`ca session` - **Target repo path** — absolute path to the repo - **Verifier AGENT_RESULT** — structured output from the verifier (screenshot URLs, evidence markers, pass/fail) @@ -24,26 +23,26 @@ You receive from the orchestrator: Run the session command to orient yourself: ```bash -SESSION=$(ca session --task ) +SESSION=$(ca session --task ) echo "$SESSION" ``` -Read the output to understand: current branch, last commits, task status, which agents have run, and what evidence exists. This replaces manual git log / task file discovery. +Read the output to understand: current branch, last commits, task status, which agents have run, and what evidence exists. This replaces manual git log / task discovery. ### 0.5. Record Start Mark yourself as running with a start timestamp immediately: ```bash -ca status agent closer status running -ca status agent closer started now +ca status agent closer status running +ca status agent closer started now ``` ### 1. Gather Context -1. Read the task file (`.md`) — full content including progress log entries from all agents -2. Read the task JSON for issue reference, repo, branch -3. Read verification evidence markers (get task slug from `.case/active`, markers are under `.case//`): +1. Read the task (`td show `) — full content including progress log entries from all agents +2. Read the task record for issue reference, repo, branch +3. Read verification evidence markers (the task slug is the taskId — the **Task** id in the Task Context block, or `SLUG=$(ca status id)`; markers are under `.case//`): - `.case//tested` — should have `output_hash` field - `.case//manual-tested` — should have `evidence` field (if src/ files changed) - `.case//reviewed` — should have `critical: 0` (review findings summary) @@ -108,12 +107,12 @@ Closes # Before running `gh pr create`, verify every requirement. -**CRITICAL: Check the task JSON first.** Read the task JSON and confirm the reviewer agent phase shows `"status": "completed"`. If the reviewer never ran, STOP — do not attempt to create the PR. Report the missing reviewer phase in your error output so the orchestrator can dispatch the reviewer. +**CRITICAL: Check the task record first.** Read the task and confirm the reviewer agent phase shows `"status": "completed"`. If the reviewer never ran, STOP — do not attempt to create the PR. Report the missing reviewer phase in your error output so the orchestrator can dispatch the reviewer. -1. **Reviewer ran**: Read the task JSON and confirm `agents.reviewer.status` is `"completed"` +1. **Reviewer ran**: Read the task and confirm `agents.reviewer.status` is `"completed"` ```bash - test "$(ca status agent reviewer status)" = "completed" + test "$(ca status agent reviewer status)" = "completed" ``` 2. **Branch**: Verify not on main/master @@ -128,7 +127,7 @@ Before running `gh pr create`, verify every requirement. 3. **Test evidence**: Read `.case//tested` — must exist with `output_hash` field ```bash - SLUG=$(cat .case/active | tr -d '[:space:]') + SLUG=$(ca status id) test -f ".case/${SLUG}/tested" && grep -q "output_hash:" ".case/${SLUG}/tested" ``` @@ -168,7 +167,7 @@ The body must contain verification keywords (any of: "verif", "tested", "test pl If the reviewer produced warnings or info findings (check `.case//reviewed` for `warnings` and `info` counts), post them as a PR review comment: ```bash -# Read findings from the reviewer's progress log entry in the task file +# Read findings from the reviewer's progress log entry in the task # Format as a comment gh api repos/{owner}/{repo}/pulls/{pr_number}/reviews \ --method POST \ @@ -188,18 +187,18 @@ Only post if there are actual findings to share. Skip this step if the reviewer ### 5. Record -1. **Update task JSON** — set agent phase completed, then transition status and record PR URL: +1. **Update the task** — set agent phase completed, then transition status and record PR URL: ```bash - ca status agent closer status completed - ca status agent closer completed now - ca status status pr-opened - ca status prUrl "" + ca status agent closer status completed + ca status agent closer completed now + ca status status pr-opened + ca status prUrl "" ``` Extract the PR URL from the `gh pr create` output. A null `prUrl` makes the task record incomplete — this is not optional. -2. **Append to the task file's Progress Log**: +2. **Append to the task's Progress Log**: ```markdown ### Closer — diff --git a/agents/implementer.md b/agents/implementer.md index 48217ee..4b5186a 100644 --- a/agents/implementer.md +++ b/agents/implementer.md @@ -12,8 +12,7 @@ Implement a fix or feature in the target repo. Write code, run automated tests, You receive from the orchestrator: -- **Task file path** — absolute path to the `.md` task file under the target repo's ignored `.case/tasks/active/` -- **Task JSON path** — the `.task.json` companion (same stem as the .md) +- **td issue handle** — the `td-…` id for this task (shown as **td issue** in the Task Context block); pass it to `ca status`/`ca session` - **Target repo path** — absolute path to the repo where you'll work - **Issue summary** — title, body, and key details from the GitHub/Linear issue - **Project commands** — setup/test/typecheck/lint/build commands from `projects.json`, when available @@ -26,34 +25,34 @@ You receive from the orchestrator: Run the session command to orient yourself: ```bash -SESSION=$(ca session --task ) +SESSION=$(ca session --task ) echo "$SESSION" ``` -Read the output to understand: current branch, last commits, task status, which agents have run, and what evidence exists. This replaces manual git log / task file discovery. +Read the output to understand: current branch, last commits, task status, which agents have run, and what evidence exists. This replaces manual git log / task discovery. ### 1. Setup -1. Update task JSON: set status to `implementing` and agent phase to running +1. Update the task: set status to `implementing` and agent phase to running ```bash - ca status status implementing - ca status agent implementer status running - ca status agent implementer started now + ca status status implementing + ca status agent implementer status running + ca status agent implementer started now ``` -2. Read the task file (`.md`) — understand the objective, acceptance criteria, and checklist +2. Read the task (`td show `) — understand the objective, acceptance criteria, and checklist 3. Read the target repo's `CLAUDE.md` for project-specific instructions -4. Read the playbook referenced in the task file +4. Read the playbook referenced in the task 5. Use the Project Commands section in this prompt for available commands (test, typecheck, lint, build, format). If it is absent, inspect `package.json` and `CLAUDE.md`. 6. Read the target repo's `.case/learnings.md` for tactical knowledge from previous tasks in this repo, if it exists -7. Check for working memory — the orchestrator already injects structured working memory as a `## Prior Context` block at the top of this prompt when one exists. Review it carefully: it lists what previous runs tried, what failed, blockers, and files changed so far. **Do not repeat approaches marked `[failed]`**. If a `{task-stem}.working.md` file also exists alongside the task file, read it as well — it's the legacy free-form variant kept for back-compat. -8. If the task JSON has a `checkCommand`, run it now and record the output as your baseline: +7. Check for working memory — the orchestrator already injects structured working memory as a `## Prior Context` block at the top of this prompt when one exists. Review it carefully: it lists what previous runs tried, what failed, blockers, and files changed so far. **Do not repeat approaches marked `[failed]`**. +8. If the task has a `checkCommand`, run it now and record the output as your baseline: ```bash - BASELINE=$(eval "$(jq -r '.checkCommand' )" 2>/dev/null) + BASELINE=$(eval "$(ca status checkCommand)" 2>/dev/null) echo "Baseline: $BASELINE" ``` - If `checkBaseline` is null in the task JSON, save the baseline: + If `checkBaseline` is null, save the baseline: ```bash - ca status checkBaseline "$BASELINE" + ca status checkBaseline "$BASELINE" ``` ### 2. Implement @@ -94,7 +93,7 @@ After each implementation attempt, measure whether you made progress: 1. **Run fast tests first** (two-tier verification). If the task has a `fastTestCommand`, use it: ```bash - FAST_CMD=$(jq -r '.fastTestCommand // empty' ) + FAST_CMD=$(ca status fastTestCommand) if [[ -n "$FAST_CMD" ]]; then eval "$FAST_CMD" > /tmp/fast-test.log 2>&1 || { echo "FAST TESTS FAILED:"; tail -10 /tmp/fast-test.log; } fi @@ -113,7 +112,7 @@ After each implementation attempt, measure whether you made progress: 2. If the task has a `checkCommand`, run it: ```bash - CURRENT=$(eval "$(jq -r '.checkCommand' )" 2>/dev/null) + CURRENT=$(eval "$(ca status checkCommand)" 2>/dev/null) echo "Baseline: $BASELINE → Current: $CURRENT" ``` 3. If `CURRENT` moved toward `checkTarget` (or tests went from failing to passing) → **keep** the commit @@ -200,7 +199,7 @@ Fix any errors before proceeding. Warnings should be addressed if feasible but d pnpm test 2>&1 | ca mark-tested ``` - This creates `.case//tested` with a hash of test output AND updates the task JSON `tested` field. You do NOT set `tested` directly. + This creates `.case//tested` with a hash of test output AND updates the task's `tested` field. You do NOT set `tested` directly. 2. **Commit with a conventional message**: @@ -210,7 +209,7 @@ Fix any errors before proceeding. Warnings should be addressed if feasible but d Types: `feat`, `fix`, `docs`, `refactor`, `test`, `chore`. Use imperative mood. Keep subject under 72 chars. Body explains why, not what. -3. **Append to the task file's Progress Log**: +3. **Append to the task's Progress Log**: ```markdown ### Implementer — @@ -222,15 +221,15 @@ Fix any errors before proceeding. Warnings should be addressed if feasible but d - Commit: ``` -4. **Update task JSON**: +4. **Update the task**: ```bash - ca status agent implementer status completed - ca status agent implementer completed now + ca status agent implementer status completed + ca status agent implementer completed now ``` ### 4b. Update Working Memory -**Always do this, even on failure.** Persist structured progress via the `ca update-memory` CLI. It writes `.case//working-memory.json`, which the orchestrator reads before dispatching the next phase (or the next implementer cycle). +**Always do this, even on failure.** Persist structured progress via the `ca update-memory` CLI. It writes `.case//working-memory.json` (slug = taskId), which the orchestrator reads before dispatching the next phase (or the next implementer cycle). Record at least the current state and the approach you used. If you tried multiple approaches, record each with its outcome. If you hit errors, record their resolution status. Examples: @@ -277,7 +276,7 @@ If you failed, set `"status":"failed"` and fill in the `"error"` field. Still en - **Never run browser automation.** That's the verifier's job. - **Never create PRs or push.** That's the closer's job. - **Never create manual-tested markers.** That's the verifier's job via `ca mark-manual-tested`. -- **Never set `tested` or `manualTested` directly in task JSON.** The marker script handles `tested` as a side effect. +- **Never set `tested` or `manualTested` directly on the task.** The marker script handles `tested` as a side effect. - **Always commit before returning.** The verifier needs a clean diff to review. - **Always update the progress log.** The closer reads it to draft the PR description. - **Always end with `<<>>`.** The orchestrator depends on this. diff --git a/agents/retrospective.md b/agents/retrospective.md index 81f04ac..827b882 100644 --- a/agents/retrospective.md +++ b/agents/retrospective.md @@ -12,8 +12,7 @@ You run after every `/case` pipeline completion (success or failure). Your job: You receive from the orchestrator: -- **Task file path** — absolute path to the `.md` task file (with progress log from all agents) -- **Task JSON path** — the `.task.json` companion (with status, agent phases, evidence flags) +- **td issue handle** — the `td-…` id for this task (shown as **td issue** in the Task Context block); pass it to `ca status`/`ca session`. The task record carries the progress log, status, agent phases, and evidence flags. - **Pipeline outcome** — "completed" (PR created) or "failed" (stopped at some agent) - **Failed agent** (if applicable) — which agent failed and the AGENT_RESULT error @@ -24,16 +23,16 @@ You receive from the orchestrator: Run the session-start command to orient yourself: ```bash -SESSION=$(ca session --task ) +SESSION=$(ca session --task ) echo "$SESSION" ``` -Read the output to understand: current branch, last commits, task status, which agents have run, and what evidence exists. This replaces manual git log / task file discovery. +Read the output to understand: current branch, last commits, task status, which agents have run, and what evidence exists. This replaces manual git log / task discovery. ### 1. Read the Full Record -1. Read the task file — focus on the `## Progress Log` section -2. Read the task JSON — check agent phase statuses, timing, evidence flags +1. Read the task (`td show `) — focus on the `## Progress Log` section +2. Read the task record — check agent phase statuses, timing, evidence flags 3. If the pipeline failed, read the failed agent's error from AGENT_RESULT ### 2. Analyze for Improvement Signals @@ -107,7 +106,7 @@ If any of your proposals target an agent prompt (`agents/*.md`), create a snapsh ```bash ca snapshot \ - --task "" \ + --task "" \ --reason "<1-line: what metric or failure motivated this change>" ``` @@ -122,7 +121,7 @@ For each finding, create a proposal file in `.case/amendments/` under the target **Priority:** high | medium | low **Target file:** {path relative to case/} -**Triggered by:** {task filename} — {brief description of what happened} +**Triggered by:** {task id} — {brief description of what happened} **Metrics motivation:** {what measurement or observation led to this} **Prompt version:** {version tag from `ca snapshot`, if target is agents/\*.md — otherwise omit} @@ -155,7 +154,7 @@ Filename format: `{YYYY-MM-DD}-{slug}.md` (e.g., `2026-03-14-implementer-esm-rem **What you must NEVER edit:** - Target repo source code (anything outside `.case/`) -- Task files in `.case/tasks/active/` (those are the record of what happened) +- Task records in the repo's `td` store (`td list`, `td show `) (those are the record of what happened) - `projects.json` schema or structure ### 4b. Update Repo Learnings (direct — no staging required) @@ -177,12 +176,12 @@ Repo learnings are tactical, low-risk, and append-only. These are the ONE thing **How to append:** -1. Identify the target repo from the task file's `## Target Repos` section +1. Identify the target repo from the task's `## Target Repos` section 2. Read `.case/learnings.md` 3. Check if a similar learning already exists (don't duplicate) 4. Append a new entry: ``` - - **{YYYY-MM-DD}** — `{file or area}`: {1-2 line tactical note}. (from task {task-filename}) + - **{YYYY-MM-DD}** — `{file or area}`: {1-2 line tactical note}. (from task {task-id}) ``` ### 4c. Escalate Repeated Violations diff --git a/agents/reviewer.md b/agents/reviewer.md index 50648c4..e9f6bcb 100644 --- a/agents/reviewer.md +++ b/agents/reviewer.md @@ -12,8 +12,7 @@ You start with a **completely fresh context**. You did not write the code — yo You receive from the orchestrator: -- **Task file path** — absolute path to the `.md` task file under the target repo's ignored `.case/tasks/active/` -- **Task JSON path** — the `.task.json` companion +- **td issue handle** — the `td-…` id for this task (shown as **td issue** in the Task Context block); pass it to `ca status`/`ca session` - **Target repo path** — absolute path to the repo where the fix was implemented ## Workflow @@ -23,21 +22,21 @@ You receive from the orchestrator: Run the session command to orient yourself: ```bash -SESSION=$(ca session --task ) +SESSION=$(ca session --task ) echo "$SESSION" ``` -Read the output to understand: current branch, last commits, task status, which agents have run, and what evidence exists. This replaces manual git log / task file discovery. +Read the output to understand: current branch, last commits, task status, which agents have run, and what evidence exists. This replaces manual git log / task discovery. ### 1. Gather Context -1. Update task JSON: +1. Update the task: ```bash - ca status status reviewing - ca status agent reviewer status running - ca status agent reviewer started now + ca status status reviewing + ca status agent reviewer status running + ca status agent reviewer started now ``` -2. Read the task file — understand the issue, objective, and acceptance criteria +2. Read the task (`td show `) — understand the issue, objective, and acceptance criteria 3. Read the git diff to understand what the implementer changed: ```bash git log --oneline -5 @@ -45,7 +44,7 @@ Read the output to understand: current branch, last commits, task status, which git diff main ``` 4. Read the Golden Principles section in this prompt — all invariants -5. Read structured test output from `.case//tested` (Phase 1 format with passed/failed/total/duration_ms/suites/files fields). Get the task slug from `.case/active`. +5. Read structured test output from `.case//tested` (Phase 1 format with passed/failed/total/duration_ms/suites/files fields). The task slug is the taskId — the **Task** id in the Task Context block (or `SLUG=$(ca status id)`). 6. Read the target repo's `CLAUDE.md` for repo-specific conventions ### 2. Review the Diff @@ -131,7 +130,7 @@ Format each finding as: 2. If **critical findings exist**: do NOT create the marker. Report the findings so the orchestrator can re-dispatch the implementer. -3. **Append to the task file's Progress Log**: +3. **Append to the task's Progress Log**: ```markdown ### Reviewer — @@ -143,10 +142,10 @@ Format each finding as: - Evidence: .case//reviewed (created/not created) ``` -4. **Update task JSON**: +4. **Update the task**: ```bash - ca status agent reviewer status completed - ca status agent reviewer completed now + ca status agent reviewer status completed + ca status agent reviewer completed now ``` ### 4b. Score Rubric diff --git a/agents/scout.md b/agents/scout.md index 4fead1b..a9a67b1 100644 --- a/agents/scout.md +++ b/agents/scout.md @@ -19,8 +19,7 @@ You are **strictly read-only**: You receive from the orchestrator: -- **Task file path** — absolute path to the `.md` task file describing the change -- **Task JSON path** — the `.task.json` companion +- **td issue handle** — the `td-…` id for this task (shown as **td issue** in the Task Context block); pass it to `ca status`/`ca session` - **Target repo path** — absolute path to the repo where the implementer will work - **Repo name**, **evidence strategy**, **package manager**, **issue reference** (when present), and **project commands** (build/test/etc.) @@ -30,7 +29,7 @@ You have a **3-minute wall-clock budget** by default. Do not exceed it. If you h ### 1. Read the task -1. Read the task file to understand the objective, scope, and acceptance criteria. +1. Read the task (`td show `) to understand the objective, scope, and acceptance criteria. 2. Note the issue type (bug / feature / refactor) and any explicit `## Evidence Expectations`. 3. If the task references specific files or symbols, capture them as the first entries in `relevantFiles`. @@ -71,7 +70,7 @@ Note any gotchas the implementer must respect: - Deprecated APIs that look attractive but should not be used. - Pending migrations or refactors that the new change must align with. -- Known issues in the affected area (referenced in `// TODO`, `// FIXME`, or the task file). +- Known issues in the affected area (referenced in `// TODO`, `// FIXME`, or the task). - Project conventions that aren't obvious from the code (e.g., "all CLI commands live in `src/commands/`"). Keep constraints to short, actionable bullets — full sentences, no editorializing. diff --git a/agents/verifier.md b/agents/verifier.md index e951429..faf2f1f 100644 --- a/agents/verifier.md +++ b/agents/verifier.md @@ -12,8 +12,7 @@ You start with a **completely fresh context**. You did not write the code — yo You receive from the orchestrator: -- **Task file path** — absolute path to the `.md` task file under the target repo's ignored `.case/tasks/active/` -- **Task JSON path** — the `.task.json` companion +- **td issue handle** — the `td-…` id for this task (shown as **td issue** in the Task Context block); pass it to `ca status`/`ca session` - **Target repo path** — absolute path to the repo where the fix was implemented ## Workflow @@ -23,23 +22,23 @@ You receive from the orchestrator: Run the session command to orient yourself: ```bash -SESSION=$(ca session --task ) +SESSION=$(ca session --task ) echo "$SESSION" ``` -Read the output to understand: current branch, last commits, task status, which agents have run, and what evidence exists. This replaces manual git log / task file discovery. +Read the output to understand: current branch, last commits, task status, which agents have run, and what evidence exists. This replaces manual git log / task discovery. ### 1. Assess > **Prior context:** if the implementer ran before you, the orchestrator prepends a `## Prior Context` block to this prompt that summarizes their approach, the files they changed, and any errors they hit. Use it to scope your verification — focus on the listed files and the implementer's stated approach rather than re-deriving everything from `git diff`. If the block is absent, this is a cold start. -1. Update task JSON: +1. Update the task: ```bash - ca status status verifying - ca status agent verifier status running - ca status agent verifier started now + ca status status verifying + ca status agent verifier status running + ca status agent verifier started now ``` -2. Read the task file — understand the issue, objective, and acceptance criteria +2. Read the task (`td show `) — understand the issue, objective, and acceptance criteria 3. **Read the `## Evidence Expectations` section.** This is the contract from the orchestrator — it specifies exactly what evidence you must produce. Your verification plan must satisfy every expectation listed. If the section is missing or vague, treat it as a defect and report it rather than guessing. 4. Read the git diff to understand what the implementer changed: ```bash @@ -47,7 +46,7 @@ Read the output to understand: current branch, last commits, task status, which git diff HEAD~1 --stat git diff HEAD~1 ``` -5. Read the issue reference from the task file to understand what to test specifically +5. Read the issue reference from the task to understand what to test specifically ### 2. Determine Scope @@ -103,7 +102,7 @@ For library repos, you verify by writing and running a **scenario script** that This is the critical step. Write a short script (10-30 lines) that exercises the **specific change** from the issue as an external consumer would use it. This catches things unit tests miss: export issues, real API behavior, integration gaps. -5. **Read the issue** from the task file to understand the exact scenario. +5. **Read the issue** from the task to understand the exact scenario. 6. **Read credentials** if the scenario needs real API calls. The credentials file path is in the Task Context under **Credentials**: @@ -144,13 +143,13 @@ This is the critical step. Write a short script (10-30 lines) that exercises the 10. Continue to step 5 (Record). -**Credential safety:** The scenario script reads credentials from env vars at runtime. **Never** write credential values into the script file, task file, or AGENT_RESULT. The script in `/tmp/` is disposable and not committed. +**Credential safety:** The scenario script reads credentials from env vars at runtime. **Never** write credential values into the script file, the task, or AGENT_RESULT. The script in `/tmp/` is disposable and not committed. ### 3. Test the Specific Fix **This is the critical step.** You must test the exact scenario described in the issue — not just the happy path. -1. Read the issue description from the task file's `## Issue Reference` or `## Objective` section +1. Read the issue description from the task's `## Issue Reference` or `## Objective` section 2. Identify the specific bug/feature scenario to reproduce 3. Use the Task Context and target repo structure to find an example app, if one exists @@ -274,11 +273,11 @@ Most AuthKit example apps redirect to the WorkOS hosted login page. Follow this ```bash ca mark-manual-tested ``` - This checks for recent playwright screenshots and creates `.case//manual-tested` with evidence. It also updates the task JSON `manualTested` field. You do NOT set `manualTested` directly. + This checks for recent playwright screenshots and creates `.case//manual-tested` with evidence. It also updates the task's `manualTested` field. You do NOT set `manualTested` directly. ### 5. Record -1. **Append to the task file's Progress Log**: +1. **Append to the task's Progress Log**: ```markdown ### Verifier — @@ -293,15 +292,15 @@ Most AuthKit example apps redirect to the WorkOS hosted login page. Follow this - Evidence: .case//tested (from implementer), .case//manual-tested (created) ``` -2. **Update task JSON**: +2. **Update the task**: ```bash - ca status agent verifier status completed - ca status agent verifier completed now + ca status agent verifier status completed + ca status agent verifier completed now ``` ### 5b. Score Rubric -After testing, re-read the `## Evidence Expectations` section from the task file. For each expectation listed, confirm your evidence satisfies it. If any expectation is unmet, your rubric verdict for `evidence-proves-change` must be `fail` — even if the generic rubric questions would pass. +After testing, re-read the `## Evidence Expectations` section from the task. For each expectation listed, confirm your evidence satisfies it. If any expectation is unmet, your rubric verdict for `evidence-proves-change` must be `fail` — even if the generic rubric questions would pass. Score each category honestly. `fail` means the evidence doesn't support this claim. `na` means the category genuinely doesn't apply (justify why in detail). @@ -337,7 +336,7 @@ If verification failed (the fix doesn't work), set `"status":"failed"` and descr - **Never edit source code.** You verify, not implement. - **Never commit.** The implementer already committed. - **Never create PRs.** That's the closer's job. -- **Never set `tested` or `manualTested` directly in task JSON.** Marker commands handle this. +- **Never set `tested` or `manualTested` directly on the task.** Marker commands handle this. - **Always test the specific fix scenario.** "It loads" is not verification. "The org switch works with a custom cookie name" is verification. Your before/after screenshots must show a visible difference. - **Always complete the login flow when testing authenticated features.** Use the credentials from Task Context and follow the login procedure in the Verification Notes (if provided) or step 3c. Never screenshot an unauthenticated landing page as "evidence" for an auth feature. - **Never record video of a page doing nothing.** If you use video, the recording must capture real interactions. If you're only loading a page and taking a screenshot, skip video entirely. diff --git a/docs/conventions/entropy-management.md b/docs/conventions/entropy-management.md index 59db830..8956958 100644 --- a/docs/conventions/entropy-management.md +++ b/docs/conventions/entropy-management.md @@ -63,5 +63,5 @@ When drift is detected: 1. Read the failures array in the JSON output 2. Fix the lowest-effort issues first (commit format, missing fields) -3. For structural issues (file sizes, missing tests), create a task in the target repo's `.case/tasks/active/` +3. For structural issues (file sizes, missing tests), create a task in the target repo's `td` store (`ca create …` / `td create …`) 4. Run `ca check --repo {name}` to verify fixes diff --git a/docs/golden-principles.md b/docs/golden-principles.md index 85b33b2..574adf9 100644 --- a/docs/golden-principles.md +++ b/docs/golden-principles.md @@ -96,4 +96,4 @@ Check: Every call to `unsealData` / `decryptSession` must be wrapped in try-catc **[enforced]** The reviewer agent must produce a `.case//reviewed` marker with `critical: 0` before the closer can create a PR. Critical findings (enforced principle violations, failing tests, missing test coverage for public API changes) block PR creation. Advisory findings are posted as PR comments. -Check: `SLUG=$(cat .case/active | tr -d '[:space:]') && test -f ".case/${SLUG}/reviewed" && grep -q "critical: 0" ".case/${SLUG}/reviewed"` +Check: `SLUG=$(ca status id) && test -f ".case/${SLUG}/reviewed" && grep -q "critical: 0" ".case/${SLUG}/reviewed"` diff --git a/docs/playbooks/README.md b/docs/playbooks/README.md index 5c6112f..8875fca 100644 --- a/docs/playbooks/README.md +++ b/docs/playbooks/README.md @@ -14,12 +14,12 @@ Step-by-step guides for recurring operations across WorkOS OSS repos. Each playb ## How Playbooks Work -1. A human fills in a task template (from `tasks/templates/`) and drops it in the target repo's `.case/tasks/active/`. +1. A human fills in a task template (from `tasks/templates/`) and creates a `td` issue in the target repo's `.todos/` store. 2. The implementer reads the task and playbook, writes the fix/feature, and commits. 3. The verifier tests the specific scenario with fresh context. 4. The reviewer checks the diff against golden principles and conventions. 5. The closer opens a PR in the target repo (requires `.case//reviewed`). -6. After merge, the task JSON status is updated; runtime task files stay in ignored `.case/` history. +6. After merge, the task record's status is updated in the repo's `td` store. ## Related Docs diff --git a/src/__tests__/assembler.spec.ts b/src/__tests__/assembler.spec.ts index 6427995..73c6a14 100644 --- a/src/__tests__/assembler.spec.ts +++ b/src/__tests__/assembler.spec.ts @@ -20,8 +20,8 @@ async function setupTemplates() { function makeConfig(overrides: Partial = {}): PipelineConfig { return { mode: 'attended', - taskJsonPath: join(tempCaseRoot, '.case/tasks/active/cli-1-issue-53.task.json'), - taskMdPath: join(tempCaseRoot, '.case/tasks/active/cli-1-issue-53.md'), + taskId: 'cli-1-issue-53', + tdId: 'td-test1', repoPath: tempCaseRoot, repoName: 'cli', packageRoot: tempCaseRoot, @@ -136,7 +136,8 @@ describe('assemblePrompt', () => { const prompt = await assemblePrompt('verifier', makeConfig(), makeTask(), repoContext, new Map()); expect(prompt).toContain('# Verifier Template'); - expect(prompt).toContain('Task file'); + expect(prompt).toContain('- **Task**: cli-1-issue-53'); + expect(prompt).toContain('- **td issue**: td-test1'); expect(prompt).not.toContain('should not appear'); expect(prompt).not.toContain('Working Memory'); }); diff --git a/src/__tests__/helpers/td-task.ts b/src/__tests__/helpers/td-task.ts new file mode 100644 index 0000000..0f03728 --- /dev/null +++ b/src/__tests__/helpers/td-task.ts @@ -0,0 +1,57 @@ +/** + * Test helper: create a `td`-backed Case task in a throwaway repo. + * + * Replaces the old pattern of hand-writing `.case/tasks/active/.task.json` + * fixtures. Spins up a real `td` database (the `td` binary must be on PATH) in + * a temp dir, creates the task via the production {@link createTask}, then + * applies any state overrides through the production {@link TaskStore} so tests + * exercise the same read/write path as the pipeline. + */ +import { mkdtempSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { createTask } from '../../entry/task-factory.js'; +import { TaskStore } from '../../state/task-store.js'; +import type { TaskCreateRequest, TaskJson } from '../../types.js'; + +export interface TdTaskFixture { + repoPath: string; + tdId: string; + taskId: string; + store: TaskStore; +} + +export interface CreateTdTaskOptions { + /** Existing repo dir (with or without a td db). A temp dir is made when omitted. */ + repoPath?: string; + /** Overrides applied to the TaskCreateRequest before creation. */ + request?: Partial; + /** State overrides written back after creation (status, agents, prUrl, etc.). */ + overrides?: Partial; +} + +export function makeTempRepo(): string { + return mkdtempSync(join(tmpdir(), 'case-td-')); +} + +export async function createTdTask(opts: CreateTdTaskOptions = {}): Promise { + const repoPath = opts.repoPath ?? makeTempRepo(); + + const request: TaskCreateRequest = { + repo: 'cli', + title: 'Fix the flaky login test', + description: 'The login test fails intermittently.', + trigger: { type: 'cli', user: 'test' }, + evidenceExpectations: 'Full test suite passes.', + ...opts.request, + }; + + const { taskId, tdId } = await createTask(repoPath, request, { repoPath }); + const store = new TaskStore(repoPath, tdId); + + if (opts.overrides) { + await store.writeFromProjection(opts.overrides); + } + + return { repoPath, tdId, taskId, store }; +} diff --git a/src/__tests__/implement-phase.spec.ts b/src/__tests__/implement-phase.spec.ts index 8c4447b..9a0fcce 100644 --- a/src/__tests__/implement-phase.spec.ts +++ b/src/__tests__/implement-phase.spec.ts @@ -18,8 +18,8 @@ async function setupTempFiles() { function makeConfig(overrides: Partial = {}): PipelineConfig { return { mode: 'attended', - taskJsonPath: join(tempCaseRoot, '.case/tasks/active/cli-1.task.json'), - taskMdPath: join(tempCaseRoot, '.case/tasks/active/cli-1.md'), + taskId: 'cli-1', + tdId: 'td-test1', repoPath: tempCaseRoot, repoName: 'cli', packageRoot: tempCaseRoot, diff --git a/src/__tests__/interview.spec.ts b/src/__tests__/interview.spec.ts index 727c13e..4716862 100644 --- a/src/__tests__/interview.spec.ts +++ b/src/__tests__/interview.spec.ts @@ -187,11 +187,13 @@ describe('synthesizeProjectEntry', () => { expect(entry.commands.setup).toBe('pnpm install'); }); - it('keeps detected command when override value is empty', () => { + it('deletes the detected command when override value is empty', () => { const findings = makeFindings({ commandOverrides: { test: ' ' } }); const detected = makeDetected(); const entry = synthesizeProjectEntry(findings, detected); - expect(entry.commands.test).toBe('pnpm test'); + expect(entry.commands.test).toBeUndefined(); + // unrelated detected commands are preserved + expect(entry.commands.build).toBe('pnpm build'); }); it('adds new commands from overrides not present in detection', () => { diff --git a/src/__tests__/pipeline-tool.spec.ts b/src/__tests__/pipeline-tool.spec.ts index 78adf3e..0b7f0d1 100644 --- a/src/__tests__/pipeline-tool.spec.ts +++ b/src/__tests__/pipeline-tool.spec.ts @@ -24,8 +24,8 @@ describe('createPipelineTool', () => { mockBuildPipelineConfig.mockResolvedValue({ mode: 'attended', - taskJsonPath: '/repos/cli/.case/tasks/active/cli-1.task.json', - taskMdPath: '/repos/cli/.case/tasks/active/cli-1.md', + taskId: 'cli-1', + tdId: 'td-test1', repoPath: '/repos/cli', repoName: 'cli', packageRoot: '/case', @@ -44,10 +44,11 @@ describe('createPipelineTool', () => { }); it('calls buildPipelineConfig with correct params', async () => { - await tool.execute('call-1', { taskJsonPath: '/tasks/test.task.json' }, undefined, undefined, {} as any); + await tool.execute('call-1', { tdId: 'td-test1', repoPath: '/some/repo' }, undefined, undefined, {} as any); expect(mockBuildPipelineConfig).toHaveBeenCalledWith({ - taskJsonPath: '/tasks/test.task.json', + tdId: 'td-test1', + repoPath: '/some/repo', mode: 'attended', dryRun: false, }); @@ -56,21 +57,22 @@ describe('createPipelineTool', () => { it('passes mode and dryRun when provided', async () => { await tool.execute( 'call-2', - { taskJsonPath: '/tasks/test.task.json', mode: 'unattended', dryRun: true }, + { tdId: 'td-test1', repoPath: '/some/repo', mode: 'unattended', dryRun: true }, undefined, undefined, {} as any, ); expect(mockBuildPipelineConfig).toHaveBeenCalledWith({ - taskJsonPath: '/tasks/test.task.json', + tdId: 'td-test1', + repoPath: '/some/repo', mode: 'unattended', dryRun: true, }); }); it('calls runPipeline with the built config', async () => { - await tool.execute('call-3', { taskJsonPath: '/tasks/test.task.json' }, undefined, undefined, {} as any); + await tool.execute('call-3', { tdId: 'td-test1', repoPath: '/some/repo' }, undefined, undefined, {} as any); expect(mockRunPipeline).toHaveBeenCalledTimes(1); const config = mockRunPipeline.mock.calls[0][0]; @@ -80,14 +82,14 @@ describe('createPipelineTool', () => { it('returns success content on completion', async () => { const result = await tool.execute( 'call-4', - { taskJsonPath: '/tasks/test.task.json' }, + { tdId: 'td-test1', repoPath: '/some/repo' }, undefined, undefined, {} as any, ); expect(result.content[0]).toEqual({ type: 'text', text: 'Pipeline completed successfully.' }); - expect(result.details).toEqual({ taskJsonPath: '/tasks/test.task.json' }); + expect(result.details).toEqual({ tdId: 'td-test1' }); }); it('streams progress via onUpdate when heartbeat fires', async () => { @@ -102,12 +104,12 @@ describe('createPipelineTool', () => { } }); - await tool.execute('call-5', { taskJsonPath: '/tasks/test.task.json' }, undefined, onUpdate, {} as any); + await tool.execute('call-5', { tdId: 'td-test1', repoPath: '/some/repo' }, undefined, onUpdate, {} as any); expect(onUpdate).toHaveBeenCalledTimes(2); expect(updates[0]).toEqual({ content: [{ type: 'text', text: '... still running (5s)\n' }], - details: { taskJsonPath: '/tasks/test.task.json' }, + details: { tdId: 'td-test1' }, }); }); @@ -115,7 +117,7 @@ describe('createPipelineTool', () => { mockRunPipeline.mockRejectedValue(new Error('Pipeline exploded')); await expect( - tool.execute('call-6', { taskJsonPath: '/tasks/test.task.json' }, undefined, undefined, {} as any), + tool.execute('call-6', { tdId: 'td-test1', repoPath: '/some/repo' }, undefined, undefined, {} as any), ).rejects.toThrow('Pipeline exploded'); }); }); diff --git a/src/__tests__/pipeline.spec.ts b/src/__tests__/pipeline.spec.ts index 4c96ee0..4461670 100644 --- a/src/__tests__/pipeline.spec.ts +++ b/src/__tests__/pipeline.spec.ts @@ -77,8 +77,8 @@ const mockRuntime = { function makeConfig(overrides: Partial = {}): PipelineConfig { return { mode: 'attended', - taskJsonPath: join(tempCaseRoot, '.case/tasks/active/cli-1.task.json'), - taskMdPath: join(tempCaseRoot, '.case/tasks/active/cli-1.md'), + taskId: 'cli-1', + tdId: 'td-test1', repoPath: tempCaseRoot, repoName: 'cli', packageRoot: tempCaseRoot, diff --git a/src/__tests__/prefetch.spec.ts b/src/__tests__/prefetch.spec.ts index 67aff17..b5ae42f 100644 --- a/src/__tests__/prefetch.spec.ts +++ b/src/__tests__/prefetch.spec.ts @@ -14,8 +14,8 @@ let packageRoot: string; function makeConfig(overrides: Partial = {}): PipelineConfig { return { mode: 'attended', - taskJsonPath: join(repoDir, '.case/tasks/active/cli-1.task.json'), - taskMdPath: join(repoDir, '.case/tasks/active/cli-1.md'), + taskId: 'cli-1', + tdId: 'cli-1', repoPath: repoDir, repoName: 'cli', packageRoot, diff --git a/src/__tests__/review-phase.spec.ts b/src/__tests__/review-phase.spec.ts index 874ba6b..31459f2 100644 --- a/src/__tests__/review-phase.spec.ts +++ b/src/__tests__/review-phase.spec.ts @@ -17,8 +17,8 @@ async function setupTempFiles() { function makeConfig(overrides: Partial = {}): PipelineConfig { return { mode: 'attended', - taskJsonPath: join(tempCaseRoot, '.case/tasks/active/cli-1.task.json'), - taskMdPath: join(tempCaseRoot, '.case/tasks/active/cli-1.md'), + taskId: 'cli-1', + tdId: 'td-test1', repoPath: tempCaseRoot, repoName: 'cli', packageRoot: tempCaseRoot, diff --git a/src/__tests__/task-factory.spec.ts b/src/__tests__/task-factory.spec.ts index cf09ef3..371e017 100644 --- a/src/__tests__/task-factory.spec.ts +++ b/src/__tests__/task-factory.spec.ts @@ -1,5 +1,6 @@ import { describe, it, expect, beforeEach, afterEach } from 'bun:test'; import { createTask } from '../entry/task-factory.js'; +import { decodeState, extractSpec, tdCurrent, tdShow } from '../state/td-client.js'; import type { TaskCreateRequest } from '../types.js'; import { mkdir, rm } from 'node:fs/promises'; import { join } from 'node:path'; @@ -18,7 +19,7 @@ describe('createTask', () => { await rm(tempDir, { recursive: true, force: true }); }); - it('creates task.json and task.md files', async () => { + it('creates a focused td issue with the embedded task state', async () => { const request: TaskCreateRequest = { repo: 'cli', title: 'Fix broken test', @@ -30,23 +31,28 @@ describe('createTask', () => { const result = await createTask(tempDir, request, { repoPath: tempDir }); expect(result.taskId).toContain('cli-'); - expect(result.taskJsonPath).toContain('.task.json'); - expect(result.taskMdPath).toContain('.md'); - expect(result.taskJsonPath).toContain(join('.case', 'tasks', 'active')); - - const taskJson = JSON.parse(await Bun.file(result.taskJsonPath).text()); - expect(taskJson.id).toBe(result.taskId); - expect(taskJson.repo).toBe('cli'); - expect(taskJson.status).toBe('active'); - expect(taskJson.tested).toBe(false); - - const taskMd = await Bun.file(result.taskMdPath).text(); - expect(taskMd).toContain('Fix broken test'); - expect(taskMd).toContain('The login test'); - expect(taskMd).toContain('Repo:** cli'); - expect(taskMd).toContain('## Evidence Expectations'); - expect(taskMd).toContain('flaky login test passes 10 consecutive runs'); - expect((await Bun.file(join(tempDir, '.case', 'active')).text()).trim()).toBe(result.taskId); + expect(result.tdId).toMatch(/^td-/); + + const issue = await tdShow(tempDir, result.tdId); + expect(issue).not.toBeNull(); + + const taskJson = decodeState(issue!.description); + expect(taskJson).not.toBeNull(); + expect(taskJson!.id).toBe(result.taskId); + expect(taskJson!.repo).toBe('cli'); + expect(taskJson!.status).toBe('active'); + expect(taskJson!.tested).toBe(false); + expect(taskJson!.tdId).toBe(result.tdId); + + const spec = extractSpec(issue!.description); + expect(spec).toContain('Fix broken test'); + expect(spec).toContain('The login test'); + expect(spec).toContain('Repo:** cli'); + expect(spec).toContain('## Evidence Expectations'); + expect(spec).toContain('flaky login test passes 10 consecutive runs'); + + // The created task is focused (replaces the old .case/active marker). + expect(await tdCurrent(tempDir)).toBe(result.tdId); }); it('includes issue and trigger info', async () => { @@ -62,20 +68,22 @@ describe('createTask', () => { }; const result = await createTask(tempDir, request, { repoPath: tempDir }); - const taskJson = JSON.parse(await Bun.file(result.taskJsonPath).text()); + const issue = await tdShow(tempDir, result.tdId); + const taskJson = decodeState(issue!.description); - expect(taskJson.issueType).toBe('github'); - expect(taskJson.issue).toBe('https://github.com/workos/authkit-ssr/issues/42'); - expect(taskJson.mode).toBe('unattended'); + expect(taskJson!.issueType).toBe('github'); + expect(taskJson!.issue).toBe('https://github.com/workos/authkit-ssr/issues/42'); + expect(taskJson!.mode).toBe('unattended'); - const taskMd = await Bun.file(result.taskMdPath).text(); - expect(taskMd).toContain('webhook'); + const spec = extractSpec(issue!.description); + expect(spec).toContain('webhook'); + expect(spec).toContain('https://github.com/workos/authkit-ssr/issues/42'); }); it('includes check fields when provided', async () => { const request: TaskCreateRequest = { repo: 'cli', - title: 'Fix test', + title: 'Fix the broken unit test', description: 'Test is broken.', trigger: { type: 'manual', description: 'test' }, checkCommand: 'vitest run --reporter=json', @@ -85,10 +93,11 @@ describe('createTask', () => { }; const result = await createTask(tempDir, request, { repoPath: tempDir }); - const taskJson = JSON.parse(await Bun.file(result.taskJsonPath).text()); + const issue = await tdShow(tempDir, result.tdId); + const taskJson = decodeState(issue!.description); - expect(taskJson.checkCommand).toBe('vitest run --reporter=json'); - expect(taskJson.checkBaseline).toBe(10); - expect(taskJson.checkTarget).toBe(12); + expect(taskJson!.checkCommand).toBe('vitest run --reporter=json'); + expect(taskJson!.checkBaseline).toBe(10); + expect(taskJson!.checkTarget).toBe(12); }); }); diff --git a/src/__tests__/task-scanner.spec.ts b/src/__tests__/task-scanner.spec.ts index 4e744bf..3428d1a 100644 --- a/src/__tests__/task-scanner.spec.ts +++ b/src/__tests__/task-scanner.spec.ts @@ -1,220 +1,131 @@ -import { describe, it, expect, beforeEach, afterEach } from 'bun:test'; +import { describe, it, expect, beforeAll } from 'bun:test'; import { findTaskByIssue, findTaskByMarker } from '../entry/task-scanner.js'; -import type { TaskJson } from '../types.js'; -import { mkdir, rm, utimes } from 'node:fs/promises'; -import { join } from 'node:path'; - -let tempDir: string; -let repoDir: string; - -function makeTaskJson(overrides: Partial = {}): TaskJson { - return { - id: 'cli-abc-fix-test', - status: 'active', - created: '2026-03-14T00:00:00Z', - repo: 'cli', - issue: '1523', - issueType: 'github', - branch: 'fix/issue-1523', - agents: {}, - tested: false, - manualTested: false, - prUrl: null, - prNumber: null, - ...overrides, - }; -} - -async function writeLegacyTask(taskId: string, task: TaskJson): Promise { - const taskJsonPath = join(tempDir, 'tasks/active', `${taskId}.task.json`); - await mkdir(join(tempDir, 'tasks/active'), { recursive: true }); - await Bun.write(taskJsonPath, JSON.stringify(task, null, 2)); - return taskJsonPath; -} - -async function writeRepoTask(taskId: string, task: TaskJson): Promise { - const taskJsonPath = join(repoDir, '.case/tasks/active', `${taskId}.task.json`); - await mkdir(join(repoDir, '.case/tasks/active'), { recursive: true }); - await Bun.write(taskJsonPath, JSON.stringify(task, null, 2)); - return taskJsonPath; -} +import { createTdTask, makeTempRepo } from './helpers/td-task.js'; describe('task-scanner', () => { - const originalEnv = { ...process.env }; - - beforeEach(async () => { - tempDir = join(process.env.TMPDIR ?? '/tmp', `case-scanner-test-${Date.now()}`); - repoDir = join(tempDir, 'repo'); - await mkdir(join(repoDir, '.case/tasks/active'), { recursive: true }); - // Point the legacy data-dir fallback at a sibling temp dir so tests can - // explicitly distinguish repo-local state from legacy state. - process.env.CASE_DATA_DIR = join(tempDir, '.case-data-empty'); - }); - - afterEach(async () => { - process.env = { ...originalEnv }; - await rm(tempDir, { recursive: true, force: true }); - }); - describe('findTaskByIssue', () => { - it('returns matching task with correct entry phase', async () => { - const task = makeTaskJson(); - await writeRepoTask('cli-abc-fix-test', task); + // One repo shared across the matching cases. It holds three tasks that + // differ only by repo / issueType so we can assert the three-way match. + let repoPath: string; + let correctTaskId: string; + + beforeAll(async () => { + repoPath = makeTempRepo(); + + // Same issue number but different repo. + await createTdTask({ + repoPath, + request: { repo: 'other-repo', issue: '1523', issueType: 'github' }, + }); + // Same repo + issue but different issueType. + await createTdTask({ + repoPath, + request: { repo: 'cli', issue: '1523', issueType: 'linear' }, + }); + // The correct match: repo=cli, issueType=github, issue=1523. + const correct = await createTdTask({ + repoPath, + request: { repo: 'cli', issue: '1523', issueType: 'github' }, + }); + correctTaskId = correct.taskId; + }); - const result = await findTaskByIssue(tempDir, 'cli', 'github', '1523', repoDir); + it('returns matching task with correct entry phase', async () => { + const result = await findTaskByIssue(repoPath, 'cli', 'github', '1523', repoPath); expect(result).not.toBeNull(); - expect(result!.taskJson.id).toBe('cli-abc-fix-test'); + expect(result!.taskJson.id).toBe(correctTaskId); expect(result!.taskJson.issue).toBe('1523'); expect(result!.entryPhase).toBe('implement'); - expect(result!.taskJsonPath).toContain('cli-abc-fix-test.task.json'); - expect(result!.taskJsonPath).toContain(join('.case', 'tasks', 'active')); - expect(result!.taskMdPath).toContain('cli-abc-fix-test.md'); + expect(result!.tdId).toMatch(/^td-/); + expect(result!.taskJson.tdId).toBe(result!.tdId); }); it('returns null when no task matches', async () => { - const task = makeTaskJson(); - await writeRepoTask('cli-abc-fix-test', task); - - const result = await findTaskByIssue(tempDir, 'cli', 'github', '9999', repoDir); + const result = await findTaskByIssue(repoPath, 'cli', 'github', '9999', repoPath); expect(result).toBeNull(); }); it('matches by all three criteria: repo + issueType + issue', async () => { - // Same issue number but different repo - await writeRepoTask('other-abc', makeTaskJson({ id: 'other-abc', repo: 'other-repo' })); - // Same repo + issue but different issueType - await writeRepoTask('cli-linear', makeTaskJson({ id: 'cli-linear', issueType: 'linear' })); - // Correct match - await writeRepoTask('cli-correct', makeTaskJson({ id: 'cli-correct' })); - - const result = await findTaskByIssue(tempDir, 'cli', 'github', '1523', repoDir); + const result = await findTaskByIssue(repoPath, 'cli', 'github', '1523', repoPath); expect(result).not.toBeNull(); - expect(result!.taskJson.id).toBe('cli-correct'); + expect(result!.taskJson.id).toBe(correctTaskId); + expect(result!.taskJson.repo).toBe('cli'); + expect(result!.taskJson.issueType).toBe('github'); }); - it('returns correct entry phase for implementing task with completed implementer', async () => { - const task = makeTaskJson({ - status: 'implementing', - agents: { - implementer: { started: '2026-03-14T00:00:00Z', completed: '2026-03-14T00:01:00Z', status: 'completed' }, + it('returns null when the repo has no tasks at all', async () => { + const emptyRepo = makeTempRepo(); + const result = await findTaskByIssue(emptyRepo, 'cli', 'github', '1523', emptyRepo); + expect(result).toBeNull(); + }); + }); + + describe('findTaskByIssue entry-phase derivation', () => { + it('returns verify phase for implementing task with completed implementer', async () => { + const { repoPath } = await createTdTask({ + request: { repo: 'cli', issue: '4242', issueType: 'github' }, + overrides: { + status: 'implementing', + agents: { + implementer: { started: '2026-03-14T00:00:00Z', completed: '2026-03-14T00:01:00Z', status: 'completed' }, + }, }, }); - await writeRepoTask('cli-abc-fix-test', task); - const result = await findTaskByIssue(tempDir, 'cli', 'github', '1523', repoDir); + const result = await findTaskByIssue(repoPath, 'cli', 'github', '4242', repoPath); expect(result).not.toBeNull(); expect(result!.entryPhase).toBe('verify'); }); it('returns complete phase for pr-opened task', async () => { - const task = makeTaskJson({ status: 'pr-opened', prUrl: 'https://github.com/org/repo/pull/42' }); - await writeRepoTask('cli-abc-fix-test', task); + const { repoPath } = await createTdTask({ + request: { repo: 'cli', issue: '4343', issueType: 'github' }, + overrides: { status: 'pr-opened', prUrl: 'https://github.com/org/repo/pull/42' }, + }); - const result = await findTaskByIssue(tempDir, 'cli', 'github', '1523', repoDir); + const result = await findTaskByIssue(repoPath, 'cli', 'github', '4343', repoPath); expect(result).not.toBeNull(); expect(result!.entryPhase).toBe('complete'); }); - - it('returns null when no active task directory exists', async () => { - await rm(join(repoDir, '.case/tasks'), { recursive: true, force: true }); - - const result = await findTaskByIssue(tempDir, 'cli', 'github', '1523', repoDir); - expect(result).toBeNull(); - }); - - it('skips unparseable JSON files', async () => { - await Bun.write(join(repoDir, '.case/tasks/active/bad.task.json'), 'not json{{{'); - await writeRepoTask('cli-good', makeTaskJson({ id: 'cli-good' })); - - const result = await findTaskByIssue(tempDir, 'cli', 'github', '1523', repoDir); - expect(result).not.toBeNull(); - expect(result!.taskJson.id).toBe('cli-good'); - }); - - it('falls back to legacy tasks/active when repo-local state is absent', async () => { - await rm(join(repoDir, '.case/tasks'), { recursive: true, force: true }); - await writeLegacyTask('cli-legacy', makeTaskJson({ id: 'cli-legacy' })); - - const result = await findTaskByIssue(tempDir, 'cli', 'github', '1523', repoDir); - - expect(result).not.toBeNull(); - expect(result!.taskJson.id).toBe('cli-legacy'); - expect(result!.taskJsonPath).toContain(join('tasks', 'active')); - }); }); describe('findTaskByMarker', () => { - it('returns task when marker points to valid task', async () => { - const task = makeTaskJson(); - await writeRepoTask('cli-abc-fix-test', task); - await Bun.write(join(repoDir, '.case', 'active'), 'cli-abc-fix-test\n'); + it('returns the focused task with correct entry phase', async () => { + // createTdTask focuses the task it creates (via td focus on create). + const { repoPath, taskId } = await createTdTask({ + request: { repo: 'cli', issue: '5151', issueType: 'github' }, + }); - const result = await findTaskByMarker(tempDir, repoDir); + const result = await findTaskByMarker(repoPath, repoPath); expect(result).not.toBeNull(); - expect(result!.taskJson.id).toBe('cli-abc-fix-test'); + expect(result!.taskJson.id).toBe(taskId); expect(result!.entryPhase).toBe('implement'); + expect(result!.tdId).toMatch(/^td-/); }); - it('returns null when no marker exists', async () => { - const result = await findTaskByMarker(tempDir, repoDir); - expect(result).toBeNull(); - }); - - it('cleans up active marker when task file is missing', async () => { - await Bun.write(join(repoDir, '.case', 'active'), 'nonexistent-task-id\n'); - await Bun.write(join(repoDir, '.case', 'learnings.md'), 'keep me\n'); - - const result = await findTaskByMarker(tempDir, repoDir); - - expect(result).toBeNull(); - const markerExists = await Bun.file(join(repoDir, '.case', 'active')).exists(); - expect(markerExists).toBe(false); - expect(await Bun.file(join(repoDir, '.case', 'learnings.md')).exists()).toBe(true); - }); - - it('cleans up stale marker (>24h)', async () => { - const task = makeTaskJson(); - await writeRepoTask('cli-abc-fix-test', task); - const markerPath = join(repoDir, '.case', 'active'); - await Bun.write(markerPath, 'cli-abc-fix-test\n'); - - // Set mtime to 25 hours ago - const pastTime = new Date(Date.now() - 25 * 60 * 60 * 1000); - await utimes(markerPath, pastTime, pastTime); - - const result = await findTaskByMarker(tempDir, repoDir); - - expect(result).toBeNull(); - const markerExists = await Bun.file(markerPath).exists(); - expect(markerExists).toBe(false); - }); - - it('cleans up marker with empty content', async () => { - await Bun.write(join(repoDir, '.case', 'active'), ' \n'); - - const result = await findTaskByMarker(tempDir, repoDir); - + it('returns null when nothing is focused', async () => { + const emptyRepo = makeTempRepo(); + const result = await findTaskByMarker(emptyRepo, emptyRepo); expect(result).toBeNull(); - const markerExists = await Bun.file(join(repoDir, '.case', 'active')).exists(); - expect(markerExists).toBe(false); }); it('returns correct entry phase for verifying task', async () => { - const task = makeTaskJson({ - status: 'verifying', - agents: { - verifier: { started: '2026-03-14T00:00:00Z', completed: null, status: 'running' }, + const { repoPath } = await createTdTask({ + request: { repo: 'cli', issue: '5252', issueType: 'github' }, + overrides: { + status: 'verifying', + agents: { + verifier: { started: '2026-03-14T00:00:00Z', completed: null, status: 'running' }, + }, }, }); - await writeRepoTask('cli-abc-fix-test', task); - await Bun.write(join(repoDir, '.case', 'active'), 'cli-abc-fix-test\n'); - const result = await findTaskByMarker(tempDir, repoDir); + const result = await findTaskByMarker(repoPath, repoPath); expect(result).not.toBeNull(); expect(result!.entryPhase).toBe('verify'); diff --git a/src/__tests__/verify-phase.spec.ts b/src/__tests__/verify-phase.spec.ts index 9a30f7e..14efca6 100644 --- a/src/__tests__/verify-phase.spec.ts +++ b/src/__tests__/verify-phase.spec.ts @@ -17,8 +17,8 @@ async function setupTempFiles() { function makeConfig(overrides: Partial = {}): PipelineConfig { return { mode: 'attended', - taskJsonPath: join(tempCaseRoot, '.case/tasks/active/cli-1.task.json'), - taskMdPath: join(tempCaseRoot, '.case/tasks/active/cli-1.md'), + taskId: 'cli-1', + tdId: 'td-test1', repoPath: tempCaseRoot, repoName: 'cli', packageRoot: tempCaseRoot, diff --git a/src/__tests__/working-memory.spec.ts b/src/__tests__/working-memory.spec.ts index df61887..2123a15 100644 --- a/src/__tests__/working-memory.spec.ts +++ b/src/__tests__/working-memory.spec.ts @@ -285,6 +285,15 @@ describe('taskSlugFromTaskJsonPath', () => { describe('ca update-memory CLI (handler)', () => { let tempCwd: string; let originalCwd: string; + let slug: string; + + // The slug is the focused td task's id. Create a real td-backed task in the + // temp repo and run the command from that cwd so `td current` resolves it. + async function focusTask(): Promise { + const { createTdTask } = await import('./helpers/td-task.js'); + const fixture = await createTdTask({ repoPath: tempCwd }); + slug = fixture.taskId; + } beforeEach(() => { originalCwd = process.cwd(); @@ -305,12 +314,12 @@ describe('ca update-memory CLI (handler)', () => { }); it('creates working-memory.json on first call', async () => { - writeFileSync(join(tempCwd, '.case/active'), 'foo-1'); + await focusTask(); const { handler } = await import('../commands/update-memory.js'); const code = await handler(['--state', 'Starting', '--approach', 'TDD', '--file', 'src/x.ts']); expect(code).toBe(0); - const path = join(tempCwd, '.case/foo-1/working-memory.json'); + const path = join(tempCwd, '.case', slug, 'working-memory.json'); expect(existsSync(path)).toBe(true); const memory = JSON.parse(readFileSync(path, 'utf-8')); expect(memory.currentState).toBe('Starting'); @@ -320,33 +329,33 @@ describe('ca update-memory CLI (handler)', () => { }); it('appends to arrays on subsequent calls', async () => { - writeFileSync(join(tempCwd, '.case/active'), 'foo-1'); + await focusTask(); const { handler } = await import('../commands/update-memory.js'); await handler(['--state', 'A', '--file', 'src/a.ts']); await handler(['--file', 'src/b.ts', '--tried', 'first', '--tried-outcome', 'failed']); - const memory = JSON.parse(readFileSync(join(tempCwd, '.case/foo-1/working-memory.json'), 'utf-8')); + const memory = JSON.parse(readFileSync(join(tempCwd, '.case', slug, 'working-memory.json'), 'utf-8')); expect(memory.filesChanged).toEqual(['src/a.ts', 'src/b.ts']); expect(memory.approachesTried).toEqual([{ approach: 'first', outcome: 'failed' }]); }); it('rejects invalid --error-status with exit 1', async () => { - writeFileSync(join(tempCwd, '.case/active'), 'foo-1'); + await focusTask(); const { handler } = await import('../commands/update-memory.js'); const code = await handler(['--error', 'X', '--error-status', 'bogus']); expect(code).toBe(1); }); it('rejects --error-status without preceding --error', async () => { - writeFileSync(join(tempCwd, '.case/active'), 'foo-1'); + await focusTask(); const { handler } = await import('../commands/update-memory.js'); const code = await handler(['--error-status', 'fixed']); expect(code).toBe(1); }); it('rejects empty argv', async () => { - writeFileSync(join(tempCwd, '.case/active'), 'foo-1'); + await focusTask(); const { handler } = await import('../commands/update-memory.js'); const code = await handler([]); expect(code).toBe(1); @@ -359,7 +368,7 @@ describe('ca update-memory CLI (handler)', () => { }); it('attaches --error-file and --error-status to most recent --error', async () => { - writeFileSync(join(tempCwd, '.case/active'), 'foo-1'); + await focusTask(); const { handler } = await import('../commands/update-memory.js'); const code = await handler([ '--error', @@ -374,7 +383,7 @@ describe('ca update-memory CLI (handler)', () => { 'workaround', ]); expect(code).toBe(0); - const memory = JSON.parse(readFileSync(join(tempCwd, '.case/foo-1/working-memory.json'), 'utf-8')); + const memory = JSON.parse(readFileSync(join(tempCwd, '.case', slug, 'working-memory.json'), 'utf-8')); expect(memory.errorsSeen).toEqual([ { error: 'TypeError', file: 'src/x.ts', resolution: 'fixed' }, { error: 'RangeError', resolution: 'workaround' }, diff --git a/src/agent/orchestrator-session.ts b/src/agent/orchestrator-session.ts index dee6639..645aa3f 100644 --- a/src/agent/orchestrator-session.ts +++ b/src/agent/orchestrator-session.ts @@ -148,7 +148,7 @@ async function gatherContext(options: OrchestratorSessionOptions): Promise { const config = await buildPipelineConfig({ - taskJsonPath: params.taskJsonPath, + tdId: params.tdId, + repoPath: params.repoPath, mode: (params.mode as 'attended' | 'unattended') ?? 'attended', dryRun: params.dryRun ?? false, }); @@ -26,7 +28,7 @@ export function createPipelineTool(_caseRoot: string) { config.onAgentHeartbeat = (elapsedMs) => { onUpdate?.({ content: [{ type: 'text', text: `... still running (${Math.floor(elapsedMs / 1000)}s)\n` }], - details: { taskJsonPath: params.taskJsonPath }, + details: { tdId: params.tdId }, }); }; @@ -34,7 +36,7 @@ export function createPipelineTool(_caseRoot: string) { return { content: [{ type: 'text', text: 'Pipeline completed successfully.' }], - details: { taskJsonPath: params.taskJsonPath }, + details: { tdId: params.tdId }, }; }, }); diff --git a/src/agent/tools/task-tool.ts b/src/agent/tools/task-tool.ts index 0cb8266..c158795 100644 --- a/src/agent/tools/task-tool.ts +++ b/src/agent/tools/task-tool.ts @@ -53,7 +53,7 @@ export function createTaskTool(caseRoot: string) { content: [ { type: 'text', - text: `Task created: ${result.taskId}\n JSON: ${result.taskJsonPath}\n Spec: ${result.taskMdPath}`, + text: `Task created: ${result.taskId}\n td issue: ${result.tdId}`, }, ], details: result, diff --git a/src/commands/analyze-failure.ts b/src/commands/analyze-failure.ts index 65db8fd..a90a622 100644 --- a/src/commands/analyze-failure.ts +++ b/src/commands/analyze-failure.ts @@ -1,5 +1,4 @@ import { existsSync, readFileSync } from 'node:fs'; -import { basename, dirname, resolve } from 'node:path'; import type { FailureAnalysis } from '../types.js'; const FAILURE_PATTERNS: Array<{ keywords: string[]; failureClass: string; suggestedFocus: string }> = [ @@ -90,15 +89,11 @@ async function getFilesInvolved(cwd?: string): Promise { } export async function analyzeFailure( - taskFile: string, + workingMemoryFile: string, failedAgent: string, errorSummary: string, ): Promise { - const taskStem = basename(taskFile, '.task.json'); - const taskDir = dirname(taskFile); - const workingFile = resolve(taskDir, `${taskStem}.working.md`); - - const whatWasTried = parseWorkingMemory(workingFile); + const whatWasTried = parseWorkingMemory(workingMemoryFile); const filesInvolved = await getFilesInvolved(); const { failureClass, suggestedFocus: baseFocus } = classifyError(errorSummary); diff --git a/src/commands/bootstrap.ts b/src/commands/bootstrap.ts index de9b8bd..314b2ae 100644 --- a/src/commands/bootstrap.ts +++ b/src/commands/bootstrap.ts @@ -115,10 +115,13 @@ function ensureCaseIgnored(repoPath: string): void { if (!existsSync(gitignore)) return; const current = readFileSync(gitignore, 'utf-8'); - if (current.split(/\r?\n/).some((line) => line.trim() === '.case/')) return; + const lines = current.split(/\r?\n/).map((line) => line.trim()); + // `.case/` = evidence markers & runtime state; `.todos/` = the td task database. + const missing = ['.case/', '.todos/'].filter((entry) => !lines.includes(entry)); + if (missing.length === 0) return; const prefix = current.endsWith('\n') ? '' : '\n'; - writeFileSync(gitignore, `${current}${prefix}\n# Case harness markers\n.case/\n`); + writeFileSync(gitignore, `${current}${prefix}\n# Case harness markers\n${missing.join('\n')}\n`); } function lastLines(text: string, count: number): string[] { diff --git a/src/commands/create.ts b/src/commands/create.ts index 9be8df5..bc3ea67 100644 --- a/src/commands/create.ts +++ b/src/commands/create.ts @@ -49,9 +49,8 @@ export async function handler(argv: string[]): Promise { try { const result = await createTask(caseRoot, request); process.stdout.write(`Task created: ${result.taskId}\n`); - process.stdout.write(` JSON: ${result.taskJsonPath}\n`); - process.stdout.write(` Spec: ${result.taskMdPath}\n`); - process.stdout.write(`\nRun with:\n bun src/index.ts --task ${result.taskJsonPath}\n`); + process.stdout.write(` td issue: ${result.tdId}\n`); + process.stdout.write(`\nRun with:\n bun src/index.ts --task ${result.tdId} --repo-path \n`); return 0; } catch (err) { const msg = err instanceof Error ? err.message : String(err); diff --git a/src/commands/mark-manual-tested.ts b/src/commands/mark-manual-tested.ts index 9967e75..e0d22ff 100644 --- a/src/commands/mark-manual-tested.ts +++ b/src/commands/mark-manual-tested.ts @@ -1,15 +1,11 @@ -import { existsSync, mkdirSync, readFileSync, writeFileSync, readdirSync, statSync } from 'node:fs'; +import { existsSync, mkdirSync, writeFileSync, readdirSync, statSync } from 'node:fs'; import { createHash } from 'node:crypto'; import { resolve, join } from 'node:path'; -import { updateTaskJson } from './mark-tested.js'; +import { updateTaskState } from './mark-tested.js'; +import { resolveFocusedTask } from '../state/td-client.js'; export const description = 'Mark a repo as manually tested (writes .case//manual-tested)'; -function resolveTaskSlug(): string | null { - if (!existsSync('.case/active')) return null; - return readFileSync('.case/active', 'utf-8').trim() || null; -} - function countRecentPngs(dir: string, maxAgeMinutes: number): number { if (!existsSync(dir)) return 0; const cutoff = Date.now() - maxAgeMinutes * 60 * 1000; @@ -30,11 +26,12 @@ function countRecentPngs(dir: string, maxAgeMinutes: number): number { } export async function handler(argv: string[]): Promise { - const slug = resolveTaskSlug(); - if (!slug) { - process.stderr.write('ERROR: No active task — .case/active is missing or empty. Run the orchestrator first.\n'); + const focused = await resolveFocusedTask(process.cwd()); + if (!focused) { + process.stderr.write('ERROR: No active task — no focused td task. Run the orchestrator first.\n'); return 1; } + const slug = focused.task.id; const markerDir = `.case/${slug}`; mkdirSync(markerDir, { recursive: true }); @@ -79,6 +76,6 @@ export async function handler(argv: string[]): Promise { writeFileSync(resolve(markerDir, 'manual-tested'), `timestamp: ${timestamp}\nevidence: ${evidenceDetails}\n`); process.stderr.write(`.case/${slug}/manual-tested created (${evidenceDetails})\n`); - updateTaskJson(slug, 'manualTested'); + await updateTaskState(process.cwd(), focused.tdId, 'manualTested'); return 0; } diff --git a/src/commands/mark-reviewed.ts b/src/commands/mark-reviewed.ts index b919c46..3829c02 100644 --- a/src/commands/mark-reviewed.ts +++ b/src/commands/mark-reviewed.ts @@ -1,14 +1,10 @@ -import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { mkdirSync, writeFileSync } from 'node:fs'; import { resolve } from 'node:path'; -import { resolveDataDir, resolvePackageRoot, resolveRepoTaskJson } from '../paths.js'; +import { resolveFocusedTask } from '../state/td-client.js'; +import { TaskStore } from '../state/task-store.js'; export const description = 'Mark a repo as reviewed (writes .case//reviewed)'; -function resolveTaskSlug(): string | null { - if (!existsSync('.case/active')) return null; - return readFileSync('.case/active', 'utf-8').trim() || null; -} - export async function handler(argv: string[]): Promise { let critical = 0; let warnings = 0; @@ -24,11 +20,12 @@ export async function handler(argv: string[]): Promise { return 1; } - const slug = resolveTaskSlug(); - if (!slug) { - process.stderr.write('ERROR: No active task — .case/active is missing or empty. Run the orchestrator first.\n'); + const focused = await resolveFocusedTask(process.cwd()); + if (!focused) { + process.stderr.write('ERROR: No active task — no focused td task. Run the orchestrator first.\n'); return 1; } + const slug = focused.task.id; const markerDir = `.case/${slug}`; mkdirSync(markerDir, { recursive: true }); @@ -39,27 +36,16 @@ export async function handler(argv: string[]): Promise { ); process.stderr.write(`.case/${slug}/reviewed created (${warnings} warnings, ${info} info)\n`); - let dataRoot: string; try { - dataRoot = resolveDataDir(); + const agents = { ...focused.task.agents }; + agents.reviewer = { + ...(agents.reviewer ?? { started: null }), + status: 'completed', + completed: new Date().toISOString(), + }; + await new TaskStore(process.cwd(), focused.tdId).writeFromProjection({ agents }); } catch { - dataRoot = resolvePackageRoot(); - } - let taskJson = resolveRepoTaskJson(process.cwd(), slug); - if (!existsSync(taskJson)) taskJson = resolve(dataRoot, 'tasks', 'active', `${slug}.task.json`); - if (!existsSync(taskJson)) taskJson = resolve(resolvePackageRoot(), 'tasks', 'active', `${slug}.task.json`); - if (existsSync(taskJson)) { - try { - const data = JSON.parse(readFileSync(taskJson, 'utf-8')); - const agents = data.agents ?? {}; - if (!agents.reviewer) agents.reviewer = {}; - agents.reviewer.status = 'completed'; - agents.reviewer.completed = new Date().toISOString(); - data.agents = agents; - writeFileSync(taskJson, JSON.stringify(data, null, 2) + '\n'); - } catch { - /* best-effort */ - } + /* best-effort */ } return 0; } diff --git a/src/commands/mark-tested.ts b/src/commands/mark-tested.ts index a48deba..1993b8e 100644 --- a/src/commands/mark-tested.ts +++ b/src/commands/mark-tested.ts @@ -1,15 +1,11 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; import { resolve } from 'node:path'; import { createHash } from 'node:crypto'; -import { resolveDataDir, resolvePackageRoot, resolveRepoTaskJson } from '../paths.js'; +import { resolveFocusedTask } from '../state/td-client.js'; +import { TaskStore } from '../state/task-store.js'; export const description = 'Mark a repo as auto-tested (writes .case//tested with SHA-256 of test output)'; -function resolveTaskSlug(): string | null { - if (!existsSync('.case/active')) return null; - return readFileSync('.case/active', 'utf-8').trim() || null; -} - function parseVitestJson(raw: string): { passed: number; failed: number; @@ -54,11 +50,12 @@ export async function handler(argv: string[]): Promise { return 1; } - const slug = resolveTaskSlug(); - if (!slug) { - process.stderr.write('ERROR: No active task — .case/active is missing or empty. Run the orchestrator first.\n'); + const focused = await resolveFocusedTask(process.cwd()); + if (!focused) { + process.stderr.write('ERROR: No active task — no focused td task. Run the orchestrator first.\n'); return 1; } + const slug = focused.task.id; const markerDir = `.case/${slug}`; mkdirSync(markerDir, { recursive: true }); @@ -88,30 +85,14 @@ export async function handler(argv: string[]): Promise { writeFileSync(resolve(markerDir, 'tested'), markerContent); process.stderr.write(`.case/${slug}/tested created (hash: ${hash.slice(0, 12)}...)\n`); - updateTaskJson(slug, 'tested'); + await updateTaskState(process.cwd(), focused.tdId, 'tested'); return 0; } -export function updateTaskJson(slug: string, field: 'tested' | 'manualTested'): void { - let dataRoot: string; - try { - dataRoot = resolveDataDir(); - } catch { - dataRoot = resolvePackageRoot(); - } - - let taskJson = resolveRepoTaskJson(process.cwd(), slug); - if (!existsSync(taskJson)) taskJson = resolve(dataRoot, 'tasks', 'active', `${slug}.task.json`); - if (!existsSync(taskJson)) taskJson = resolve(resolvePackageRoot(), 'tasks', 'active', `${slug}.task.json`); - if (!existsSync(taskJson)) { - process.stderr.write(`WARNING: task JSON not found for ${slug}\n`); - return; - } - +/** Flip a boolean evidence flag in the focused task's td-backed state. Best-effort. */ +export async function updateTaskState(repoPath: string, tdId: string, field: 'tested' | 'manualTested'): Promise { try { - const data = JSON.parse(readFileSync(taskJson, 'utf-8')); - data[field] = true; - writeFileSync(taskJson, JSON.stringify(data, null, 2) + '\n'); + await new TaskStore(repoPath, tdId).writeFromProjection({ [field]: true }); } catch { /* best-effort */ } diff --git a/src/commands/run.ts b/src/commands/run.ts index 75ec91c..187db41 100644 --- a/src/commands/run.ts +++ b/src/commands/run.ts @@ -1,4 +1,5 @@ import { parseArgs } from 'node:util'; +import { resolve } from 'node:path'; import { buildPipelineConfig } from '../config.js'; import { runPipeline } from '../pipeline.js'; import { runCliOrchestrator } from '../entry/cli-orchestrator.js'; @@ -28,6 +29,7 @@ export async function handler(argv: string[]): Promise { args: argv, options: { task: { type: 'string', short: 't' }, + 'repo-path': { type: 'string' }, mode: { type: 'string', short: 'm' }, agent: { type: 'boolean' }, model: { type: 'string' }, @@ -108,7 +110,8 @@ function printRunHelp(): void { Run the agent pipeline for a GitHub or Linear issue. Options: - --task, -t Run an existing task JSON file directly + --task, -t Run an existing td task directly (by issue handle) + --repo-path Repo whose td store holds --task (default: cwd) --agent Start an interactive steering session --model Override model for all agents in this run --mode, -m "attended" (default) or "unattended" @@ -121,11 +124,9 @@ Options: } async function runTaskFlow(values: Record): Promise { - const taskPath = values.task as string; - if (!(await Bun.file(taskPath).exists())) { - process.stderr.write(`Error: task file not found: ${taskPath}\n`); - return 1; - } + // --task takes a td issue handle; --repo-path locates its `.todos/` store (default cwd). + const tdId = values.task as string; + const repoPath = resolve((values['repo-path'] as string | undefined) ?? '.'); const mode = values.mode as PipelineMode | undefined; if (mode && mode !== 'attended' && mode !== 'unattended') { @@ -135,7 +136,8 @@ async function runTaskFlow(values: Record): Promise { try { const config = await buildPipelineConfig({ - taskJsonPath: taskPath, + tdId, + repoPath, mode, dryRun: values['dry-run'] as boolean | undefined, }); diff --git a/src/commands/session.ts b/src/commands/session.ts index c922d65..ef1cab8 100644 --- a/src/commands/session.ts +++ b/src/commands/session.ts @@ -1,7 +1,8 @@ -import { existsSync, readFileSync } from 'node:fs'; +import { existsSync } from 'node:fs'; import { resolve } from 'node:path'; +import { decodeState, tdCurrent, tdShow } from '../state/td-client.js'; -export const description = 'Print session context (git branch, task file, repo info)'; +export const description = 'Print session context (git branch, current task, repo info)'; async function run(cmd: string[], cwd?: string): Promise { try { @@ -25,25 +26,30 @@ async function runOk(cmd: string[], cwd?: string): Promise { export async function handler(argv: string[]): Promise { if (argv[0] === '--help' || argv[0] === '-h') { - process.stderr.write('Usage: ca session [--task ]\n'); + process.stderr.write('Usage: ca session [--task ]\n'); return 0; } - let repoPath = argv[0] || '.'; - let taskJsonPath = ''; + const repoPath = argv[0] || '.'; + let tdId = ''; for (let i = 1; i < argv.length; i++) { if (argv[i] === '--task' && argv[i + 1]) { - taskJsonPath = argv[i + 1]!; + tdId = argv[i + 1]!; i++; } } - const ctx = await gatherSessionContext(resolve(repoPath), taskJsonPath || undefined); + const ctx = await gatherSessionContext(resolve(repoPath), tdId || undefined); process.stdout.write(JSON.stringify(ctx, null, 2) + '\n'); return 0; } -/** Programmatic API — returns session context as a structured object. */ -export async function gatherSessionContext(repoPath: string, taskJsonPath?: string): Promise> { +/** + * Programmatic API — returns session context as a structured object. + * + * `tdId` selects an explicit task; when omitted the repo's focused task (via + * `td current`) is used. Evidence markers still live under `.case//`. + */ +export async function gatherSessionContext(repoPath: string, tdId?: string): Promise> { repoPath = resolve(repoPath); const branch = (await run(['git', 'branch', '--show-current'], repoPath)) || 'detached'; const onMain = branch === 'main' || branch === 'master'; @@ -54,41 +60,40 @@ export async function gatherSessionContext(repoPath: string, taskJsonPath?: stri const recentCommits = recentRaw.split('\n').filter(Boolean); const caseDir = resolve(repoPath, '.case'); - const activeFile = resolve(caseDir, 'active'); + + // Resolve the active task from td: an explicit handle, else the focused one. + const activeTdId = tdId ?? (await tdCurrent(repoPath)); let caseActive = false; let caseTested = false; let caseManualTested = false; let caseReviewed = false; - if (existsSync(activeFile)) { - caseActive = true; - const taskSlug = readFileSync(activeFile, 'utf-8').trim(); - if (taskSlug) { - const slugDir = resolve(caseDir, taskSlug); + let task: Record | null = null; + + if (activeTdId) { + const issue = await tdShow(repoPath, activeTdId); + const state = issue ? decodeState(issue.description) : null; + if (state) { + caseActive = true; + const slugDir = resolve(caseDir, state.id); caseTested = existsSync(resolve(slugDir, 'tested')); caseManualTested = existsSync(resolve(slugDir, 'manual-tested')); caseReviewed = existsSync(resolve(slugDir, 'reviewed')); + task = { + id: state.id ?? null, + td_id: activeTdId, + status: state.status ?? null, + tested: state.tested ?? false, + manual_tested: state.manualTested ?? false, + agents: state.agents ?? {}, + }; + } else if (tdId) { + task = { error: `could not read td task: ${activeTdId}` }; } } const nodeVersion = (await run(['node', '--version'])) || 'not found'; const pnpmVersion = (await run(['pnpm', '--version'])) || 'not found'; - let task: Record | null = null; - if (taskJsonPath) { - try { - const raw = JSON.parse(readFileSync(taskJsonPath, 'utf-8')); - task = { - id: raw.id ?? null, - status: raw.status ?? null, - tested: raw.tested ?? false, - manual_tested: raw.manualTested ?? false, - agents: raw.agents ?? {}, - }; - } catch (e: unknown) { - task = { error: `could not read task file: ${(e as Error).message}` }; - } - } - return { repo: { path: repoPath, diff --git a/src/commands/status.ts b/src/commands/status.ts index 5df8138..9106d11 100644 --- a/src/commands/status.ts +++ b/src/commands/status.ts @@ -1,5 +1,6 @@ -import { readFileSync, writeFileSync, existsSync } from 'node:fs'; -import type { TaskStatus } from '../types.js'; +import type { TaskJson, TaskStatus } from '../types.js'; +import { decodeState, tdShow } from '../state/td-client.js'; +import { TaskStore } from '../state/task-store.js'; export const description = 'Read or update the current task status'; @@ -31,12 +32,16 @@ const KNOWN_FIELDS = new Set([ 'mode', ]); -function readTask(path: string): Record { - return JSON.parse(readFileSync(path, 'utf-8')); +async function readTask(repoPath: string, tdId: string): Promise> { + const issue = await tdShow(repoPath, tdId); + if (!issue) throw new Error(`td issue not found: ${tdId}`); + const state = decodeState(issue.description); + if (!state) throw new Error(`td issue ${tdId} has no case-state payload`); + return state as unknown as Record; } -function writeTask(path: string, data: Record): void { - writeFileSync(path, JSON.stringify(data, null, 2) + '\n'); +async function writeTask(repoPath: string, tdId: string, data: Record): Promise { + await new TaskStore(repoPath, tdId).writeFromProjection(data as Partial); } function printValue(val: unknown): void { @@ -56,28 +61,29 @@ function coerce(value: string): unknown { } export async function handler(argv: string[]): Promise { - const taskFile = argv[0]; + const tdId = argv[0]; const field = argv[1]; const value = argv[2]; const extra = argv[3]; + const repoPath = process.cwd(); - if (!taskFile || !field) { + if (!tdId || !field) { process.stderr.write( - 'Usage: ca status [value] [--from-marker]\n\n' + + 'Usage: ca status [value] [--from-marker]\n\n' + 'Fields: status, id, repo, issue, issueType, branch, tested, manualTested, prUrl, prNumber, contractPath\n' + 'Special: agent [value]\n', ); return 1; } - if (!existsSync(taskFile)) { - process.stderr.write(`Error: task file not found: ${taskFile}\n`); + if (!(await tdShow(repoPath, tdId))) { + process.stderr.write(`Error: td issue not found: ${tdId}\n`); return 1; } // Read mode if (value === undefined && field !== 'agent') { - printValue(readTask(taskFile)[field]); + printValue((await readTask(repoPath, tdId))[field]); return 0; } @@ -87,10 +93,10 @@ export async function handler(argv: string[]): Promise { const agentField = extra; const agentValue = argv[4]; if (!agentName || !agentField) { - process.stderr.write('Usage: ca status agent [value]\n'); + process.stderr.write('Usage: ca status agent [value]\n'); return 1; } - const data = readTask(taskFile); + const data = await readTask(repoPath, tdId); const agents = (data.agents ?? {}) as Record>; if (agentValue === undefined) { printValue((agents[agentName] ?? {})[agentField]); @@ -113,7 +119,7 @@ export async function handler(argv: string[]): Promise { return 1; } data.agents = agents; - writeTask(taskFile, data); + await writeTask(repoPath, tdId, data); process.stdout.write(`OK: agents.${agentName}.${agentField} = ${agentValue}\n`); return 0; } @@ -128,7 +134,7 @@ export async function handler(argv: string[]): Promise { // Status transition validation if (field === 'status') { - const data = readTask(taskFile); + const data = await readTask(repoPath, tdId); const current = (data.status as string) ?? 'active'; const allowed = TRANSITIONS[current] ?? []; if (!allowed.includes(value as TaskStatus)) { @@ -138,13 +144,13 @@ export async function handler(argv: string[]): Promise { return 1; } data.status = value; - writeTask(taskFile, data); + await writeTask(repoPath, tdId, data); process.stdout.write(`OK: status ${current} → ${value}\n`); return 0; } // Generic field write - const data = readTask(taskFile); + const data = await readTask(repoPath, tdId); if (READONLY_FIELDS.has(field)) { process.stderr.write(`Error: field "${field}" is read-only\n`); return 1; @@ -154,7 +160,7 @@ export async function handler(argv: string[]): Promise { return 1; } data[field] = coerce(value); - writeTask(taskFile, data); + await writeTask(repoPath, tdId, data); process.stdout.write(`OK: ${field} = ${value}\n`); return 0; } diff --git a/src/commands/update-memory.ts b/src/commands/update-memory.ts index 4a860f7..3da5edd 100644 --- a/src/commands/update-memory.ts +++ b/src/commands/update-memory.ts @@ -19,10 +19,10 @@ * --blocker Append to `blockers` (repeatable) * * Reads existing memory (or starts empty), merges, validates, writes back. - * Always paired with an active task — resolves the slug from `.case/active`. + * Always paired with an active task — resolves the slug from the focused td task. */ -import { existsSync, readFileSync } from 'node:fs'; import { resolve } from 'node:path'; +import { resolveFocusedTask } from '../state/td-client.js'; import { emptyWorkingMemory, mergeWorkingMemory, @@ -39,11 +39,6 @@ import type { WorkingMemoryApproach, WorkingMemoryError, WorkingMemoryUpdate } f export const description = 'Update structured working memory at .case//working-memory.json'; -function resolveTaskSlug(): string | null { - if (!existsSync('.case/active')) return null; - return readFileSync('.case/active', 'utf-8').trim() || null; -} - interface ParsedFlags { update: WorkingMemoryUpdate; /** Recorded for `--help` / debugging — never affects the merge. */ @@ -81,11 +76,12 @@ export async function handler(argv: string[]): Promise { throw err; } - const slug = resolveTaskSlug(); - if (!slug) { - process.stderr.write('ERROR: No active task — .case/active is missing or empty. Run the orchestrator first.\n'); + const focused = await resolveFocusedTask(process.cwd()); + if (!focused) { + process.stderr.write('ERROR: No active task — no focused td task. Run the orchestrator first.\n'); return 1; } + const slug = focused.task.id; const taskDir = resolve('.case', slug); const existing = readWorkingMemory(taskDir) ?? emptyWorkingMemory(); @@ -211,7 +207,7 @@ function usage(): string { ' --tried-reason Reason for the previous --tried', ' --blocker Append blocker (repeatable)', '', - 'Writes to .case//working-memory.json. Requires .case/active.', + 'Writes to .case//working-memory.json. Requires a focused td task.', '', ].join('\n'); } diff --git a/src/config.ts b/src/config.ts index 9240284..1c4267d 100644 --- a/src/config.ts +++ b/src/config.ts @@ -96,15 +96,20 @@ export function resolveRepoPath(basePath: string, repoPath: string): string { return resolve(basePath, repoPath); } -/** Build a complete PipelineConfig from a task file path and options. */ +/** Build a complete PipelineConfig from a td task handle and options. */ export async function buildPipelineConfig(opts: { - taskJsonPath: string; + /** td issue handle backing the task. */ + tdId: string; + /** Target repo checkout holding the task's `.todos/` store. */ + repoPath: string; mode?: PipelineMode; dryRun?: boolean; }): Promise { - const taskJsonPath = resolve(opts.taskJsonPath); - const raw = await Bun.file(taskJsonPath).text(); - const task = JSON.parse(raw) as { repo: string; mode?: PipelineMode }; + const { tdShow, decodeState } = await import('./state/td-client.js'); + const issue = await tdShow(opts.repoPath, opts.tdId); + if (!issue) throw new Error(`td issue not found: ${opts.tdId}`); + const task = decodeState(issue.description); + if (!task) throw new Error(`td issue ${opts.tdId} has no case-state payload`); const packageRoot = resolvePackageRoot(); @@ -114,21 +119,18 @@ export async function buildPipelineConfig(opts: { throw new Error(`Repo "${task.repo}" not found in projects.json`); } - const repoPath = resolveRepoPath(manifest.repoBasePath, project.path); + const repoPath = resolve(opts.repoPath); // Mutable task runtime state is repo-local under `/.case/`. // The field is still named dataDir for API compatibility with the existing pipeline code. const dataDir = repoPath; - // Task .md path is same stem as .task.json but with .md extension - const taskMdPath = taskJsonPath.replace(/\.task\.json$/, '.md'); - - // Mode priority: CLI flag > task JSON field > default + // Mode priority: CLI flag > task field > default const mode = opts.mode ?? task.mode ?? 'attended'; return { mode, - taskJsonPath, - taskMdPath, + taskId: task.id, + tdId: opts.tdId, repoPath, repoName: task.repo, project, diff --git a/src/context/assembler.ts b/src/context/assembler.ts index 8648aaa..5a25bf3 100644 --- a/src/context/assembler.ts +++ b/src/context/assembler.ts @@ -112,8 +112,8 @@ function buildContextBlock( const lines: string[] = ['## Task Context', '']; // Common context for all roles - lines.push(`- **Task file**: \`${config.taskMdPath}\``); - lines.push(`- **Task JSON**: \`${config.taskJsonPath}\``); + lines.push(`- **Task**: ${config.taskId}`); + lines.push(`- **td issue**: ${config.tdId}`); lines.push(`- **Target repo**: \`${config.repoPath}\``); lines.push(`- **Repo name**: ${config.repoName}`); if (config.project) { diff --git a/src/context/prefetch.ts b/src/context/prefetch.ts index a9413db..39f36ce 100644 --- a/src/context/prefetch.ts +++ b/src/context/prefetch.ts @@ -22,15 +22,14 @@ export async function prefetchRepoContext(config: PipelineConfig, role: AgentNam const dataDirLearnings = safeDataDirLearningsPath(config.repoName); const legacyLearnings = `docs/learnings/${config.repoName}.md`; - const taskStem = config.taskJsonPath.replace(/\.task\.json$/, ''); - const workingMemoryPath = `${taskStem}.working.md`; + const workingMemoryPath = join(config.repoPath, '.case', config.taskId, 'working.md'); const needsLearnings = role === 'implementer'; const needsPrinciples = role === 'reviewer'; const needsWorkingMemory = role === 'implementer'; const promises: Promise[] = [ - gatherSessionContext(config.repoPath, config.taskJsonPath), + gatherSessionContext(config.repoPath, config.tdId), runCommand('git', ['log', '--oneline', '-10'], { cwd: config.repoPath }), ]; diff --git a/src/entry/cli-orchestrator.ts b/src/entry/cli-orchestrator.ts index 9b8e174..6594ae5 100644 --- a/src/entry/cli-orchestrator.ts +++ b/src/entry/cli-orchestrator.ts @@ -109,7 +109,7 @@ export async function runCliOrchestrator(options: CliOrchestratorOptions): Promi }; const taskResult = await createTask(caseRoot, request, { issueContext, branch: branchName, repoPath: detected.path }); - setupStep(notifier, 'Task', taskResult.taskId); + setupStep(notifier, 'Task', `${taskResult.taskId} (${taskResult.tdId})`); // --- Step 3: Run baseline --- const baseline = await runBootstrap(detected.name, caseRoot); @@ -128,7 +128,8 @@ export async function runCliOrchestrator(options: CliOrchestratorOptions): Promi // --- Step 4: Dispatch to pipeline --- const config = await buildPipelineConfig({ - taskJsonPath: taskResult.taskJsonPath, + tdId: taskResult.tdId, + repoPath: detected.path, mode, dryRun, }); @@ -149,7 +150,7 @@ async function resumeTask( setupStartedAt: number, renderer?: 'structured' | 'tui', ): Promise { - const { taskJson, taskJsonPath, entryPhase } = match; + const { taskJson, tdId, entryPhase } = match; // Guard: task already has a PR open if (taskJson.status === 'pr-opened' || taskJson.status === 'merged') { @@ -168,9 +169,10 @@ async function resumeTask( setupStep(notifier, 'Branch', taskJson.branch); } - // Build config from existing task JSON and dispatch + // Build config from the existing td task and dispatch const config = await buildPipelineConfig({ - taskJsonPath, + tdId, + repoPath, mode, dryRun, }); diff --git a/src/entry/task-factory.ts b/src/entry/task-factory.ts index dfdd38c..6e2ebe6 100644 --- a/src/entry/task-factory.ts +++ b/src/entry/task-factory.ts @@ -1,14 +1,12 @@ -import { mkdir } from 'node:fs/promises'; -import { basename, resolve } from 'node:path'; import type { IssueContext, TaskCreateRequest, TaskJson } from '../types.js'; import { loadProjectsManifest, resolveRepoPath } from '../config.js'; -import { resolveRepoActiveMarker, resolveRepoActiveTaskDir } from '../paths.js'; +import { buildLabels, encodeDescription, tdCreate, tdFocus } from '../state/td-client.js'; import { createLogger } from '../util/logger.js'; import { slugify } from '../util/slugify.js'; const log = createLogger(); -/** Generate a task ID from repo + timestamp + title slug. */ +/** Generate a canonical Case task ID from repo + timestamp + title slug. */ function generateTaskId(repo: string, title: string): string { const ts = Date.now().toString(36); const slug = slugify(title).slice(0, 30); @@ -17,8 +15,8 @@ function generateTaskId(repo: string, title: string): string { export interface TaskCreateResult { taskId: string; - taskJsonPath: string; - taskMdPath: string; + /** td issue handle backing the task. */ + tdId: string; } /** Optional enrichment passed by the CLI orchestrator. */ @@ -29,13 +27,12 @@ export interface TaskEnrichment { } /** - * Create a task.json + task.md pair in the target repo's .case/tasks/active/ - * from a TaskCreateRequest. - * Returns paths to the created files for pipeline dispatch. + * Create a task as a `td` issue in the target repo's `.todos/` store. * - * When `enrichment` is provided (from CLI orchestrator), the task gets: - * - `branch` field in JSON - * - Richer markdown with issue reference and labels + * The issue's description carries the human spec plus a hidden `case-state` + * comment holding the authoritative {@link TaskJson} (see td-client.ts). The + * new task is focused so re-entry (`ca` with no argument) resolves it via + * `td current`. Returns the canonical task id and the td handle for dispatch. */ export async function createTask( caseRoot: string, @@ -44,11 +41,6 @@ export async function createTask( ): Promise { const taskId = generateTaskId(request.repo, request.title); const repoPath = enrichment?.repoPath ?? (await resolveTargetRepoPath(caseRoot, request.repo)); - const activeDir = resolveRepoActiveTaskDir(repoPath); - await mkdir(activeDir, { recursive: true }); - - const taskJsonPath = resolve(activeDir, `${taskId}.task.json`); - const taskMdPath = resolve(activeDir, `${taskId}.md`); const taskJson: TaskJson = { id: taskId, @@ -70,23 +62,31 @@ export async function createTask( checkTarget: request.checkTarget ?? null, }; - const taskMd = buildTaskMarkdown(request, taskJson, enrichment?.issueContext); + const { spec, acceptance } = buildTaskSpec(request, taskJson, enrichment?.issueContext); + + const tdId = await tdCreate(repoPath, { + title: request.title, + description: encodeDescription(spec, taskJson), + acceptance, + labels: buildLabels(taskJson), + }); + taskJson.tdId = tdId; - await Bun.write(taskJsonPath, JSON.stringify(taskJson, null, 2) + '\n'); - await Bun.write(taskMdPath, taskMd); - await mkdir(resolve(repoPath, '.case'), { recursive: true }); - await Bun.write(resolveRepoActiveMarker(repoPath), `${taskId}\n`); + // Persist the td handle back into the embedded state, then focus the task. + const { tdUpdate } = await import('../state/td-client.js'); + await tdUpdate(repoPath, tdId, { description: encodeDescription(spec, taskJson) }); + await tdFocus(repoPath, tdId); log.info('task created', { taskId, + tdId, repo: request.repo, trigger: request.trigger.type, branch: enrichment?.branch, repoPath, - file: basename(taskJsonPath), }); - return { taskId, taskJsonPath, taskMdPath }; + return { taskId, tdId }; } async function resolveTargetRepoPath(caseRoot: string, repoName: string): Promise { @@ -96,8 +96,19 @@ async function resolveTargetRepoPath(caseRoot: string, repoName: string): Promis return resolveRepoPath(manifest.repoBasePath, project.path); } -/** Build task markdown. Enriched with issue context when available. */ -function buildTaskMarkdown(request: TaskCreateRequest, taskJson: TaskJson, issueContext?: IssueContext): string { +/** + * Build the human spec markdown (td description body) and the acceptance + * criteria text (td native `acceptance` field). The acceptance criteria are + * kept in both so agents reading the rendered spec and `td` tooling both see + * them. + */ +function buildTaskSpec( + request: TaskCreateRequest, + taskJson: TaskJson, + issueContext?: IssueContext, +): { spec: string; acceptance: string } { + const acceptance = '- [ ] Fix verified by tests\n- [ ] No regressions introduced'; + const lines: (string | false)[] = [ `# ${request.title}`, '', @@ -109,7 +120,6 @@ function buildTaskMarkdown(request: TaskCreateRequest, taskJson: TaskJson, issue '', ]; - // Issue reference section when enriched if (issueContext) { lines.push('## Issue Reference', '', `**Source:** ${issueContext.issueType} #${issueContext.issueNumber}`); if (issueContext.labels.length > 0) { @@ -118,17 +128,7 @@ function buildTaskMarkdown(request: TaskCreateRequest, taskJson: TaskJson, issue lines.push(''); } - lines.push( - '## Description', - '', - request.description, - '', - '## Acceptance Criteria', - '', - '- [ ] Fix verified by tests', - '- [ ] No regressions introduced', - '', - ); + lines.push('## Description', '', request.description, '', '## Acceptance Criteria', '', acceptance, ''); if (request.verificationScenarios) { lines.push('## Verification Scenarios', '', request.verificationScenarios, ''); @@ -143,8 +143,6 @@ function buildTaskMarkdown(request: TaskCreateRequest, taskJson: TaskJson, issue lines.push('## Evidence Expectations', '', request.evidenceExpectations, ''); } - // Progress Log always at the end - lines.push('## Progress Log', '', '', ''); - - return lines.filter((line) => line !== false).join('\n'); + const spec = lines.filter((line) => line !== false).join('\n'); + return { spec, acceptance }; } diff --git a/src/entry/task-scanner.ts b/src/entry/task-scanner.ts index 2181eae..b9b1c0c 100644 --- a/src/entry/task-scanner.ts +++ b/src/entry/task-scanner.ts @@ -1,23 +1,22 @@ -import { join, resolve } from 'node:path'; -import { readdir, stat } from 'node:fs/promises'; import { determineEntryPhase } from '../state/transitions.js'; -import { resolveRepoActiveMarker, resolveRepoActiveTaskDir, resolveRepoTaskJson, resolveTaskDir } from '../paths.js'; -import type { TaskJson, PipelinePhase } from '../types.js'; - -const STALE_MARKER_MS = 24 * 60 * 60 * 1000; // 24 hours +import { loadProjectsManifest, resolveRepoPath } from '../config.js'; +import { decodeState, tdCurrent, tdList, tdShow } from '../state/td-client.js'; +import type { PipelinePhase, TaskJson } from '../types.js'; export interface TaskMatch { taskJson: TaskJson; - taskJsonPath: string; - taskMdPath: string; + /** td issue handle backing the matched task. */ + tdId: string; entryPhase: PipelinePhase; } /** - * Scan active task JSON files for a task matching the given issue. - * Returns the match with its resolved entry phase, or null if not found. + * Find an active task for the given issue by querying the repo's `td` store. * - * Scans repo-local `.case/tasks/active` first, then legacy global/in-repo locations. + * Tasks are tagged with `repo:` and `issue:` labels at creation, so a + * label-filtered `td list` narrows the candidates; the embedded case-state then + * confirms the issue type. Returns the match with its resolved entry phase, or + * null when no live task tracks the issue. */ export async function findTaskByIssue( caseRoot: string, @@ -26,118 +25,45 @@ export async function findTaskByIssue( issueNumber: string, repoPath?: string, ): Promise { - for (const activeDir of activeDirCandidates(caseRoot, repoPath)) { - let entries: string[]; - try { - entries = await readdir(activeDir); - } catch { - continue; - } - - for (const file of entries.filter((f) => f.endsWith('.task.json'))) { - const taskJsonPath = resolve(activeDir, file); - try { - const raw = await Bun.file(taskJsonPath).text(); - const task = JSON.parse(raw) as TaskJson; - - if (task.repo === repoName && task.issueType === issueType && task.issue === issueNumber) { - const entryPhase = determineEntryPhase(task); - const taskMdPath = taskJsonPath.replace(/\.task\.json$/, '.md'); - return { taskJson: task, taskJsonPath, taskMdPath, entryPhase }; - } - } catch { - // Skip unparseable files - continue; - } + const resolvedRepoPath = repoPath ?? (await resolveTargetRepoPath(caseRoot, repoName)); + + const candidates = await tdList(resolvedRepoPath, [`repo:${repoName}`, `issue:${issueNumber}`]); + for (const issue of candidates) { + const task = decodeState(issue.description); + if (!task) continue; + if (task.repo === repoName && task.issueType === issueType && task.issue === issueNumber) { + return toMatch(task, issue.id); } } - return null; } -/** Candidate active-tasks dirs in resolution order. */ -function activeDirCandidates(caseRoot: string, repoPath?: string): string[] { - const list: string[] = []; - if (repoPath) { - list.push(resolveRepoActiveTaskDir(repoPath)); - } - try { - list.push(join(resolveTaskDir(), 'active')); - } catch { - // resolveDataDir() may throw if HOME/XDG/CASE_DATA_DIR unset - } - list.push(resolve(caseRoot, 'tasks/active')); - return list; -} - /** - * Scan for a task via the `.case/active` marker in the given repo directory. - * Reads the task ID from the marker file, then loads the task JSON directly. - * - * Handles stale markers (>24h) and missing task files by cleaning up. + * Resolve the repo's currently focused task (the `td` replacement for the old + * `.case/active` marker). Returns null when nothing is focused or the focused + * issue has no case-state payload. */ export async function findTaskByMarker(caseRoot: string, repoPath: string): Promise { - const markerPath = resolveRepoActiveMarker(repoPath); - - // Check marker exists and staleness in one stat call - let markerStat; - try { - markerStat = await stat(markerPath); - } catch { - return null; // Marker doesn't exist - } - - const ageMs = Date.now() - markerStat.mtimeMs; - if (ageMs > STALE_MARKER_MS) { - await cleanupActiveMarker(markerPath); - process.stdout.write('Stale .case/active marker (>24h) cleaned up.\n'); - return null; - } + void caseRoot; + const tdId = await tdCurrent(repoPath); + if (!tdId) return null; - // Read task ID from marker - const taskId = (await Bun.file(markerPath).text()).trim(); - if (!taskId) { - await cleanupActiveMarker(markerPath); - return null; - } + const issue = await tdShow(repoPath, tdId); + if (!issue) return null; - // Load the task JSON — try repo-local state first, then legacy dataDir/in-repo paths. - let taskJsonPath: string | null = null; - for (const candidate of [ - resolveRepoTaskJson(repoPath, taskId), - ...activeDirCandidates(caseRoot).map((activeDir) => resolve(activeDir, `${taskId}.task.json`)), - ]) { - if (await Bun.file(candidate).exists()) { - taskJsonPath = candidate; - break; - } - } + const task = decodeState(issue.description); + if (!task) return null; - if (!taskJsonPath) { - await cleanupActiveMarker(markerPath); - process.stdout.write('Stale marker cleaned. No active task.\n'); - return null; - } - - try { - const raw = await Bun.file(taskJsonPath).text(); - const task = JSON.parse(raw) as TaskJson; - const entryPhase = determineEntryPhase(task); - const taskMdPath = taskJsonPath.replace(/\.task\.json$/, '.md'); + return toMatch(task, issue.id); +} - return { taskJson: task, taskJsonPath, taskMdPath, entryPhase }; - } catch { - await cleanupActiveMarker(markerPath); - return null; - } +function toMatch(task: TaskJson, tdId: string): TaskMatch { + return { taskJson: { ...task, tdId }, tdId, entryPhase: determineEntryPhase(task) }; } -/** Remove only the active marker; repo-local learnings and task history are kept. */ -async function cleanupActiveMarker(markerPath: string): Promise { - try { - const { rm } = await import('node:fs/promises'); - await rm(markerPath, { force: true }); - } catch { - // Already removed or inaccessible - } +async function resolveTargetRepoPath(caseRoot: string, repoName: string): Promise { + const manifest = await loadProjectsManifest(caseRoot); + const project = manifest.repos.find((p) => p.name === repoName); + if (!project) throw new Error(`Repo "${repoName}" not found in projects.json`); + return resolveRepoPath(manifest.repoBasePath, project.path); } diff --git a/src/generated/package-assets.ts b/src/generated/package-assets.ts index 257dbed..a4ac16d 100644 --- a/src/generated/package-assets.ts +++ b/src/generated/package-assets.ts @@ -14,63 +14,36 @@ import asset10 from '../../ast-rules/self/no-macos-open.yml' with { type: 'text' import asset11 from '../../ast-rules/target/no-console-log.yml' with { type: 'text' }; import asset12 from '../../ast-rules/target/no-default-export.yml' with { type: 'text' }; import asset13 from '../../ast-rules/target/no-require.yml' with { type: 'text' }; -import asset14 from '../../docs/agent-versions/implementer-2026-05-17.md' with { type: 'text' }; -import asset15 from '../../docs/architecture/README.md' with { type: 'text' }; -import asset16 from '../../docs/architecture/authkit-framework.md' with { type: 'text' }; -import asset17 from '../../docs/architecture/authkit-session.md' with { type: 'text' }; -import asset18 from '../../docs/architecture/cli.md' with { type: 'text' }; -import asset19 from '../../docs/architecture/skills-plugin.md' with { type: 'text' }; -import asset20 from '../../docs/architecture/workos-node.md' with { type: 'text' }; -import asset21 from '../../docs/conventions/README.md' with { type: 'text' }; -import asset22 from '../../docs/conventions/claude-md-ordering.md' with { type: 'text' }; -import asset23 from '../../docs/conventions/code-style.md' with { type: 'text' }; -import asset24 from '../../docs/conventions/commits.md' with { type: 'text' }; -import asset25 from '../../docs/conventions/entropy-management.md' with { type: 'text' }; -import asset26 from '../../docs/conventions/pull-requests.md' with { type: 'text' }; -import asset27 from '../../docs/conventions/testing.md' with { type: 'text' }; -import asset28 from '../../docs/failure-matrix.md' with { type: 'text' }; -import asset29 from '../../docs/golden-principles.md' with { type: 'text' }; -import asset30 from '../../docs/ideation/harness-resilience/contract.md' with { type: 'text' }; -import asset31 from '../../docs/ideation/harness-resilience/spec-phase-1.md' with { type: 'text' }; -import asset32 from '../../docs/ideation/harness-resilience/spec-phase-2.md' with { type: 'text' }; -import asset33 from '../../docs/ideation/harness-resilience/spec-phase-3.md' with { type: 'text' }; -import asset34 from '../../docs/ideation/harness-resilience/spec-phase-4.md' with { type: 'text' }; -import asset35 from '../../docs/ideation/onboard-interview/contract.md' with { type: 'text' }; -import asset36 from '../../docs/ideation/onboard-interview/spec-phase-1.md' with { type: 'text' }; -import asset37 from '../../docs/ideation/onboard-interview/spec-phase-2.md' with { type: 'text' }; -import asset38 from '../../docs/ideation/onboard-interview/spec-phase-3.md' with { type: 'text' }; -import asset39 from '../../docs/ideation/pipeline-terminal-ux/contract.md' with { type: 'text' }; -import asset40 from '../../docs/ideation/pipeline-terminal-ux/spec-phase-1.md' with { type: 'text' }; -import asset41 from '../../docs/ideation/pipeline-terminal-ux/spec-phase-2.md' with { type: 'text' }; -import asset42 from '../../docs/ideation/pipeline-terminal-ux/spec-phase-3.md' with { type: 'text' }; -import asset43 from '../../docs/ideation/pipeline-terminal-ux/spec-phase-4.md' with { type: 'text' }; -import asset44 from '../../docs/learnings/README.md' with { type: 'text' }; -import asset45 from '../../docs/learnings/authkit-nextjs.md' with { type: 'text' }; -import asset46 from '../../docs/learnings/authkit-session.md' with { type: 'text' }; -import asset47 from '../../docs/learnings/authkit-tanstack-start.md' with { type: 'text' }; -import asset48 from '../../docs/learnings/cli.md' with { type: 'text' }; -import asset49 from '../../docs/learnings/skills.md' with { type: 'text' }; -import asset50 from '../../docs/learnings/workos-node.md' with { type: 'text' }; -import asset51 from '../../docs/philosophy.md' with { type: 'text' }; -import asset52 from '../../docs/playbooks/README.md' with { type: 'text' }; -import asset53 from '../../docs/playbooks/add-authkit-framework.md' with { type: 'text' }; -import asset54 from '../../docs/playbooks/add-cli-command.md' with { type: 'text' }; -import asset55 from '../../docs/playbooks/add-feature.md' with { type: 'text' }; -import asset56 from '../../docs/playbooks/cross-repo-update.md' with { type: 'text' }; -import asset57 from '../../docs/playbooks/fix-bug.md' with { type: 'text' }; -import asset58 from '../../docs/proposed-amendments/2026-03-14-clean-stale-markers-on-resume.md' with { type: 'text' }; -import asset59 from '../../docs/proposed-amendments/2026-03-14-mark-manual-tested-subdirectory-screenshots.md' with { type: 'text' }; -import asset60 from '../../docs/proposed-amendments/2026-03-14-transitions-detect-stale-running.md' with { type: 'text' }; -import asset61 from '../../docs/proposed-amendments/2026-03-16-add-feature-playbook-library-manual-test-note.md' with { type: 'text' }; -import asset62 from '../../docs/proposed-amendments/2026-03-16-pre-pr-hook-skip-manual-test-for-library-repos.md' with { type: 'text' }; -import asset63 from '../../docs/proposed-amendments/2026-03-18-closer-preflight-library-repo-exemption.md' with { type: 'text' }; -import asset64 from '../../docs/proposed-amendments/2026-03-18-missing-playbooks-directory.md' with { type: 'text' }; -import asset65 from '../../docs/proposed-amendments/2026-03-18-projects-json-workos-node-library-type.md' with { type: 'text' }; -import asset66 from '../../docs/proposed-amendments/2026-03-18-verifier-library-repo-skip-playwright.md' with { type: 'text' }; -import asset67 from '../../docs/proposed-amendments/2026-03-19-mark-tested-jest-summary-parsing.md' with { type: 'text' }; -import asset68 from '../../docs/proposed-amendments/2026-03-19-mark-tested-vitest-summary-parsing.md' with { type: 'text' }; -import asset69 from '../../docs/proposed-amendments/2026-03-29-escalate-mark-tested-false-positives.md' with { type: 'text' }; -import asset70 from '../../docs/proposed-amendments/README.md' with { type: 'text' }; +import asset14 from '../../docs/architecture/README.md' with { type: 'text' }; +import asset15 from '../../docs/architecture/authkit-framework.md' with { type: 'text' }; +import asset16 from '../../docs/architecture/authkit-session.md' with { type: 'text' }; +import asset17 from '../../docs/architecture/cli.md' with { type: 'text' }; +import asset18 from '../../docs/architecture/skills-plugin.md' with { type: 'text' }; +import asset19 from '../../docs/architecture/workos-node.md' with { type: 'text' }; +import asset20 from '../../docs/conventions/README.md' with { type: 'text' }; +import asset21 from '../../docs/conventions/claude-md-ordering.md' with { type: 'text' }; +import asset22 from '../../docs/conventions/code-style.md' with { type: 'text' }; +import asset23 from '../../docs/conventions/commits.md' with { type: 'text' }; +import asset24 from '../../docs/conventions/entropy-management.md' with { type: 'text' }; +import asset25 from '../../docs/conventions/pull-requests.md' with { type: 'text' }; +import asset26 from '../../docs/conventions/testing.md' with { type: 'text' }; +import asset27 from '../../docs/failure-matrix.md' with { type: 'text' }; +import asset28 from '../../docs/golden-principles.md' with { type: 'text' }; +import asset29 from '../../docs/learnings/README.md' with { type: 'text' }; +import asset30 from '../../docs/learnings/authkit-nextjs.md' with { type: 'text' }; +import asset31 from '../../docs/learnings/authkit-session.md' with { type: 'text' }; +import asset32 from '../../docs/learnings/authkit-tanstack-start.md' with { type: 'text' }; +import asset33 from '../../docs/learnings/cli.md' with { type: 'text' }; +import asset34 from '../../docs/learnings/skills.md' with { type: 'text' }; +import asset35 from '../../docs/learnings/workos-node.md' with { type: 'text' }; +import asset36 from '../../docs/philosophy.md' with { type: 'text' }; +import asset37 from '../../docs/playbooks/README.md' with { type: 'text' }; +import asset38 from '../../docs/playbooks/add-authkit-framework.md' with { type: 'text' }; +import asset39 from '../../docs/playbooks/add-cli-command.md' with { type: 'text' }; +import asset40 from '../../docs/playbooks/add-feature.md' with { type: 'text' }; +import asset41 from '../../docs/playbooks/cross-repo-update.md' with { type: 'text' }; +import asset42 from '../../docs/playbooks/fix-bug.md' with { type: 'text' }; +import asset43 from '../../docs/proposed-amendments/README.md' with { type: 'text' }; export const embeddedPackageAssets: Record = { 'agents/closer.md': asset0, @@ -87,61 +60,34 @@ export const embeddedPackageAssets: Record = { 'ast-rules/target/no-console-log.yml': asset11, 'ast-rules/target/no-default-export.yml': asset12, 'ast-rules/target/no-require.yml': asset13, - 'docs/agent-versions/implementer-2026-05-17.md': asset14, - 'docs/architecture/README.md': asset15, - 'docs/architecture/authkit-framework.md': asset16, - 'docs/architecture/authkit-session.md': asset17, - 'docs/architecture/cli.md': asset18, - 'docs/architecture/skills-plugin.md': asset19, - 'docs/architecture/workos-node.md': asset20, - 'docs/conventions/README.md': asset21, - 'docs/conventions/claude-md-ordering.md': asset22, - 'docs/conventions/code-style.md': asset23, - 'docs/conventions/commits.md': asset24, - 'docs/conventions/entropy-management.md': asset25, - 'docs/conventions/pull-requests.md': asset26, - 'docs/conventions/testing.md': asset27, - 'docs/failure-matrix.md': asset28, - 'docs/golden-principles.md': asset29, - 'docs/ideation/harness-resilience/contract.md': asset30, - 'docs/ideation/harness-resilience/spec-phase-1.md': asset31, - 'docs/ideation/harness-resilience/spec-phase-2.md': asset32, - 'docs/ideation/harness-resilience/spec-phase-3.md': asset33, - 'docs/ideation/harness-resilience/spec-phase-4.md': asset34, - 'docs/ideation/onboard-interview/contract.md': asset35, - 'docs/ideation/onboard-interview/spec-phase-1.md': asset36, - 'docs/ideation/onboard-interview/spec-phase-2.md': asset37, - 'docs/ideation/onboard-interview/spec-phase-3.md': asset38, - 'docs/ideation/pipeline-terminal-ux/contract.md': asset39, - 'docs/ideation/pipeline-terminal-ux/spec-phase-1.md': asset40, - 'docs/ideation/pipeline-terminal-ux/spec-phase-2.md': asset41, - 'docs/ideation/pipeline-terminal-ux/spec-phase-3.md': asset42, - 'docs/ideation/pipeline-terminal-ux/spec-phase-4.md': asset43, - 'docs/learnings/README.md': asset44, - 'docs/learnings/authkit-nextjs.md': asset45, - 'docs/learnings/authkit-session.md': asset46, - 'docs/learnings/authkit-tanstack-start.md': asset47, - 'docs/learnings/cli.md': asset48, - 'docs/learnings/skills.md': asset49, - 'docs/learnings/workos-node.md': asset50, - 'docs/philosophy.md': asset51, - 'docs/playbooks/README.md': asset52, - 'docs/playbooks/add-authkit-framework.md': asset53, - 'docs/playbooks/add-cli-command.md': asset54, - 'docs/playbooks/add-feature.md': asset55, - 'docs/playbooks/cross-repo-update.md': asset56, - 'docs/playbooks/fix-bug.md': asset57, - 'docs/proposed-amendments/2026-03-14-clean-stale-markers-on-resume.md': asset58, - 'docs/proposed-amendments/2026-03-14-mark-manual-tested-subdirectory-screenshots.md': asset59, - 'docs/proposed-amendments/2026-03-14-transitions-detect-stale-running.md': asset60, - 'docs/proposed-amendments/2026-03-16-add-feature-playbook-library-manual-test-note.md': asset61, - 'docs/proposed-amendments/2026-03-16-pre-pr-hook-skip-manual-test-for-library-repos.md': asset62, - 'docs/proposed-amendments/2026-03-18-closer-preflight-library-repo-exemption.md': asset63, - 'docs/proposed-amendments/2026-03-18-missing-playbooks-directory.md': asset64, - 'docs/proposed-amendments/2026-03-18-projects-json-workos-node-library-type.md': asset65, - 'docs/proposed-amendments/2026-03-18-verifier-library-repo-skip-playwright.md': asset66, - 'docs/proposed-amendments/2026-03-19-mark-tested-jest-summary-parsing.md': asset67, - 'docs/proposed-amendments/2026-03-19-mark-tested-vitest-summary-parsing.md': asset68, - 'docs/proposed-amendments/2026-03-29-escalate-mark-tested-false-positives.md': asset69, - 'docs/proposed-amendments/README.md': asset70, + 'docs/architecture/README.md': asset14, + 'docs/architecture/authkit-framework.md': asset15, + 'docs/architecture/authkit-session.md': asset16, + 'docs/architecture/cli.md': asset17, + 'docs/architecture/skills-plugin.md': asset18, + 'docs/architecture/workos-node.md': asset19, + 'docs/conventions/README.md': asset20, + 'docs/conventions/claude-md-ordering.md': asset21, + 'docs/conventions/code-style.md': asset22, + 'docs/conventions/commits.md': asset23, + 'docs/conventions/entropy-management.md': asset24, + 'docs/conventions/pull-requests.md': asset25, + 'docs/conventions/testing.md': asset26, + 'docs/failure-matrix.md': asset27, + 'docs/golden-principles.md': asset28, + 'docs/learnings/README.md': asset29, + 'docs/learnings/authkit-nextjs.md': asset30, + 'docs/learnings/authkit-session.md': asset31, + 'docs/learnings/authkit-tanstack-start.md': asset32, + 'docs/learnings/cli.md': asset33, + 'docs/learnings/skills.md': asset34, + 'docs/learnings/workos-node.md': asset35, + 'docs/philosophy.md': asset36, + 'docs/playbooks/README.md': asset37, + 'docs/playbooks/add-authkit-framework.md': asset38, + 'docs/playbooks/add-cli-command.md': asset39, + 'docs/playbooks/add-feature.md': asset40, + 'docs/playbooks/cross-repo-update.md': asset41, + 'docs/playbooks/fix-bug.md': asset42, + 'docs/proposed-amendments/README.md': asset43, }; diff --git a/src/phases/implement.ts b/src/phases/implement.ts index 07f9246..bccda80 100644 --- a/src/phases/implement.ts +++ b/src/phases/implement.ts @@ -15,7 +15,7 @@ import { assemblePrompt } from '../context/assembler.js'; import { prefetchRepoContext } from '../context/prefetch.js'; import { analyzeFailure } from '../commands/analyze-failure.js'; import { readWorkingMemory } from '../memory/working-memory.js'; -import { formatForImplementer, taskSlugFromTaskJsonPath } from '../memory/format.js'; +import { formatForImplementer } from '../memory/format.js'; import { synthesizeForImplementer } from '../scout/findings.js'; import { createLogger } from '../util/logger.js'; @@ -118,7 +118,8 @@ async function attemptRetry( ): Promise { let analysis: FailureAnalysis; try { - analysis = await analyzeFailure(config.taskJsonPath, 'implementer', originalResult.error ?? 'unknown error'); + const workingMemoryFile = resolve(config.repoPath, '.case', config.taskId, 'working.md'); + analysis = await analyzeFailure(workingMemoryFile, 'implementer', originalResult.error ?? 'unknown error'); } catch (err: unknown) { log.error('failure analysis failed', { error: (err as Error).message }); return null; @@ -180,8 +181,7 @@ async function attemptRetry( * still covers the no-memory case until agents adopt `ca update-memory`. */ function prependWorkingMemory(basePrompt: string, config: PipelineConfig): string { - const slug = taskSlugFromTaskJsonPath(config.taskJsonPath); - const taskDir = resolve(config.repoPath, '.case', slug); + const taskDir = resolve(config.repoPath, '.case', config.taskId); const memory = readWorkingMemory(taskDir); if (!memory) return basePrompt; return formatForImplementer(memory) + '\n' + basePrompt; diff --git a/src/phases/retrospective.ts b/src/phases/retrospective.ts index 8c539af..269951e 100644 --- a/src/phases/retrospective.ts +++ b/src/phases/retrospective.ts @@ -80,8 +80,8 @@ export async function runRetrospectivePhase( '', '## Task Context', '', - `- **Task file**: \`${config.taskMdPath}\``, - `- **Task JSON**: \`${config.taskJsonPath}\``, + `- **Task**: ${config.taskId}`, + `- **td issue**: ${config.tdId}`, `- **Target repo**: \`${config.repoPath}\``, `- **Repo name**: ${config.repoName}`, '', diff --git a/src/phases/scout.ts b/src/phases/scout.ts index 2f055a4..a42e4bd 100644 --- a/src/phases/scout.ts +++ b/src/phases/scout.ts @@ -192,8 +192,8 @@ async function readScoutTemplate(packageRoot: string): Promise { function buildScoutContextBlock(config: PipelineConfig, task: TaskJson): string { const lines: string[] = ['## Task Context', '']; - lines.push(`- **Task file**: \`${config.taskMdPath}\``); - lines.push(`- **Task JSON**: \`${config.taskJsonPath}\``); + lines.push(`- **Task**: ${config.taskId}`); + lines.push(`- **td issue**: ${config.tdId}`); lines.push(`- **Target repo**: \`${config.repoPath}\``); lines.push(`- **Repo name**: ${config.repoName}`); if (config.project) { diff --git a/src/phases/verify.ts b/src/phases/verify.ts index 3feac5f..fd80094 100644 --- a/src/phases/verify.ts +++ b/src/phases/verify.ts @@ -6,7 +6,7 @@ import { assemblePrompt } from '../context/assembler.js'; import { prefetchRepoContext } from '../context/prefetch.js'; import { buildRevisionRequest } from './revision.js'; import { readWorkingMemory } from '../memory/working-memory.js'; -import { formatForVerifier, taskSlugFromTaskJsonPath } from '../memory/format.js'; +import { formatForVerifier } from '../memory/format.js'; import { createLogger } from '../util/logger.js'; const log = createLogger(); @@ -116,8 +116,7 @@ function classifyVerifierFailure(fails: Array<{ category: string; detail: string * start returns the base prompt unchanged. */ function prependWorkingMemory(basePrompt: string, config: PipelineConfig): string { - const slug = taskSlugFromTaskJsonPath(config.taskJsonPath); - const taskDir = resolve(config.repoPath, '.case', slug); + const taskDir = resolve(config.repoPath, '.case', config.taskId); const memory = readWorkingMemory(taskDir); if (!memory) return basePrompt; return formatForVerifier(memory) + '\n' + basePrompt; diff --git a/src/pipeline.ts b/src/pipeline.ts index 54073dd..05b49f8 100644 --- a/src/pipeline.ts +++ b/src/pipeline.ts @@ -29,8 +29,8 @@ import type { PipelineGraph } from './dag/types.js'; const log = createLogger(); export async function runPipeline(config: PipelineConfig): Promise { - // Task JSON lives in the target repo's ignored .case directory. - const store = new TaskStore(config.taskJsonPath, config.packageRoot); + // Task state is backed by the repo's `td` store (see td-client.ts). + const store = new TaskStore(config.repoPath, config.tdId); // Renderer selection: TUI wins when explicitly requested (even over a // pre-built notifier from cli-orchestrator's setup phase). Otherwise an // explicit notifier takes priority, falling back to structured log. diff --git a/src/state/task-store.ts b/src/state/task-store.ts index c4199cf..e5173d2 100644 --- a/src/state/task-store.ts +++ b/src/state/task-store.ts @@ -1,6 +1,13 @@ -import { writeFileSync } from 'node:fs'; -import { resolve } from 'node:path'; -import type { TaskJson } from '../types.js'; +import type { RevisionRequest, TaskJson } from '../types.js'; +import { + buildLabels, + caseToTdStatus, + decodeState, + encodeDescription, + extractSpec, + tdShow, + tdUpdate, +} from './td-client.js'; export class TaskStateError extends Error { constructor(message: string) { @@ -10,19 +17,31 @@ export class TaskStateError extends Error { } /** - * Read/write task.json — all writes are now pure TypeScript. - * Transition validation and evidence flag guards are enforced inline. + * Read/write a single task's state, backed by a `td` issue (see td-client.ts). + * + * The authoritative {@link TaskJson} rides inside the td issue's description as + * a hidden `` comment; the human spec precedes it and + * is preserved verbatim across writes. Every mutation rewrites that comment and + * mirrors the coarse status onto td's native `status` field for visibility. */ export class TaskStore { - private readonly taskJsonPath: string; + private readonly repoPath: string; + private readonly tdId: string; - constructor(taskJsonPath: string, _packageRoot?: string) { - this.taskJsonPath = resolve(taskJsonPath); + /** @param repoPath target repo whose `.todos/` db holds the issue. @param tdId td issue handle. */ + constructor(repoPath: string, tdId: string) { + this.repoPath = repoPath; + this.tdId = tdId; } async read(): Promise { - const raw = await Bun.file(this.taskJsonPath).text(); - return JSON.parse(raw) as TaskJson; + const issue = await tdShow(this.repoPath, this.tdId); + if (!issue) throw new TaskStateError(`td issue not found: ${this.tdId}`); + const state = decodeState(issue.description); + if (!state) throw new TaskStateError(`td issue ${this.tdId} has no case-state payload`); + // td's native fields are authoritative for the spec/acceptance the agents + // edited; the embedded state owns everything else. + return { ...state, tdId: issue.id }; } async setField(field: string, value: string): Promise { @@ -37,23 +56,30 @@ export class TaskStore { if (Number.isInteger(n) && String(n) === value) coerced = n; } (task as unknown as Record)[field] = coerced; - this.writeSync(task); + await this.write(task); } async writeFromProjection(projected: Partial): Promise { const task = await this.read(); Object.assign(task, projected); - this.writeSync(task); + await this.write(task); } - async setPendingRevision(revision: import('../types.js').RevisionRequest | null): Promise { + async setPendingRevision(revision: RevisionRequest | null): Promise { const task = await this.read(); if (revision) task.pendingRevision = revision; else delete task.pendingRevision; - this.writeSync(task); + await this.write(task); } - private writeSync(task: TaskJson): void { - writeFileSync(this.taskJsonPath, JSON.stringify(task, null, 2) + '\n'); + /** Persist the full task state back into the td issue (state comment + native mirror). */ + private async write(task: TaskJson): Promise { + const issue = await tdShow(this.repoPath, this.tdId); + const spec = issue ? extractSpec(issue.description) : ''; + await tdUpdate(this.repoPath, this.tdId, { + description: encodeDescription(spec, task), + status: caseToTdStatus(task.status), + labels: buildLabels(task), + }); } } diff --git a/src/state/td-client.ts b/src/state/td-client.ts new file mode 100644 index 0000000..1aefd3f --- /dev/null +++ b/src/state/td-client.ts @@ -0,0 +1,263 @@ +/** + * Thin wrapper around the `td` CLI (marcus/td) — the task store for Case. + * + * Case used to persist each task as a `.case/tasks/active/.task.json` + * (machine state) plus a `.md` (human spec). Both are now replaced by a + * single `td` issue per task, stored in the target repo's `.todos/` SQLite db: + * + * - td `title` ← task title + * - td `acceptance` ← acceptance criteria text + * - td `status` ← best-effort mirror of the Case status (human/td-CLI + * visibility only — see {@link caseToTdStatus}) + * - td `labels` ← `caseid:`, `repo:`, `issuetype:`, + * and `issue:` when the task tracks an issue + * - td `description` ← the human-readable spec markdown, followed by a + * hidden `` comment holding + * the authoritative {@link TaskJson}. + * + * The hidden comment is what makes a round-trip lossless: Case's status enum + * (`active`/`implementing`/.../`merged`), the per-agent phase map, and the + * pending revision are all finer-grained than anything td models natively, so + * the full `TaskJson` rides along as JSON. td's native fields are a projection + * for humans and `td` tooling; the comment is the source of truth. + * + * Execution state (the JSONL event log, plan.json, metrics) is unaffected — it + * still lives under `/.case//` and was never "task management". + */ +import type { TaskJson } from '../types.js'; + +export class TdError extends Error { + constructor(message: string) { + super(message); + this.name = 'TdError'; + } +} + +const CASE_STATE_OPEN = ''; + +// --- description codec --------------------------------------------------- + +/** + * Compose a td `description` from the human spec and the authoritative task + * state. The state is embedded as a trailing HTML comment so it is invisible + * when the spec is rendered (`td show -m`) but survives a JSON round-trip. + */ +export function encodeDescription(spec: string, task: TaskJson): string { + const body = spec.trimEnd(); + const state = `${CASE_STATE_OPEN}\n${JSON.stringify(task)}\n${CASE_STATE_CLOSE}`; + return body.length > 0 ? `${body}\n\n${state}\n` : `${state}\n`; +} + +/** Extract the embedded {@link TaskJson} from a td description. */ +export function decodeState(description: string): TaskJson | null { + const start = description.indexOf(CASE_STATE_OPEN); + if (start === -1) return null; + const end = description.indexOf(CASE_STATE_CLOSE, start + CASE_STATE_OPEN.length); + if (end === -1) return null; + const json = description.slice(start + CASE_STATE_OPEN.length, end).trim(); + try { + return JSON.parse(json) as TaskJson; + } catch { + return null; + } +} + +/** Strip the embedded state comment, returning just the human spec markdown. */ +export function extractSpec(description: string): string { + const start = description.indexOf(CASE_STATE_OPEN); + if (start === -1) return description.trimEnd(); + return description.slice(0, start).trimEnd(); +} + +// --- status / label mapping ---------------------------------------------- + +/** Map a Case status onto the nearest native td lifecycle status. */ +export function caseToTdStatus(status: TaskJson['status']): string { + switch (status) { + case 'active': + return 'open'; + case 'pr-opened': + return 'in_review'; + case 'merged': + return 'closed'; + default: + // implementing / verifying / reviewing / evaluating / closing + return 'in_progress'; + } +} + +/** Build the canonical label set Case stamps on every td issue. */ +export function buildLabels(task: Pick): string[] { + const labels = [`caseid:${task.id}`, `repo:${task.repo}`]; + if (task.issueType) labels.push(`issuetype:${task.issueType}`); + if (task.issue) labels.push(`issue:${task.issue}`); + return labels; +} + +// --- raw td issue shape (subset we read) --------------------------------- + +export interface TdIssue { + id: string; + title: string; + description: string; + acceptance: string; + status: string; + labels: string[]; +} + +// --- CLI invocation ------------------------------------------------------ + +/** + * Invoke `td` directly via Bun.spawn rather than through the shared + * `runCommand` util. `td` is the task store under test, so it must reach the + * real binary even in unit tests (where `runCommand` is mocked to block process + * execution). `-w` resolves the repo's `.todos` database; `TD_NO_UPDATE_CHECK` + * suppresses the "update available" banner that would corrupt parsed output. + */ +async function td(repoPath: string, args: string[]): Promise<{ stdout: string; stderr: string; exitCode: number }> { + try { + const proc = Bun.spawn(['td', '-w', repoPath, ...args], { + cwd: repoPath, + stdout: 'pipe', + stderr: 'pipe', + env: { ...process.env, TD_NO_UPDATE_CHECK: '1' }, + }); + const [stdout, stderr] = await Promise.all([new Response(proc.stdout).text(), new Response(proc.stderr).text()]); + const exitCode = await proc.exited; + return { stdout, stderr, exitCode }; + } catch (err) { + return { stdout: '', stderr: (err as Error).message ?? String(err), exitCode: 1 }; + } +} + +/** Ensure a td database exists for the repo. Idempotent. */ +export async function ensureTd(repoPath: string): Promise { + const probe = await td(repoPath, ['list', '--json', '-n', '1']); + if (probe.exitCode === 0) return; + if (/database not found/i.test(probe.stderr) || /run 'td init'/i.test(probe.stderr)) { + const init = await td(repoPath, ['init']); + if (init.exitCode !== 0) throw new TdError(`td init failed: ${init.stderr.trim()}`); + return; + } + throw new TdError(`td unavailable in ${repoPath}: ${probe.stderr.trim()}`); +} + +export interface TdCreateInput { + title: string; + description: string; + acceptance: string; + labels: string[]; + type?: string; +} + +/** Create a td issue and return its td handle (e.g. `td-a1b2c3`). */ +export async function tdCreate(repoPath: string, input: TdCreateInput): Promise { + await ensureTd(repoPath); + const args = ['create', input.title, '--description', input.description, '--type', input.type ?? 'task']; + if (input.acceptance) args.push('--acceptance', input.acceptance); + if (input.labels.length > 0) args.push('--labels', input.labels.join(',')); + + const res = await td(repoPath, args); + if (res.exitCode !== 0) throw new TdError(`td create failed: ${res.stderr.trim() || res.stdout.trim()}`); + const match = res.stdout.match(/td-[0-9a-z]+/); + if (!match) throw new TdError(`td create produced no issue id: ${res.stdout.trim()}`); + return match[0]; +} + +function parseIssue(raw: unknown): TdIssue | null { + if (typeof raw !== 'object' || raw === null) return null; + const o = raw as Record; + if (typeof o.id !== 'string') return null; + return { + id: o.id, + title: typeof o.title === 'string' ? o.title : '', + description: typeof o.description === 'string' ? o.description : '', + acceptance: typeof o.acceptance === 'string' ? o.acceptance : '', + status: typeof o.status === 'string' ? o.status : '', + labels: Array.isArray(o.labels) ? (o.labels.filter((l) => typeof l === 'string') as string[]) : [], + }; +} + +/** Fetch a single td issue by its td handle, or null if not found. */ +export async function tdShow(repoPath: string, tdId: string): Promise { + const res = await td(repoPath, ['show', tdId, '--json']); + if (res.exitCode !== 0) return null; + try { + const data = JSON.parse(res.stdout); + return parseIssue(data); + } catch { + return null; + } +} + +/** List td issues, optionally filtered by labels. Includes closed/deferred. */ +export async function tdList(repoPath: string, labels?: string[]): Promise { + const args = ['list', '--json', '-a', '-n', '500']; + for (const label of labels ?? []) args.push('--labels', label); + const res = await td(repoPath, args); + if (res.exitCode !== 0) return []; + try { + const data = JSON.parse(res.stdout); + if (!Array.isArray(data)) return []; + return data.map(parseIssue).filter((i): i is TdIssue => i !== null); + } catch { + return []; + } +} + +export interface TdUpdateInput { + description?: string; + acceptance?: string; + status?: string; + labels?: string[]; + comment?: string; + title?: string; +} + +/** Update fields on a td issue. `labels` replaces the full label set. */ +export async function tdUpdate(repoPath: string, tdId: string, fields: TdUpdateInput): Promise { + const args = ['update', tdId]; + if (fields.title !== undefined) args.push('--title', fields.title); + if (fields.description !== undefined) args.push('--description', fields.description); + if (fields.acceptance !== undefined) args.push('--acceptance', fields.acceptance); + if (fields.status !== undefined) args.push('--status', fields.status); + if (fields.labels !== undefined) args.push('--labels', fields.labels.join(',')); + if (fields.comment !== undefined) args.push('--comment', fields.comment); + if (args.length === 2) return; // nothing to update + const res = await td(repoPath, args); + if (res.exitCode !== 0) throw new TdError(`td update failed: ${res.stderr.trim() || res.stdout.trim()}`); +} + +/** Set the focused/current task for the repo (replaces the old .case/active marker). */ +export async function tdFocus(repoPath: string, tdId: string): Promise { + const res = await td(repoPath, ['focus', tdId]); + if (res.exitCode !== 0) throw new TdError(`td focus failed: ${res.stderr.trim()}`); +} + +/** Return the td handle of the currently focused task, or null. */ +export async function tdCurrent(repoPath: string): Promise { + const res = await td(repoPath, ['current', '--json']); + if (res.exitCode !== 0) return null; + try { + const data = JSON.parse(res.stdout) as { focused?: { issue?: { id?: string } } }; + return data.focused?.issue?.id ?? null; + } catch { + return null; + } +} + +/** + * Resolve the repo's focused task to its td handle and decoded {@link TaskJson}. + * Returns null when nothing is focused or the focused issue lacks case-state. + * This is the `td` replacement for reading the old `.case/active` marker. + */ +export async function resolveFocusedTask(repoPath: string): Promise<{ tdId: string; task: TaskJson } | null> { + const tdId = await tdCurrent(repoPath); + if (!tdId) return null; + const issue = await tdShow(repoPath, tdId); + if (!issue) return null; + const task = decodeState(issue.description); + if (!task) return null; + return { tdId, task: { ...task, tdId } }; +} diff --git a/src/types.ts b/src/types.ts index ef166e9..8a47941 100644 --- a/src/types.ts +++ b/src/types.ts @@ -19,6 +19,8 @@ export interface AgentPhase { export interface TaskJson { id: string; + /** td issue handle (e.g. `td-a1b2c3`) — the address for `td` CLI mutations. */ + tdId?: string; status: TaskStatus; created: string; repo: string; @@ -135,8 +137,10 @@ export const PHASE_ORDER: PipelinePhase[] = ['scout', 'implement', 'verify', 're export interface PipelineConfig { mode: PipelineMode; - taskJsonPath: string; - taskMdPath: string; + /** Canonical Case task id (`--`) — names `.case//` runtime state. */ + taskId: string; + /** td issue handle backing this task in the repo's `.todos/` store. */ + tdId: string; repoPath: string; repoName: string; /** Project metadata from projects.json, when the config was built from the manifest. */ diff --git a/tasks/README.md b/tasks/README.md index 8cd669a..4f6aa33 100644 --- a/tasks/README.md +++ b/tasks/README.md @@ -1,72 +1,71 @@ -# Task File Format +# Task Model -Tasks are markdown files that define work for agents. New runtime task files live in the target repo's ignored `.case/tasks/active/`. +Tasks are **`td` issues** (marcus/td) stored in each target repo's `.todos/` SQLite +database. There are no `.task.json` / `.md` task files — a task is one `td` issue, +addressed by its handle (e.g. `td-a1b2c3`). Case keeps its own canonical task id +(`{repo}-{ts}-{slug}`) alongside the td handle. -## Naming Convention +The markdown templates under `tasks/templates/` are still useful: they scaffold the +**spec** that becomes a td issue's description when you run `ca create`. -- **Single-repo**: `{repo}-{n}-{slug}.md` - - `cli-1-add-widgets-command.md` - - `authkit-nextjs-2-fix-session-refresh.md` -- **Cross-repo**: `x-{n}-{slug}.md` - - `x-1-update-readme-badges.md` - - `x-3-add-changelog-entry.md` +## How a task is stored -Numbers are sequential per prefix: `cli-1`, `cli-2`, `authkit-nextjs-1`, `x-1`, etc. +A single `td` issue holds everything: -## Required Sections +| td field | Holds | +| ------------- | --------------------------------------------------------------------------------------- | +| `title` | Task title | +| `description` | The human spec (Objective, Acceptance Criteria, Evidence Expectations, …) followed by a hidden `` comment carrying the authoritative `TaskJson` | +| `acceptance` | Acceptance criteria (also kept in the spec for agents) | +| `status` | Best-effort mirror of the Case status (`open`/`in_progress`/`in_review`/`closed`) | +| `labels` | `caseid:`, `repo:`, `issuetype:`, `issue:` | -| Section | Purpose | -| --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ | -| Mission Summary | Blockquote at the very top (before `# Title`) with Mission, Repo, and Done-when — survives context compaction | -| `# Title` | Brief description (becomes the task file name slug) | -| `## Objective` | What needs to happen and why | -| `## Target Repos` | Which repos this task touches (paths from projects.json) | -| `## Playbook` | Reference to the relevant playbook in docs/playbooks/ (if one exists) | -| `## Acceptance Criteria` | Checkboxes defining "done" — agent cannot mark done until these pass | -| `## Checklist` | Step-by-step progress tracker — agent checks items off as it works | -| `## Verification Scenarios` | (Optional) Concrete scenarios the verifier will test — generated by orchestrator during task creation | -| `## Non-Goals` | (Optional) What is explicitly NOT in scope — prevents implementer scope creep | -| `## Edge Cases` | (Optional) Edge cases the implementer should consider | -| `## Evidence Expectations` | Required. What proof of completion looks like (screenshots, test output, etc.) — orchestrator generates from the repo's `evidenceStrategy` | +The `case-state` comment is the source of truth. It carries the fields that td does +not model natively: `id`, `tdId`, `status`, `created`, `repo`, `issue`, `issueType`, +`branch`, `profile`, `agents`, `tested`, `manualTested`, `prUrl`, `prNumber`, +`pendingRevision`, `checkCommand`, `checkBaseline`, `checkTarget`. Read/write it via +`ca status [value]` (the `TaskStore` rewrites the comment and mirrors +the coarse status onto td's native `status`). -Optional: `## Context` for background info, issue links, API specs, etc. +Profile values: `tiny` (skip verify — docs, config, typos) and `standard` (all phases, +default). Issue types: `github`, `linear`, `freeform`. -## Lifecycle - -1. Orchestrator creates task file (`.md` + `.task.json`) in the target repo's `.case/tasks/active/` -2. Implementer writes the fix/feature, runs tests, commits -3. Verifier tests the specific scenario with fresh context -4. Reviewer checks the diff against golden principles and conventions -5. Closer agent opens a PR in the target repo (requires `.case//reviewed` with critical: 0) -6. Post-PR hook updates `.task.json` status to `pr-opened` -7. After PR merge, status updated to `merged` (manual or automation) - -Legacy in-repo harness tasks without a `.task.json` companion still use the old file-move behavior (`active/` → `done/`). - -## JSON Companion File - -Every new task has a `.task.json` companion alongside the `.md` file. Same filename stem: +## Spec sections -``` -.case/tasks/active/authkit-nextjs-1-issue-53.md # human-readable -.case/tasks/active/authkit-nextjs-1-issue-53.task.json # machine-touched -``` - -The JSON file stores structured metadata that agents and CLI commands update programmatically. Schema: `tasks/task.schema.json`. +The spec (td description body) is generated by the orchestrator from the templates and +the issue context: -Fields: `id`, `status`, `created`, `repo`, `issue`, `issueType`, `branch`, `profile`, `agents`, `tested`, `manualTested`, `prUrl`, `prNumber`, `contractPath`. +| Section | Purpose | +| --------------------------- | ---------------------------------------------------------------------------------------- | +| `## Objective` | What needs to happen and why | +| `## Acceptance Criteria` | Checkboxes defining "done" | +| `## Verification Scenarios` | (Optional) Concrete scenarios the verifier will test | +| `## Non-Goals` | (Optional) What is explicitly NOT in scope | +| `## Edge Cases` | (Optional) Edge cases the implementer should consider | +| `## Evidence Expectations` | Required. What proof of completion looks like — derived from the repo's `evidenceStrategy`| -Profile values: `tiny` (skip verify — docs, config, typos) and `standard` (all phases — default). +## Lifecycle -Issue types: `github`, `linear`, `freeform`. +1. Orchestrator creates the td issue (`ca create` / `createTask` → `td create`) and + **focuses** it (`td focus`) so re-entry resolves it via `td current`. +2. Implementer writes the fix/feature, runs tests, commits. Progress is captured with + `ca update-memory` (structured working memory) and `td log`. +3. Verifier tests the scenario with fresh context. +4. Reviewer checks the diff against golden principles and conventions. +5. Closer opens a PR (requires `.case//reviewed` with `critical: 0`), then + sets status `pr-opened` and records `prUrl`/`prNumber`. +6. After PR merge, status becomes `merged`. -Read/write via: `ca status [value]` +## Finding the active task -**Evidence flags** (`tested`, `manualTested`) can only be set by marker commands (`ca mark-tested`, `ca mark-manual-tested`) — not by agents directly. +The focused td task replaces the old `.case/active` marker. `ca session`, `ca mark-*`, +and `ca update-memory` resolve it via `td current`. To read the slug: `ca status id`. -### Evidence Markers +## Evidence markers -Evidence markers live under `.case//` in the target repo. The `.case/active` file contains the task slug. Add `.case/` to `.gitignore` (bootstrap does this automatically). +Evidence markers still live under `.case//` in the target repo (execution +state, not task definition). Add `.case/` and `.todos/` to `.gitignore` (bootstrap does +this automatically). | Marker | Created by | Purpose | | --------------------------------- | ----------------------- | ------------------------------------------------ | @@ -74,26 +73,12 @@ Evidence markers live under `.case//` in the target repo. The `.case/ | `.case//manual-tested` | `ca mark-manual-tested` | Proves manual/browser testing was performed | | `.case//reviewed` | `ca mark-reviewed` | Proves code review passed (critical: 0) | -#### `tested` structured format - -When piped JSON output from `vitest --reporter=json`, `ca mark-tested` writes structured fields: +**Evidence flags** (`tested`, `manualTested`) can only be set by the marker commands — +not by agents directly. When piped JSON output from `vitest --reporter=json`, +`ca mark-tested` writes structured `passed`/`failed`/`total`/`duration_ms`/… fields; +plain-text output falls back to grep heuristics. -``` -timestamp: ... -output_hash: ... -pass_indicators: N -fail_indicators: N -passed: N -failed: N -total: N -duration_ms: N -suites: N -files: [...] -``` - -Plain-text fallback uses grep heuristics for pass/fail indicators only. - -## Status Lifecycle +## Status lifecycle ``` active → implementing → verifying/reviewing/evaluating → closing → pr-opened → merged @@ -107,74 +92,12 @@ Recovery transitions: pr-opened → pr-opened (idempotent, hook re-fire) ``` -Pipeline agents: implementer → verifier → reviewer → closer → (retrospective) - -Transitions are enforced by the TypeScript task store and `ca status`. Invalid transitions are rejected with an error. - -## Progress Log - -Every task file has a `## Progress Log` section at the end. Agents append entries — never edit existing ones. Each entry includes the agent name, timestamp, and what was done. - -```markdown -## Progress Log - -### Orchestrator — 2026-03-08T10:30:00Z - -- Created task from GitHub issue #53 -- Baseline smoke test: PASS +Pipeline agents: implementer → verifier → reviewer → closer → (retrospective). +Transitions are enforced by the task store and `ca status`; invalid transitions are +rejected. -### Implementer — 2026-03-08T10:35:00Z +## Progress -- Root cause: hardcoded cookie name -- Fix: use WORKOS_COOKIE_NAME env var -- Tests: 4 passing, committed abc123 -``` - -## Example - -```markdown -> **Mission**: Add `orgs list` CLI command so users can list organizations from the terminal -> **Repo**: ../cli/main -> **Done when**: `workos orgs list` outputs organizations in human-readable and JSON formats - -# Add `workos orgs list` command - -## Objective - -Add an `orgs list` subcommand to the CLI that lists organizations -in the current WorkOS environment. - -## Target Repos - -- ../cli/main - -## Playbook - -docs/playbooks/add-cli-command.md - -## Context - -API endpoint: GET /organizations -See: https://workos.com/docs/reference/organization/list - -## Acceptance Criteria - -- [ ] `workos orgs list` outputs organizations in human-readable format -- [ ] `workos orgs list --json` outputs valid JSON -- [ ] Tests pass -- [ ] Types check - -## Checklist - -- [ ] Read playbook and CLI architecture doc -- [ ] Create src/commands/organization.ts -- [ ] Create src/commands/organization.spec.ts -- [ ] Register in src/bin.ts -- [ ] Update src/utils/help-json.ts -- [ ] Run pnpm test && pnpm typecheck -- [ ] Open PR with conventional commit message - -## Progress Log - - -``` +Progress lives in `td` (`td log`, `td handoff`) and in structured working memory at +`.case//working-memory.json` (`ca update-memory`), which the orchestrator +injects as a `## Prior Context` block before dispatching the next phase. From c0ca3cdc47ff171a13007286e97859e4f996d9a3 Mon Sep 17 00:00:00 2001 From: Em Jones Date: Sun, 21 Jun 2026 13:15:22 -0700 Subject: [PATCH 02/17] docs(migration): add LangGraph + Langfuse migration RFC MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two-phase plan replacing the custom DAG executor and granular event log with LangGraph (orchestration + checkpointer) and Langfuse (observability), preserving every existing feature. - Phase 1 swaps orchestration to LangGraph; Phase 2 integrates Langfuse. Each phase isolates exactly one breaking cutover (1.3 resume cutover, 2.2 event-log deletion) so a bisect localizes regressions to one phase. - Resolves open decisions: LangGraph SQLite checkpointer for resume (td stays a coarse human mirror), `interrupt` for human override, re-point `ca watch` at the in-process callback stream, custom counter channel for the revision budget. - §9 test disposition triages every affected suite (die / port / keep / audit / net-new), pinning the resume oracle and evidence-gate coverage. - Langfuse self-hosted via podman-compose; dispatch host configurable. Co-Authored-By: Claude Opus 4.8 (1M context) --- MIGRATE_IMPLEMENTATION.md | 277 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 277 insertions(+) create mode 100644 MIGRATE_IMPLEMENTATION.md diff --git a/MIGRATE_IMPLEMENTATION.md b/MIGRATE_IMPLEMENTATION.md new file mode 100644 index 0000000..c756888 --- /dev/null +++ b/MIGRATE_IMPLEMENTATION.md @@ -0,0 +1,277 @@ +# Migration: Custom DAG + Event-Sourcing → LangGraph + Langfuse + +**Status:** Proposed (RFC) +**Author:** Case maintainers +**Scope:** Replace Case's hand-rolled orchestration engine and granular event log with LangGraph (graph execution + checkpointing) and Langfuse (observability dispatch), without losing any existing feature. + +--- + +## 1. Motivation + +Case currently owns ~5,400 LOC across three subsystems: + +- **Custom DAG** (`src/dag/`, `src/pipeline.ts`) — graph build, ready-node dispatch, revision loops, outcome routing. +- **Event-sourcing** (`src/events/`) — granular JSONL log that is replayed for crash-resume AND doubles as the observability/metrics source. +- **Agent runtime** (`src/agent/`) — pi-agent-core wrapper. + +Two of those subsystems substantially re-implement what LangGraph and Langfuse provide natively: + +- LangGraph gives `StateGraph` (conditional edges, cycles, parallel supersteps) and a **checkpointer** that subsumes our replay-for-resume path. +- Langfuse models the exact trace → span → event → score tree our event taxonomy already encodes, plus token/cost (which pi pre-computes per call but we never surface). + +The agent runtime (pi) **stays** — LangGraph nodes wrap `agent.execute()`. This is not a rewrite of how agents run; it is a replacement of how they are *sequenced* and *observed*. + +### Expected net effect + +- **Delete** the custom executor/builder and the granular event schema/appender/reducer (~1,800 LOC of the ~5,400). +- **Add** LangGraph graph wiring + Langfuse dispatch glue (~300–500 LOC). +- **Net ≈ −700 to −1,000 LOC**, plus we stop maintaining a graph runner and a trace exporter. +- **Gain** a trace UI, first-class eval scores, and per-call token + dollar cost — none of which exist today. + +### Guiding constraints + +1. **Nothing in the control path may read back from Langfuse.** Langfuse is async, batched-over-HTTP, lossy-on-crash, and retention-bounded. It is a fire-and-forget sink only. +2. **The self-improvement loop stays local and durable.** The retrospective phase reads a small local run-summary (`runs.jsonl`), never Langfuse. +3. **The live TUI feed stays in-process.** The terminal activity feed is driven by synchronous callbacks, not by the trace sink — Langfuse cannot drive a live local UI. +4. **Evidence gates stay truth-on-disk.** Marker files (`tested`, `reviewed`) remain the gate truth; they are not derived from a remote store. + +### Deployment + +Langfuse is self-hosted via `podman-compose` (see `podman-compose.yaml`). The dispatch target is **configurable** — `LANGFUSE_HOST` / `LANGFUSE_BASE_URL` (plus public/secret keys) default to the compose service but may point at any Langfuse instance (incl. cloud). Self-hosting means retention is an operator knob, not a fixed vendor limit — but the §7 "control path never reads Langfuse" rule holds regardless. + +--- + +## 2. Feature Inventory (parity table) + +Every current feature, its present implementation, and where it lands after migration. Disposition tags: + +- **KEEP** — unchanged, no migration work. +- **MOVE** — same behavior, relocated to LangGraph/Langfuse primitive. +- **REPLACE** — re-expressed against a framework primitive (logic preserved, mechanism changes). +- **UPGRADE** — gains capability we don't have today. +- **NEW** — net-new capability the migration unlocks. + +### Orchestration / DAG + +| Name | Current implementation | New implementation | Disposition | +|---|---|---|---| +| Graph construction (profiles: tiny/standard) | `buildGraph(profile, maxRevisionCycles)` — `src/dag/builder.ts:5`; `PROFILE_PHASES` `src/types.ts:119` | `StateGraph` definition; profile selects which nodes/edges are added | REPLACE | +| Ready-node detection + parallel dispatch | `findReadyNodes()` + `Promise.all` — `src/dag/executor.ts:64,177` | LangGraph native parallel supersteps (fan-out edges) | REPLACE — *parallel dispatch exists today; tiny/standard profiles are near-linear and exercise it only as graphs widen* | +| Conditional revision loops (implement→verify→review→implement N+1) | Edge predicates `revisionRequestedPredicate()` — `src/dag/builder.ts:89,140-178` | LangGraph conditional edges returning next node | REPLACE | +| Revision budget cap | `maxRevisionCycles` (default 2) — `src/pipeline.ts:106` | Counter channel in graph state + conditional-edge guard; LangGraph `recursionLimit` as backstop | REPLACE | +| Fingerprint loop detection (SHA-256 of failure reason; abort on repeat) | `handleEvaluatorPairCompletion()` — `src/dag/executor.ts:220-269` | Same logic as a node/edge function over graph state (preserved verbatim, relocated) | MOVE | +| Outcome matrix `(phase, outcome) → action` | `src/dag/outcome-table.ts` | Conditional-edge routing functions keyed off the same table | REPLACE | +| Failure routing → skip pending, run retrospective once | `src/dag/executor.ts:112` | Conditional edge to `retrospective` node; other pending nodes unreachable | REPLACE | +| Human override (retry/abort prompt, attended mode) | `src/pipeline.ts:303-313` | LangGraph `interrupt` (human-in-the-loop) or retain custom prompt around graph step | REPLACE — **decision needed** (see §5) | +| Scout non-blocking routing | Always routes `implement_0` — `src/pipeline.ts:289-293` | Unconditional edge scout→implement | REPLACE | +| Cross-phase state passing (`scoutSlot`, `previousResults`, `revision`) | Closures + `Map` — `src/pipeline.ts:82,165,297` | LangGraph state channels (typed `StateGraph` state object) | MOVE | + +### Resume / state + +| Name | Current implementation | New implementation | Disposition | +|---|---|---|---| +| Crash recovery / mid-graph resume | Replay log: `loadEventsFromFile` → `reduceEvents` → `restoreGraphState` — `src/pipeline.ts:119-127`, `src/dag/restore.ts:4-18` | LangGraph checkpointer (SQLite) auto-restores last superstep | REPLACE | +| Resume from pending revision | `task.pendingRevision` seeded from td — `src/pipeline.ts:139-148` | `pendingRevision` lives in checkpointed graph state; td still seeds first run | MOVE | +| Pipeline state model | Event-sourced `PipelineState` via `reduceEvents` — `src/events/reducer.ts` | LangGraph state channels; checkpointer snapshots replace event replay | REPLACE | +| Task state persistence (authoritative `TaskJson`) | Hidden `` JSON in td issue description — `src/state/td-client.ts`, `src/state/task-store.ts` | **Unchanged** — td remains the task-grain store | KEEP | +| Working memory (per-agent context between phases) | `working-memory.json` r/w — `src/memory/working-memory.ts:32-96`; `ca update-memory` | **Unchanged** — local JSON, not event-derived | KEEP | + +### Observability + +| Name | Current implementation | New implementation | Disposition | +|---|---|---|---| +| Granular event log (phase/tool/domain events) | JSONL `run-*.jsonl` — `src/events/appender.ts:48`, schema `src/events/schema.ts` | Langfuse dispatch at the subscriber seam (trace/span/event); **log deleted** | MOVE → Langfuse | +| Tool activity tracing (sanitized args/results) | `tool_execution_start/end` → event + `onToolActivity` — `src/agent/adapters/pi-adapter.ts:79-126` | Langfuse nested spans (via same subscriber) | MOVE → Langfuse | +| LLM-call telemetry (tokens) | Cumulative only: `ctx.getContextUsage().tokens` — `src/agent/orchestrator-session.ts:246` | Langfuse **generation** spans from `turn_end.message.usage` (per call) | UPGRADE | +| LLM-call **cost** ($) | Not tracked | Langfuse generation `usage.cost` — pi pre-computes per call (`pi-ai types.d.ts:144-157`) | NEW | +| Eval rubric scores (verifier/reviewer) | Embedded in `AgentResult` / metrics | Langfuse **score()** — first-class eval dashboards | UPGRADE | +| Phase metrics (duration, status, artifacts) | `projectMetrics()` — `src/events/projections.ts:61` | Langfuse spans + retained run-summary | MOVE → Langfuse | +| Run summary log (`runs.jsonl`) | `writeRunMetrics()` — `src/metrics/writer.ts:12` | **Kept local** — retrospective's durable read source | KEEP | +| Prior-run linking (`priorRunId`) | `findPriorRunId()` reads `runs.jsonl` — `src/versioning/prompt-tracker.ts:56-82` | **Unchanged** — reads kept `runs.jsonl` | KEEP | +| Live TUI activity feed / heartbeat (10s) | `onToolActivity` / `onAgentHeartbeat` callbacks → notifier — `src/agent/adapters/pi-adapter.ts` | **Unchanged** — synchronous in-process callbacks (Langfuse cannot drive live local UI) | KEEP | +| Live event tail (`ca watch`) | Polls JSONL — `src/watch/watcher.ts:26-77` | Langfuse trace UI (remote) **or** re-point `ca watch` at in-process callback stream | REPLACE — **decision needed** (see §5) | + +### Evidence / task mirror + +| Name | Current implementation | New implementation | Disposition | +|---|---|---|---| +| Evidence markers (`tested` / `reviewed` / `manual-tested`) | Disk files written via `projectMarkers()` — `src/events/appender.ts:76-84`; `ca mark-*` | Node writes marker file **directly** on phase completion; checkpointer holds marker set | MOVE (drop event hop; disk stays truth) | +| td status mirror (native status + labels) | `projectTaskJson()` after each event — `src/events/appender.ts:72`; `caseToTdStatus` `src/state/td-client.ts:76` | Node writes td **directly** on phase end (already a synchronous projection) | MOVE (drop event hop) | +| td CRUD / focus / resolveFocusedTask | `src/state/td-client.ts` | **Unchanged** | KEEP | + +### Agent runtime + +| Name | Current implementation | New implementation | Disposition | +|---|---|---|---| +| Per-phase agent execution | `PiRuntimeAdapter.spawn` → `agent.execute()` — `src/agent/adapters/pi-adapter.ts:29-186` | **Unchanged** — wrapped as a LangGraph node | KEEP | +| Per-agent tool sets (mutable vs read-only) | `createPiTools()` per agent | **Unchanged** | KEEP | +| System-prompt loading per agent | Loaded from `agents/*.md` | **Unchanged** | KEEP | +| Model resolution + override | `ModelRegistry` + `CASE_MODEL_OVERRIDE` | **Unchanged** | KEEP | +| Per-phase timeout (600s default) | pi-adapter timeout | **Unchanged** (or LangGraph node timeout) | KEEP | +| Result parsing → `AgentResult` | `parseAgentResult()` | **Unchanged** | KEEP | +| Runtime pluggability interface | `CaseAgentRuntime` — `src/agent/runtime.ts` | **Unchanged** — LangGraph node calls through it | KEEP | + +### Self-improvement + +| Name | Current implementation | New implementation | Disposition | +|---|---|---|---| +| Retrospective phase | Reads in-memory `metricsSnapshot` + `previousResults` — `src/phases/retrospective.ts:24-26,57-76` | **Unchanged** logic; snapshot computed from graph state / kept `runs.jsonl` | KEEP | +| Prompt versioning | `promptVersions` in metrics — `src/versioning/` | **Unchanged** | KEEP | + +--- + +## 3. Target Architecture + +``` +Run = Langfuse trace (keyed runId) +│ +├─ LangGraph StateGraph ← orchestration +│ nodes = phases (scout, implement, verify, review, close, retrospective) +│ node body wraps pi agent.execute() ← KEEP pi runtime +│ edges = conditional routing (outcome matrix, revision loop, fingerprint guard) +│ state = typed channels (results, pendingRevision, revisionCycles, markers) +│ checkpointer = SQLite in /.todos/ ← REPLACES replay-for-resume +│ +├─ pi agent.subscribe(event) [pi-adapter.ts:68, exists] ← single observability seam +│ agent_start/end → Langfuse span (phase) +│ turn_start/turn_end → Langfuse generation (usage = tokens + cost) +│ tool_execution_start/end → Langfuse span (nested) +│ domain events → Langfuse event() +│ rubric → Langfuse score() +│ AND (unchanged) → onToolActivity/onAgentHeartbeat → live TUI notifier +│ +├─ runs.jsonl (local, kept) ← retrospective read source +├─ working-memory.json (local, kept) ← cross-phase agent context +├─ marker files (local, kept) ← evidence gates +└─ td issue (kept) ← task-grain state + human mirror +``` + +Three homes, zero overlap: + +- **Orchestration state** (node status, revisionCycles, pendingRevision) → LangGraph checkpointer. +- **Non-orchestration events** (tool traces, phase timing, scout findings, rubrics, diagnostics) → Langfuse. +- **Durable local truth** (run summary, working memory, markers, task state) → unchanged files / td. + +--- + +## 4. Migration Plan (two phases, one breaking change each) + +Two phases, severable because the event log's two roles (resume source, observability source) die in different phases. **Phase 1** swaps orchestration to LangGraph and severs the resume role; the log survives **write-only** as the observability source. **Phase 2** adds Langfuse, then severs the observability role and deletes the log. Each phase is a sequence of additive/flagged/reversible steps followed by **exactly one labeled breaking cutover** — so a bisect localizes any regression to one phase, and the breaking commit in each phase is singular. + +Invariant across the whole migration until `2.2`: the granular `run-*.jsonl` keeps being **written** (the appender is untouched). Phase 1 only stops *reading* it for resume; Phase 2 stops writing it. + +### Phase 1 — Orchestration → LangGraph + +No observability change. Event log still written (now only the metrics/observability source). Langfuse absent. `ca watch` still polls JSONL. + +**1.1 — Wrap pi as a LangGraph node (parallel path, no cutover).** *Additive · reversible.* +Introduce `StateGraph` reproducing the current linear+revision flow; each node calls the existing `CaseAgentRuntime`. Gate behind `CASE_ENGINE=langgraph`. Old executor remains default. +*Acceptance:* a tiny-profile run completes through the LangGraph path with identical phase outcomes to the legacy executor. + +**1.2 — Stand up the checkpointer; dual-write; prove resume parity.** *Additive · reversible.* +Add the LangGraph SQLite checkpointer in `.todos/` (co-location per §6). Run both resume mechanisms; assert restored graph state matches `reduceEvents` on the same crash point. +*Acceptance:* kill a run mid-`implement_1`; both paths resume to the same node set and `pendingRevision`. + +**1.3 — ⚠ BREAKING: resume cutover + default flip.** *The one breaking change of Phase 1. Guarded by 1.2's parity test.* +Flip the default to LangGraph and delete the legacy engine: remove `loadEventsFromFile` → `reduceEvents` → `restoreGraphState` and the old executor/builder. Relocate the td mirror + marker writes to **node-direct** (write on node completion; remove those projection side-effects from the event path — the raw appender stays, only its derived writes move). After this, resume is checkpointer-only and orchestration no longer touches the event log. +*Acceptance:* resume works with the replay path gone; td status + labels and marker files still update each phase; `runs.jsonl`/metrics unchanged; full suite green. + +*End state of Phase 1:* LangGraph + checkpointer own orchestration; event log is a write-only observability sink; everything else (Langfuse, `ca watch`) unchanged. + +### Phase 2 — Observability → Langfuse + +No orchestration change. Begins additive; the single breaking cutover is the log deletion. + +**2.1 — Add Langfuse dispatch at the subscriber seam.** *Additive · fire-and-forget · reversible.* +In `pi-adapter.ts:68`, map `agent_start/end`, `turn_start/end`, `tool_execution_*`, domain events, and rubrics to Langfuse trace/span/generation/event/score. Keep `onToolActivity`/heartbeat feeding the TUI. Langfuse failures must not affect the run. Observability is now **dual** (JSONL + Langfuse). +*Acceptance:* a run produces a complete Langfuse trace with per-call token + cost; with Langfuse unreachable, the run still completes and the TUI feed is intact. + +**2.2 — ⚠ BREAKING: delete granular event log + re-point `ca watch`.** *The one breaking change of Phase 2.* +Delete `src/events/{schema,appender,reducer}.ts` and the now-orphaned `projectTaskJson`/`projectMarkers`. Re-point `ca watch` from JSONL polling to the in-process callback stream (per §5 decision 3). **Keep** `runs.jsonl`, `findPriorRunId`, working memory, markers, td. +*Acceptance:* full suite green; `ca watch` tails live activity; retrospective still reads `runs.jsonl`; Langfuse trace complete. Breaking surface = any external consumer of `run-*.jsonl` and `ca watch`'s source. + +--- + +## 5. Decisions (resolved) + +1. **Resume mechanism — DECIDED: LangGraph SQLite checkpointer.** Not td-embedded graph state (td stays a coarse human-facing projection — it lacks per-cycle keys, `revisionCycles`, the fingerprint set, and full `AgentResult` bodies), and not a hand-rolled snapshot. The checkpointer owns engine state; td keeps mirroring coarse status for humans. Co-location with td's SQLite must be verified (§6). +2. **Human override mechanism — DECIDED: LangGraph `interrupt`.** Native human-in-the-loop; composes with checkpointed resume. (Alt considered: custom retry/abort prompt wrapped around graph steps.) +3. **`ca watch` future — DECIDED: re-point at the in-process callback stream.** Keeps the offline local-first terminal tail; small adapter. (Alt considered: replace with the remote Langfuse trace UI — loses offline tail.) +4. **Revision budget mechanism — DECIDED: custom counter channel + edge guard.** Explicit, matches today's `maxRevisionCycles`. LangGraph `recursionLimit` retained only as a runaway backstop. (Alt considered: `recursionLimit` alone — too blunt.) + +--- + +## 6. Open Verifies (must confirm during Phase 1) + +- **Checkpointer / td SQLite co-location.** Now load-bearing (§5 decision 1 commits to the checkpointer). Confirm the LangGraph SQLite checkpointer can live in `/.todos/` alongside td's schema (separate tables, no migration conflict), or fall back to a sibling DB file (`/.todos/case-checkpoints.db`) if td guards its schema. Resolve in step 1.2. +- **Outcome-matrix → conditional-edge re-expression.** Confirm every `(phase, outcome) → action` row maps to a deterministic edge function with no loss (esp. `abort`, `request-revision`, fingerprint short-circuit). +- **pi LLM-call seam.** Confirmed available: `turn_end` carries `message.usage` with tokens **and** pre-computed `cost` (`pi-ai types.d.ts:144-157`); subscriber already exists at `pi-adapter.ts:68`. No pi patching required. + +--- + +## 7. Risks + +| Risk | Impact | Mitigation | +|---|---|---| +| Event-sourcing → snapshot semantics shift | Lose "replay full event stream to derive new metrics retroactively" | Langfuse holds the audit trace; retro metrics derived live and persisted to `runs.jsonl` | +| Langfuse retention evicts history the control path needs | Self-improvement loop breaks | Hard rule: control path never reads Langfuse; retro reads local `runs.jsonl` | +| Langfuse outage during a run | Lost observability for that run | Fire-and-forget dispatch; run + TUI unaffected (checkpointer + callbacks are local) | +| LangGraph edge re-expression drifts from outcome matrix | Subtle routing bugs | Phase 1.1/1.2 parity test vs. legacy executor on identical inputs before the 1.3 cutover | +| Marker / td drift after dropping event projection | Gates or status out of sync | Phase 1.3 writes them node-direct (same synchronous point as today) + suite assertions | + +--- + +## 8. Out of Scope + +- Replacing pi-agent-core with LangChain's agent/tool layer (separate, larger decision). +- Replacing td as the task-grain store. +- Changing agent prompts, tool sets, or model selection. + +--- + +## 9. Test Disposition + +The phases delete whole subsystems, so their tests must be triaged — not blanket-deleted. Three buckets: **DIE** (mechanism gone, behavior gone), **PORT** (behavior survives, mechanism swaps — deleting silently drops a guarantee), **KEEP** (relocated-verbatim or out of scope). + +### DIE — remove with the code + +| Test | Deleted dependency | When | +|---|---|---| +| `dag-builder.spec` | `dag/builder buildGraph` (→ `StateGraph` def) | 1.3 | +| `dag-builder-scout.spec` | `dag/builder` | 1.3 | +| `dag-executor.spec` | `dag/executor executeGraph,findReadyNodes` (→ LangGraph runs the graph) | 1.3 | +| `events-appender.spec` | `events/appender` | 2.2 | +| `events-reducer.spec` | `events/reducer reduceEvents,loadEventsFromFile` | 2.2 | +| `events-validation.spec` | `events/errors validateTransition` (no event lifecycle) | 2.2 | + +> ⚠ `events-reducer.spec` is the **resume-correctness oracle**. Its assertions are the parity target the checkpointer must match in 1.2. Retire only after 1.3 cutover is green — do not delete in step order ahead of its replacement. + +### PORT — behavior survives, must stay tested + +| Test | Behavior preserved | Re-point to | +|---|---|---| +| `events-projections.spec` | `projectTaskJson` status mapping; **`projectMarkers` (evidence gates)**; `projectMetrics` | node-direct td-write + marker-write (1.3); metrics → `runs.jsonl`/Langfuse | +| `dag-status.spec` | `projectStatusFromGraph` (node states → `TaskStatus`) | same logic over LangGraph state channels (1.3) | +| resume assertions in `pipeline.spec` | crash → correct node set + `pendingRevision` | checkpointer restore (1.2) | + +> ⚠ `projectMarkers` coverage must exist node-direct after 1.3 — markers are the evidence gates (§1 constraint 4). Losing this test silently weakens a gate. + +### KEEP — relocated-verbatim or out of scope + +- `fingerprint.spec` — `dag/fingerprint` is MOVE-verbatim (§2). +- `outcome-table.spec` — table retained; conditional edges key off it. +- `dag-merge.spec` — `mergeRevisionRequests` is pure on `RevisionRequest[]`, no graph dependency. +- All non-orchestration suites (onboard, interview, scout, sanitize, parse-agent-result, config, paths, …). + +### AUDIT — mixed, split don't blanket-delete + +- `pipeline.spec` — replay-resume parts DIE; phase-sequence/outcome parts PORT. Read and split. +- `orchestrator-session.spec` — token telemetry is cumulative today, UPGRADE'd to per-call Langfuse (§2). The cumulative-tokens assertion changes meaning; re-check rather than assume. + +### NET-NEW — coverage the phases require + +Deleting the DIE bucket leaves holes. Add: + +- **1.2:** checkpointer resume parity (the new oracle replacing `events-reducer.spec`). +- **1.3:** LangGraph graph-construction + conditional-edge routing (replaces builder/executor tests; routing still keys off `outcome-table`). +- **2.1:** Langfuse dispatch is fire-and-forget — assert *run completes + TUI feed intact with Langfuse unreachable* (§7 risk row). From b731d949d7a55f401b627639e04a358631d6aaa7 Mon Sep 17 00:00:00 2001 From: Em Jones Date: Sun, 21 Jun 2026 15:23:26 -0700 Subject: [PATCH 03/17] feat(langgraph): implement LangGraph state machine and pipeline dispatch Replace custom DAG executor with LangGraph state machine. Add pipeline-dispatch.ts for routing, update pipeline.ts to use LangGraph engine, add parity tests, and include podman-compose for local Langfuse deployment. --- MIGRATE_IMPLEMENTATION.md | 40 ++- bun.lock | 48 ++- package.json | 2 + podman-compose.yaml | 179 ++++++++++++ src/__tests__/langgraph-parity.spec.ts | 360 +++++++++++++++++++++++ src/dev/run-tests.ts | 54 +++- src/langgraph/engine.ts | 310 ++++++++++++++++++++ src/langgraph/state.ts | 67 +++++ src/pipeline-dispatch.ts | 220 ++++++++++++++ src/pipeline.ts | 389 +++++++------------------ 10 files changed, 1382 insertions(+), 287 deletions(-) create mode 100644 podman-compose.yaml create mode 100644 src/__tests__/langgraph-parity.spec.ts create mode 100644 src/langgraph/engine.ts create mode 100644 src/langgraph/state.ts create mode 100644 src/pipeline-dispatch.ts diff --git a/MIGRATE_IMPLEMENTATION.md b/MIGRATE_IMPLEMENTATION.md index c756888..ae2b746 100644 --- a/MIGRATE_IMPLEMENTATION.md +++ b/MIGRATE_IMPLEMENTATION.md @@ -1,11 +1,49 @@ # Migration: Custom DAG + Event-Sourcing → LangGraph + Langfuse -**Status:** Proposed (RFC) +**Status:** In progress — Phase 1.1 complete (see §0). **Author:** Case maintainers **Scope:** Replace Case's hand-rolled orchestration engine and granular event log with LangGraph (graph execution + checkpointing) and Langfuse (observability dispatch), without losing any existing feature. --- +## 0. Migration Status (handoff log) + +> Running log of what has actually landed, with deviations from the plan called out. Update this section as each step completes. + +### ✅ Phase 1.1 — Wrap pi as a LangGraph node (parallel path, flag-gated) — **DONE** + +LangGraph (`@langchain/langgraph` 1.4.4 + peer `@langchain/core` 1.2.0, Bun-verified) now drives orchestration behind `CASE_ENGINE=langgraph`. Legacy DAG executor remains the **default**; nothing in the default path changed behaviorally. + +**Landed:** + +- **`src/pipeline-dispatch.ts` (NEW).** Extracted `dispatchNode` / `consultMatrix` / `handleFailure` / `PipelineCallbacks` out of `pipeline.ts`. Both engines call this one dispatcher, so per-phase semantics (matrix consult, abort prompts via `handleFailure`, scout findings hand-off, `previousResults` bookkeeping) are **identical by construction**. First param generalized to `DispatchNodeRef = { phase, startedAt? }` (legacy `DagNode` is assignable). +- **`src/langgraph/state.ts` (NEW).** `StateGraph` channels: `cycle`, `revisionCycles`, `pendingRevision`, `fingerprints` (Record), `last`, `evaluator`, `decision`, `revisionClosed`. Holds **orchestration** state only — agent context (scout findings, `previousResults`) and run-level `outcome`/`failedAgent` stay in the shared pipeline closure exactly as legacy keeps them. *(This is the object the 1.2 checkpointer will snapshot.)* +- **`src/langgraph/engine.ts` (NEW).** `executeLangGraph(...)` reproduces scout→implement→verify→review→close→retrospective with the revision loop, fingerprint short-circuit, revision-budget cap, and failure→retrospective routing via conditional edges. Emits the **same event stream** through the existing `EventAppender`, so td-status mirror, evidence markers, metrics, and `runs.jsonl` stay correct **for free** (the appender's `projectTaskJson`/`projectMarkers` is the single projection seam — no shadow DAG needed). +- **`src/pipeline.ts`.** Branches on `CASE_ENGINE` inside `runPipelineBody`. Shared `dispatch` closure hoisted; legacy graph build/resume/`executeGraph` moved into the `else`. −282 LOC net (dispatcher relocated). +- **`src/__tests__/langgraph-parity.spec.ts` (NEW).** Runs both engines over an identical mock runtime, asserts identical `(phase, outcome)` sequence (and pins each to an explicit expected). 6 cases: standard happy, tiny profile-skip, verifier revision, reviewer soft-fail revision, budget-exhausted (`maxRevisionCycles=1`), fingerprint short-circuit. **6/6 green.** + +**Validation:** typecheck ✅ · `oxlint` ✅ · AST self-lint ✅ · `oxfmt` ✅ · parity 6/6 ✅ · legacy `pipeline.spec` 24/24 unchanged ✅ · full suite green (see test-runner note below). + +**Deviations / decisions made during implementation:** + +1. **Resume under `langgraph` is deferred to 1.2.** 1.1 is fresh-runs-only on the LangGraph path; event-log crash-resume stays legacy-only until the SQLite checkpointer lands. A td-persisted `pendingRevision` still seeds resume-at-implement (passed as `initialPendingRevision`, seeds `cycle`/`revisionCycles`). +2. **Replicated a legacy quirk for true parity.** When a **verify** failure is *denied* revision (budget exhausted or fingerprint match), the legacy executor still runs that cycle's **review** before closing — skipping the next cycle unblocks `verifyPassedPredicate`. The engine reproduces this: `revise` routes a denied verify-failure to `review` first (guarded by the `revisionClosed` channel so that trailing review can't itself re-trigger revision). A *review*-triggered denial closes directly (review already ran). +3. **`status_changed` is computed per-phase** (implement→implementing, verify→verifying, …, post-close→pr-opened) rather than via `projectStatusFromGraph`. The sequential engine never has verify+review running concurrently, so the legacy `evaluating` (concurrent) status is not emitted on the LangGraph path. Does not affect phase-outcome parity; revisit if a profile widens to true parallel supersteps. +4. **Skipped-phase `phase_end` events are not emitted** on the failure path (legacy emits `outcome:'skipped'` for bypassed pending nodes). Parity is asserted on *executed*-phase outcomes. If `projectMetrics`' `skippedPhases` fidelity matters under LangGraph, emit these in 1.3 when marker/td writes go node-direct. + +**Test-runner fix (`src/dev/run-tests.ts`) — required, not optional.** Bun's `mock.module()` is process-global and persists across files; `bun test ./src/__tests__/` loaded all specs into one process, so top-level mocks leaked (`pipeline-tool.spec`'s `pipeline.js` mock broke `pipeline.spec`/parity; `pipeline.spec`'s `task-store` mock broke `task-scanner`/`createTask`/`update-memory`). This was **pre-existing** (38 failures on clean HEAD). Fixed by running each unit spec in its own process (concurrency 8). Every spec passes in isolation; the suite is green. **Next session: keep specs isolated — do not collapse back to a single `bun test ` invocation.** + +### ⏭ Next: Phase 1.2 — checkpointer + resume parity + +- Add the LangGraph SQLite checkpointer; resolve the **§6 co-location verify** (live alongside td's SQLite in `/.todos/`, or sibling `case-checkpoints.db`). +- Wire checkpointed resume on the `langgraph` path; remove the 1.1 "fresh-runs-only" limitation. +- New oracle test: **checkpointer resume parity** — kill mid-`implement_1`, assert restored node set + `pendingRevision` match `reduceEvents` on the same crash point (replaces `events-reducer.spec` after the 1.3 cutover, not before). +- Snapshot target is `src/langgraph/state.ts`'s channels (already isolated to orchestration state for this purpose). + +**Not yet started:** Phase 1.3 (breaking cutover + legacy delete), all of Phase 2 (Langfuse). The `podman-compose.yaml` Langfuse stack is present but unused until Phase 2. + +--- + ## 1. Motivation Case currently owns ~5,400 LOC across three subsystems: diff --git a/bun.lock b/bun.lock index c5696e9..90d0181 100644 --- a/bun.lock +++ b/bun.lock @@ -5,6 +5,8 @@ "": { "name": "@case/orchestrator", "dependencies": { + "@langchain/core": "^1.2.0", + "@langchain/langgraph": "^1.4.4", "@mariozechner/pi-agent-core": "^0.73.1", "@mariozechner/pi-ai": "^0.73.1", "@mariozechner/pi-coding-agent": "^0.73.1", @@ -98,8 +100,20 @@ "@borewit/text-codec": ["@borewit/text-codec@0.2.2", "", {}, "sha512-DDaRehssg1aNrH4+2hnj1B7vnUGEjU6OIlyRdkMd0aUdIUvKXrJfXsy8LVtXAy7DRvYVluWbMspsRhz2lcW0mQ=="], + "@cfworker/json-schema": ["@cfworker/json-schema@4.1.1", "", {}, "sha512-gAmrUZSGtKc3AiBL71iNWxDsyUC5uMaKKGdvzYsBoTW/xi42JQHl7eKV2OYzCUqvc+D2RCcf7EXY2iCyFIk6og=="], + "@google/genai": ["@google/genai@1.46.0", "", { "dependencies": { "google-auth-library": "^10.3.0", "p-retry": "^4.6.2", "protobufjs": "^7.5.4", "ws": "^8.18.0" }, "peerDependencies": { "@modelcontextprotocol/sdk": "^1.25.2" }, "optionalPeers": ["@modelcontextprotocol/sdk"] }, "sha512-ewPMN5JkKfgU5/kdco9ZhXBHDPhVqZpMQqIFQhwsHLf8kyZfx1cNpw1pHo1eV6PGEW7EhIBFi3aYZraFndAXqg=="], + "@langchain/core": ["@langchain/core@1.2.0", "", { "dependencies": { "@cfworker/json-schema": "^4.0.2", "@standard-schema/spec": "^1.1.0", "js-tiktoken": "^1.0.12", "langsmith": ">=0.5.0 <1.0.0", "mustache": "^4.2.0", "p-queue": "^6.6.2", "zod": "^3.25.76 || ^4" } }, "sha512-nXmyH0FbcsASlRmC9sbqX0gjQdxgB9KcS13vkw9PMaH0zzylwZkGFU9sY0XCPa2/AokmaNTU9DOW3IUDfAtQow=="], + + "@langchain/langgraph": ["@langchain/langgraph@1.4.4", "", { "dependencies": { "@langchain/langgraph-checkpoint": "^1.1.2", "@langchain/langgraph-sdk": "~1.9.23", "@langchain/protocol": "^0.0.16", "@standard-schema/spec": "1.1.0" }, "peerDependencies": { "@langchain/core": "^1.1.48", "zod": "^3.25.32 || ^4.2.0", "zod-to-json-schema": "^3.x" }, "optionalPeers": ["zod-to-json-schema"] }, "sha512-20p+/xHRIUIEkk6dsoA576X7D5+FY+LkShsGjBpKrwATzQU0IJ2dfpBaP+4Z4wwpL9ArpDxjoRQR58kycdxU8A=="], + + "@langchain/langgraph-checkpoint": ["@langchain/langgraph-checkpoint@1.1.2", "", { "peerDependencies": { "@langchain/core": "^1.1.48" } }, "sha512-m5Xd7W3G9JrlEhFZ5WAcqZPgE46R9gr1gFDFaVqEKeuwin3tgEp0jlPbru+iFXCug338DcQjFS/Kuuci21ydvw=="], + + "@langchain/langgraph-sdk": ["@langchain/langgraph-sdk@1.9.23", "", { "dependencies": { "@langchain/protocol": "^0.0.16", "@types/json-schema": "^7.0.15", "p-queue": "^9.0.1", "p-retry": "^7.1.1" }, "peerDependencies": { "@langchain/core": "^1.1.48", "react": "^18 || ^19", "react-dom": "^18 || ^19", "svelte": "^4.0.0 || ^5.0.0", "vue": "^3.0.0" }, "optionalPeers": ["react", "react-dom", "svelte", "vue"] }, "sha512-JF5TWOrrKaMn9D7O0xT/9e9t3CpDRd8DUyKQdcbGswDsWdlI+04E9E1Lxv361tMu5pNYhval3iJPAwGxUuqi4w=="], + + "@langchain/protocol": ["@langchain/protocol@0.0.16", "", {}, "sha512-ws+J7MaHyhO5dG7f0vdyHQiUn9hoCnki0f3crJPa4MCTGzcRC39jYSCghyrGtBPYQnZbUQiGyRVpW3z3M8IpJg=="], + "@mariozechner/clipboard": ["@mariozechner/clipboard@0.3.6", "", { "optionalDependencies": { "@mariozechner/clipboard-darwin-arm64": "0.3.6", "@mariozechner/clipboard-darwin-universal": "0.3.6", "@mariozechner/clipboard-darwin-x64": "0.3.6", "@mariozechner/clipboard-linux-arm64-gnu": "0.3.6", "@mariozechner/clipboard-linux-arm64-musl": "0.3.6", "@mariozechner/clipboard-linux-riscv64-gnu": "0.3.6", "@mariozechner/clipboard-linux-x64-gnu": "0.3.6", "@mariozechner/clipboard-linux-x64-musl": "0.3.6", "@mariozechner/clipboard-win32-arm64-msvc": "0.3.6", "@mariozechner/clipboard-win32-x64-msvc": "0.3.6" } }, "sha512-MXdtr+6+ntlIVHdrZYuZNQydu6o8yZswFJ2Ln81j2O/Y9B/LDHvEaIm95xWNPkjGTWriSOeLnQJRFs6dYb60bg=="], "@mariozechner/clipboard-darwin-arm64": ["@mariozechner/clipboard-darwin-arm64@0.3.6", "", { "os": "darwin", "cpu": "arm64" }, "sha512-HjaisYCAbHi/1+N1yDAQHc8ZXGffufIUT5NSOSVR3f3AuMDusxTtnbK8tZ7JFDkShua1oNGZoNwQHsc8MPtE0Q=="], @@ -252,6 +266,8 @@ "@smithy/util-utf8": ["@smithy/util-utf8@2.3.0", "", { "dependencies": { "@smithy/util-buffer-from": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A=="], + "@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], + "@tokenizer/inflate": ["@tokenizer/inflate@0.4.1", "", { "dependencies": { "debug": "^4.4.3", "token-types": "^6.1.1" } }, "sha512-2mAv+8pkG6GIZiF1kNg1jAjh27IDxEPKwdGul3snfztFerfPGI1LjDezZp3i7BElXompqEtPmoPx6c2wgtWsOA=="], "@tokenizer/token": ["@tokenizer/token@0.3.0", "", {}, "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A=="], @@ -260,6 +276,8 @@ "@types/bun": ["@types/bun@1.3.14", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="], + "@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="], + "@types/mime-types": ["@types/mime-types@2.1.4", "", {}, "sha512-lfU4b34HOri+kAY5UheuFMWPDOI+OPceBSHZKp69gEyTL/mmJ4cnU6Y/rlme3UL3GyOn6Y42hyIEw0/q8sWx5w=="], "@types/node": ["@types/node@22.19.15", "", { "dependencies": { "undici-types": "6.21.0" } }, "sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg=="], @@ -332,6 +350,8 @@ "esutils": ["esutils@2.0.3", "", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="], + "eventemitter3": ["eventemitter3@4.0.7", "", {}, "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw=="], + "extend": ["extend@3.0.2", "", {}, "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="], "extract-zip": ["extract-zip@2.0.1", "", { "dependencies": { "debug": "^4.1.1", "get-stream": "^5.1.0", "yauzl": "^2.10.0" }, "optionalDependencies": { "@types/yauzl": "^2.9.1" }, "bin": { "extract-zip": "cli.js" } }, "sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg=="], @@ -386,8 +406,12 @@ "is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="], + "is-network-error": ["is-network-error@1.3.2", "", {}, "sha512-PhBY86zaxNZUuWP6h13Vu5oFe0XY6/UlKzQnYFELzGVHygP3MxmvTfYSG7GN3aIab/iWudSMgjSnG9Dq+nHrgA=="], + "jiti": ["jiti@2.7.0", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ=="], + "js-tiktoken": ["js-tiktoken@1.0.21", "", { "dependencies": { "base64-js": "^1.5.1" } }, "sha512-biOj/6M5qdgx5TKjDnFT1ymSpM5tbd3ylwDtrQvFQSu0Z7bBYko2dF+W/aUkXUPuk6IVpRxk/3Q2sHOzGlS36g=="], + "json-bigint": ["json-bigint@1.0.0", "", { "dependencies": { "bignumber.js": "^9.0.0" } }, "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ=="], "json-schema-to-ts": ["json-schema-to-ts@3.1.1", "", { "dependencies": { "@babel/runtime": "^7.18.3", "ts-algebra": "^2.0.0" } }, "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g=="], @@ -398,6 +422,8 @@ "koffi": ["koffi@2.15.2", "", {}, "sha512-r9tjJLVRSOhCRWdVyQlF3/Ugzeg13jlzS4czS82MAgLff4W+BcYOW7g8Y62t9O5JYjYOLAjAovAZDNlDfZNu+g=="], + "langsmith": ["langsmith@0.7.10", "", { "dependencies": { "p-queue": "6.6.2" }, "peerDependencies": { "@opentelemetry/api": "*", "@opentelemetry/exporter-trace-otlp-proto": "*", "@opentelemetry/sdk-trace-base": "*", "openai": "*", "ws": ">=7" }, "optionalPeers": ["@opentelemetry/api", "@opentelemetry/exporter-trace-otlp-proto", "@opentelemetry/sdk-trace-base", "openai", "ws"] }, "sha512-3EjJx9zGMzqF60eT9JADHF+Hn/T5ayTgEVp4d3M5yvJIJi3q6seX0p5jT8ecBCWBi1kIvvssWrcDxfwgSier7Q=="], + "long": ["long@5.3.2", "", {}, "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA=="], "lru-cache": ["lru-cache@7.18.3", "", {}, "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA=="], @@ -414,6 +440,8 @@ "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + "mustache": ["mustache@4.2.0", "", { "bin": { "mustache": "bin/mustache" } }, "sha512-71ippSywq5Yb7/tVYyGbkBggbU8H3u5Rz56fH60jGFgr8uHwxs+aSKeqmluIVzM0m0kB7xQjKS6qPfd0b2ZoqQ=="], + "mz": ["mz@2.7.0", "", { "dependencies": { "any-promise": "^1.0.0", "object-assign": "^4.0.1", "thenify-all": "^1.0.0" } }, "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q=="], "netmask": ["netmask@2.0.2", "", {}, "sha512-dBpDMdxv9Irdq66304OLfEmQ9tbNRFnFTuZiLo+bD+r332bBmMJ8GBLXklIXXgxd3+v9+KUnZaUR5PJMa75Gsg=="], @@ -432,7 +460,13 @@ "oxlint": ["oxlint@1.65.0", "", { "optionalDependencies": { "@oxlint/binding-android-arm-eabi": "1.65.0", "@oxlint/binding-android-arm64": "1.65.0", "@oxlint/binding-darwin-arm64": "1.65.0", "@oxlint/binding-darwin-x64": "1.65.0", "@oxlint/binding-freebsd-x64": "1.65.0", "@oxlint/binding-linux-arm-gnueabihf": "1.65.0", "@oxlint/binding-linux-arm-musleabihf": "1.65.0", "@oxlint/binding-linux-arm64-gnu": "1.65.0", "@oxlint/binding-linux-arm64-musl": "1.65.0", "@oxlint/binding-linux-ppc64-gnu": "1.65.0", "@oxlint/binding-linux-riscv64-gnu": "1.65.0", "@oxlint/binding-linux-riscv64-musl": "1.65.0", "@oxlint/binding-linux-s390x-gnu": "1.65.0", "@oxlint/binding-linux-x64-gnu": "1.65.0", "@oxlint/binding-linux-x64-musl": "1.65.0", "@oxlint/binding-openharmony-arm64": "1.65.0", "@oxlint/binding-win32-arm64-msvc": "1.65.0", "@oxlint/binding-win32-ia32-msvc": "1.65.0", "@oxlint/binding-win32-x64-msvc": "1.65.0" }, "peerDependencies": { "oxlint-tsgolint": ">=0.22.1" }, "optionalPeers": ["oxlint-tsgolint"], "bin": { "oxlint": "bin/oxlint" } }, "sha512-ChUuE3Q7XnAbscvT4XLMsH7HFJmLgLVv9lu+RRgFL5wSXnDqUOzTp5IS8qWDBGd/ZDSzQ2tbX8fjAmijlGLC7A=="], - "p-retry": ["p-retry@4.6.2", "", { "dependencies": { "@types/retry": "0.12.0", "retry": "^0.13.1" } }, "sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ=="], + "p-finally": ["p-finally@1.0.0", "", {}, "sha512-LICb2p9CB7FS+0eR1oqWnHhp0FljGLZCWBE9aix0Uye9W8LTQPwMTYVGWQWIw9RdQiDg4+epXQODwIYJtSJaow=="], + + "p-queue": ["p-queue@6.6.2", "", { "dependencies": { "eventemitter3": "^4.0.4", "p-timeout": "^3.2.0" } }, "sha512-RwFpb72c/BhQLEXIZ5K2e+AhgNVmIejGlTgiB9MzZ0e93GRvqZ7uSi0dvRF7/XIXDeNkra2fNHBxTyPDGySpjQ=="], + + "p-retry": ["p-retry@7.1.1", "", { "dependencies": { "is-network-error": "^1.1.0" } }, "sha512-J5ApzjyRkkf601HpEeykoiCvzHQjWxPAHhyjFcEUP2SWq0+35NKh8TLhpLw+Dkq5TZBFvUM6UigdE9hIVYTl5w=="], + + "p-timeout": ["p-timeout@3.2.0", "", { "dependencies": { "p-finally": "^1.0.0" } }, "sha512-rhIwUycgwwKcP9yTOOFK/AKsAopjjCakVqLHePO3CC6Mir1Z99xT+R63jZxAT5lFZLa2inS5h+ZS2GvR99/FBg=="], "pac-proxy-agent": ["pac-proxy-agent@7.2.0", "", { "dependencies": { "@tootallnate/quickjs-emscripten": "^0.23.0", "agent-base": "^7.1.2", "debug": "^4.3.4", "get-uri": "^6.0.1", "http-proxy-agent": "^7.0.0", "https-proxy-agent": "^7.0.6", "pac-resolver": "^7.0.1", "socks-proxy-agent": "^8.0.5" } }, "sha512-TEB8ESquiLMc0lV8vcd5Ql/JAKAoyzHFXaStwjkzpOpC5Yv+pIzLfHvjTSdf3vpa2bMiUQrg9i6276yn8666aA=="], @@ -544,6 +578,10 @@ "@aws-crypto/util/@aws-sdk/types": ["@aws-sdk/types@3.973.6", "", { "dependencies": { "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-Atfcy4E++beKtwJHiDln2Nby8W/mam64opFPTiHEqgsthqeydFS1pY+OUlN1ouNOmf8ArPU/6cDS65anOP3KQw=="], + "@google/genai/p-retry": ["p-retry@4.6.2", "", { "dependencies": { "@types/retry": "0.12.0", "retry": "^0.13.1" } }, "sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ=="], + + "@langchain/langgraph-sdk/p-queue": ["p-queue@9.3.0", "", { "dependencies": { "eventemitter3": "^5.0.4", "p-timeout": "^7.0.0" } }, "sha512-7NED7xhQ74Ngp4JP/2e0VZHp7vSWfJfqeiR92jPgxsz6m0Se4P03YoTKa9dDXyZ3r6P616gUXttrB6nnHYKang=="], + "cli-highlight/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], "cliui/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], @@ -552,8 +590,6 @@ "node-fetch/data-uri-to-buffer": ["data-uri-to-buffer@4.0.1", "", {}, "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A=="], - "p-retry/retry": ["retry@0.13.1", "", {}, "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg=="], - "parse5-htmlparser2-tree-adapter/parse5": ["parse5@6.0.1", "", {}, "sha512-Ofn/CTFzRGTTxwpNEs9PP93gXShHcTq255nzRYSKe8AkVpZY7e1fpmTfOyoIvjP5HG7Z2ZM7VS9PPhQGW2pOpw=="], "path-scurry/lru-cache": ["lru-cache@11.2.7", "", {}, "sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA=="], @@ -570,6 +606,12 @@ "@aws-crypto/util/@aws-sdk/types/@smithy/types": ["@smithy/types@4.13.1", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-787F3yzE2UiJIQ+wYW1CVg2odHjmaWLGksnKQHUrK/lYZSEcy1msuLVvxaR/sI2/aDe9U+TBuLsXnr3vod1g0g=="], + "@google/genai/p-retry/retry": ["retry@0.13.1", "", {}, "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg=="], + + "@langchain/langgraph-sdk/p-queue/eventemitter3": ["eventemitter3@5.0.4", "", {}, "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw=="], + + "@langchain/langgraph-sdk/p-queue/p-timeout": ["p-timeout@7.0.1", "", {}, "sha512-AxTM2wDGORHGEkPCt8yqxOTMgpfbEHqF51f/5fJCmwFC3C/zNcGT63SymH2ttOAaiIws2zVg4+izQCjrakcwHg=="], + "cliui/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], "string-width/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], diff --git a/package.json b/package.json index 8a0992d..b2d77a6 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,8 @@ "start": "bun src/index.ts" }, "dependencies": { + "@langchain/core": "^1.2.0", + "@langchain/langgraph": "^1.4.4", "@mariozechner/pi-agent-core": "^0.73.1", "@mariozechner/pi-ai": "^0.73.1", "@mariozechner/pi-coding-agent": "^0.73.1", diff --git a/podman-compose.yaml b/podman-compose.yaml new file mode 100644 index 0000000..4c34966 --- /dev/null +++ b/podman-compose.yaml @@ -0,0 +1,179 @@ +# Make sure to update the credential placeholders with your own secrets. +# We mark them with # CHANGEME in the file below. +# In addition, we recommend to restrict inbound traffic on the host to langfuse-web (port 3000) and minio (port 9090) only. +# All other components are bound to localhost (127.0.0.1) to only accept connections from the local machine. +# External connections from other machines will not be able to reach these services directly. +services: + langfuse-worker: + image: docker.io/langfuse/langfuse-worker:3 + restart: always + depends_on: &langfuse-depends-on + postgres: + condition: service_healthy + minio: + condition: service_healthy + redis: + condition: service_healthy + clickhouse: + condition: service_healthy + ports: + - 127.0.0.1:3030:3030 + environment: &langfuse-worker-env + NEXTAUTH_URL: ${NEXTAUTH_URL:-http://localhost:3000} + DATABASE_URL: ${DATABASE_URL:-postgresql://postgres:postgres@postgres:5432/postgres} # CHANGEME + SALT: ${SALT:-mysalt} # CHANGEME + ENCRYPTION_KEY: ${ENCRYPTION_KEY:-0000000000000000000000000000000000000000000000000000000000000000} # CHANGEME: generate via `openssl rand -hex 32` + TELEMETRY_ENABLED: ${TELEMETRY_ENABLED:-true} + LANGFUSE_ENABLE_EXPERIMENTAL_FEATURES: ${LANGFUSE_ENABLE_EXPERIMENTAL_FEATURES:-false} + CLICKHOUSE_MIGRATION_URL: ${CLICKHOUSE_MIGRATION_URL:-clickhouse://clickhouse:9000} + CLICKHOUSE_URL: ${CLICKHOUSE_URL:-http://clickhouse:8123} + CLICKHOUSE_USER: ${CLICKHOUSE_USER:-clickhouse} + CLICKHOUSE_PASSWORD: ${CLICKHOUSE_PASSWORD:-clickhouse} # CHANGEME + CLICKHOUSE_CLUSTER_ENABLED: ${CLICKHOUSE_CLUSTER_ENABLED:-false} + LANGFUSE_USE_AZURE_BLOB: ${LANGFUSE_USE_AZURE_BLOB:-false} + LANGFUSE_USE_OCI_NATIVE_OBJECT_STORAGE: ${LANGFUSE_USE_OCI_NATIVE_OBJECT_STORAGE:-false} + LANGFUSE_OCI_AUTH_TYPE: ${LANGFUSE_OCI_AUTH_TYPE:-workload_identity} + LANGFUSE_S3_EVENT_UPLOAD_BUCKET: ${LANGFUSE_S3_EVENT_UPLOAD_BUCKET:-langfuse} + LANGFUSE_S3_EVENT_UPLOAD_REGION: ${LANGFUSE_S3_EVENT_UPLOAD_REGION:-auto} + LANGFUSE_S3_EVENT_UPLOAD_ACCESS_KEY_ID: ${LANGFUSE_S3_EVENT_UPLOAD_ACCESS_KEY_ID:-minio} + LANGFUSE_S3_EVENT_UPLOAD_SECRET_ACCESS_KEY: ${LANGFUSE_S3_EVENT_UPLOAD_SECRET_ACCESS_KEY:-miniosecret} # CHANGEME + LANGFUSE_S3_EVENT_UPLOAD_ENDPOINT: ${LANGFUSE_S3_EVENT_UPLOAD_ENDPOINT:-http://minio:9000} + LANGFUSE_S3_EVENT_UPLOAD_FORCE_PATH_STYLE: ${LANGFUSE_S3_EVENT_UPLOAD_FORCE_PATH_STYLE:-true} + LANGFUSE_S3_EVENT_UPLOAD_PREFIX: ${LANGFUSE_S3_EVENT_UPLOAD_PREFIX:-events/} + LANGFUSE_S3_MEDIA_UPLOAD_BUCKET: ${LANGFUSE_S3_MEDIA_UPLOAD_BUCKET:-langfuse} + LANGFUSE_S3_MEDIA_UPLOAD_REGION: ${LANGFUSE_S3_MEDIA_UPLOAD_REGION:-auto} + LANGFUSE_S3_MEDIA_UPLOAD_ACCESS_KEY_ID: ${LANGFUSE_S3_MEDIA_UPLOAD_ACCESS_KEY_ID:-minio} + LANGFUSE_S3_MEDIA_UPLOAD_SECRET_ACCESS_KEY: ${LANGFUSE_S3_MEDIA_UPLOAD_SECRET_ACCESS_KEY:-miniosecret} # CHANGEME + LANGFUSE_S3_MEDIA_UPLOAD_ENDPOINT: ${LANGFUSE_S3_MEDIA_UPLOAD_ENDPOINT:-http://localhost:9090} + LANGFUSE_S3_MEDIA_UPLOAD_FORCE_PATH_STYLE: ${LANGFUSE_S3_MEDIA_UPLOAD_FORCE_PATH_STYLE:-true} + LANGFUSE_S3_MEDIA_UPLOAD_PREFIX: ${LANGFUSE_S3_MEDIA_UPLOAD_PREFIX:-media/} + LANGFUSE_S3_BATCH_EXPORT_ENABLED: ${LANGFUSE_S3_BATCH_EXPORT_ENABLED:-false} + LANGFUSE_S3_BATCH_EXPORT_BUCKET: ${LANGFUSE_S3_BATCH_EXPORT_BUCKET:-langfuse} + LANGFUSE_S3_BATCH_EXPORT_PREFIX: ${LANGFUSE_S3_BATCH_EXPORT_PREFIX:-exports/} + LANGFUSE_S3_BATCH_EXPORT_REGION: ${LANGFUSE_S3_BATCH_EXPORT_REGION:-auto} + LANGFUSE_S3_BATCH_EXPORT_ENDPOINT: ${LANGFUSE_S3_BATCH_EXPORT_ENDPOINT:-http://minio:9000} + LANGFUSE_S3_BATCH_EXPORT_EXTERNAL_ENDPOINT: ${LANGFUSE_S3_BATCH_EXPORT_EXTERNAL_ENDPOINT:-http://localhost:9090} + LANGFUSE_S3_BATCH_EXPORT_ACCESS_KEY_ID: ${LANGFUSE_S3_BATCH_EXPORT_ACCESS_KEY_ID:-minio} + LANGFUSE_S3_BATCH_EXPORT_SECRET_ACCESS_KEY: ${LANGFUSE_S3_BATCH_EXPORT_SECRET_ACCESS_KEY:-miniosecret} # CHANGEME + LANGFUSE_S3_BATCH_EXPORT_FORCE_PATH_STYLE: ${LANGFUSE_S3_BATCH_EXPORT_FORCE_PATH_STYLE:-true} + LANGFUSE_INGESTION_QUEUE_DELAY_MS: ${LANGFUSE_INGESTION_QUEUE_DELAY_MS:-} + LANGFUSE_INGESTION_CLICKHOUSE_WRITE_INTERVAL_MS: ${LANGFUSE_INGESTION_CLICKHOUSE_WRITE_INTERVAL_MS:-} + REDIS_HOST: ${REDIS_HOST:-redis} + REDIS_PORT: ${REDIS_PORT:-6379} + REDIS_AUTH: ${REDIS_AUTH:-myredissecret} # CHANGEME + LANGFUSE_BULLMQ_SKIP_REDIS_VERSION_CHECK: ${LANGFUSE_BULLMQ_SKIP_REDIS_VERSION_CHECK:-false} + REDIS_TLS_ENABLED: ${REDIS_TLS_ENABLED:-false} + REDIS_TLS_CA: ${REDIS_TLS_CA:-/certs/ca.crt} + REDIS_TLS_CERT: ${REDIS_TLS_CERT:-/certs/redis.crt} + REDIS_TLS_KEY: ${REDIS_TLS_KEY:-/certs/redis.key} + EMAIL_FROM_ADDRESS: ${EMAIL_FROM_ADDRESS:-} + SMTP_CONNECTION_URL: ${SMTP_CONNECTION_URL:-} + + langfuse-web: + image: docker.io/langfuse/langfuse:3 + restart: always + depends_on: *langfuse-depends-on + ports: + - 3000:3000 + environment: + <<: *langfuse-worker-env + NEXTAUTH_SECRET: ${NEXTAUTH_SECRET:-mysecret} # CHANGEME + LANGFUSE_INIT_ORG_ID: ${LANGFUSE_INIT_ORG_ID:-} + LANGFUSE_INIT_ORG_NAME: ${LANGFUSE_INIT_ORG_NAME:-} + LANGFUSE_INIT_PROJECT_ID: ${LANGFUSE_INIT_PROJECT_ID:-} + LANGFUSE_INIT_PROJECT_NAME: ${LANGFUSE_INIT_PROJECT_NAME:-} + LANGFUSE_INIT_PROJECT_PUBLIC_KEY: ${LANGFUSE_INIT_PROJECT_PUBLIC_KEY:-} + LANGFUSE_INIT_PROJECT_SECRET_KEY: ${LANGFUSE_INIT_PROJECT_SECRET_KEY:-} + LANGFUSE_INIT_USER_EMAIL: ${LANGFUSE_INIT_USER_EMAIL:-} + LANGFUSE_INIT_USER_NAME: ${LANGFUSE_INIT_USER_NAME:-} + LANGFUSE_INIT_USER_PASSWORD: ${LANGFUSE_INIT_USER_PASSWORD:-} + + clickhouse: + image: docker.io/clickhouse/clickhouse-server + restart: always + user: "101:101" + environment: + CLICKHOUSE_DB: default + CLICKHOUSE_USER: ${CLICKHOUSE_USER:-clickhouse} + CLICKHOUSE_PASSWORD: ${CLICKHOUSE_PASSWORD:-clickhouse} # CHANGEME + volumes: + - langfuse_clickhouse_data:/var/lib/clickhouse + - langfuse_clickhouse_logs:/var/log/clickhouse-server + ports: + - 127.0.0.1:8123:8123 + - 127.0.0.1:9000:9000 + healthcheck: + test: wget --no-verbose --tries=1 --spider http://localhost:8123/ping || exit 1 + interval: 5s + timeout: 5s + retries: 10 + start_period: 1s + + minio: + image: cgr.dev/chainguard/minio + restart: always + entrypoint: sh + # create the 'langfuse' bucket before starting the service + command: -c 'mkdir -p /data/langfuse && minio server --address ":9000" + --console-address ":9001" /data' + environment: + MINIO_ROOT_USER: ${MINIO_ROOT_USER:-minio} + MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:-miniosecret} # CHANGEME + ports: + - 9090:9000 + - 127.0.0.1:9091:9001 + volumes: + - langfuse_minio_data:/data + healthcheck: + test: ["CMD", "mc", "ready", "local"] + interval: 1s + timeout: 5s + retries: 5 + start_period: 1s + + redis: + image: docker.io/redis:7 + restart: always + # CHANGEME: row below to secure redis password + command: > + --requirepass ${REDIS_AUTH:-myredissecret} --maxmemory-policy noeviction + ports: + - 127.0.0.1:6379:6379 + volumes: + - langfuse_redis_data:/data + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 3s + timeout: 10s + retries: 10 + + postgres: + image: docker.io/postgres:${POSTGRES_VERSION:-17} + restart: always + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres"] + interval: 3s + timeout: 3s + retries: 10 + environment: + POSTGRES_USER: ${POSTGRES_USER:-postgres} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-postgres} # CHANGEME + POSTGRES_DB: ${POSTGRES_DB:-postgres} + TZ: UTC + PGTZ: UTC + ports: + - 127.0.0.1:5432:5432 + volumes: + - langfuse_postgres_data:/var/lib/postgresql/data + +volumes: + langfuse_postgres_data: + driver: local + langfuse_clickhouse_data: + driver: local + langfuse_clickhouse_logs: + driver: local + langfuse_minio_data: + driver: local + langfuse_redis_data: + driver: local diff --git a/src/__tests__/langgraph-parity.spec.ts b/src/__tests__/langgraph-parity.spec.ts new file mode 100644 index 0000000..1573b22 --- /dev/null +++ b/src/__tests__/langgraph-parity.spec.ts @@ -0,0 +1,360 @@ +import { describe, it, expect, mock, beforeEach, afterAll } from 'bun:test'; +import { + mockSpawnAgent, + mockRunCommand, + mockWriteRunMetrics, + mockGetCurrentPromptVersions, + mockFindPriorRunId, + mockGatherSessionContext, + mockAnalyzeFailure, +} from './mocks.js'; +import type { AgentResult, PipelineConfig, TaskJson } from '../types.js'; +import { mkdir, rm } from 'node:fs/promises'; +import { join } from 'node:path'; + +/** + * Phase 1.1 acceptance: a run through the LangGraph engine (`CASE_ENGINE=langgraph`) + * produces the *same phase outcomes* as the legacy DAG executor, over an identical + * mock runtime. Each case runs both engines against the same queued spawn results + * and asserts the `notifier.phaseEnd(phase, …, outcome)` sequence matches — and + * matches an explicit expected sequence (so the parity is pinned, not just mutual). + */ + +// --- Pipeline-specific mocks (mirror pipeline.spec) --- +const mockStoreRead = mock(); +const mockStoreSetPendingRevision = mock(); +const mockStoreWriteFromProjection = mock(); +const MockTaskStore = mock(() => ({ + read: mockStoreRead, + readStatus: mock(() => Promise.resolve('active')), + setStatus: mock(() => Promise.resolve(undefined)), + setAgentPhase: mock(() => Promise.resolve(undefined)), + setField: mock(() => Promise.resolve(undefined)), + setPendingRevision: mockStoreSetPendingRevision, + writeFromProjection: mockStoreWriteFromProjection, +})); + +mock.module('../state/task-store.js', () => ({ TaskStore: MockTaskStore })); +mock.module('../notify.js', () => ({ + createNotifier: mock(), + formatDuration: (ms: number) => `${Math.floor(ms / 1000)}s`, + defaultAskUser: async (_mode: unknown, _prompt: string, options: string[]) => options[options.length - 1], +})); + +const { runPipeline } = await import('../pipeline.js'); + +const tempCaseRoot = join(process.env.TMPDIR ?? '/tmp', `case-langgraph-parity-${Date.now()}`); + +async function setupTempFiles() { + const agentsDir = join(tempCaseRoot, 'agents'); + await mkdir(agentsDir, { recursive: true }); + await mkdir(join(tempCaseRoot, '.case'), { recursive: true }); + for (const agent of ['scout', 'implementer', 'verifier', 'reviewer', 'closer', 'retrospective']) { + await Bun.write(join(agentsDir, `${agent}.md`), `# ${agent}`); + } +} + +const mockRuntime = { + spawn: (options: unknown) => mockSpawnAgent(options), + createTools: () => [], + abort: () => {}, +}; + +/** A notifier that records the (phase, outcome) of every phaseEnd. */ +function capturingNotifier(seq: string[]) { + return { + send: mock(), + askUser: mock(async (_p: string, options: string[]) => options[options.length - 1]), + phaseStart: mock(), + phaseEnd: mock((phase: string, _agent: string, _elapsed: number, outcome: string) => { + seq.push(`${phase}:${outcome}`); + }), + toolStart: mock(), + toolEnd: mock(), + stepIndicator: mock(), + startHeartbeat: mock(), + stopHeartbeat: mock(), + }; +} + +const mockTask: TaskJson = { + id: 'cli-1', + status: 'active', + created: '2026-03-14T00:00:00Z', + repo: 'cli', + agents: {}, + tested: false, + manualTested: false, + prUrl: null, + prNumber: null, +}; + +function makeConfig(overrides: Partial = {}): PipelineConfig { + return { + mode: 'attended', + taskId: 'cli-1', + tdId: 'td-test1', + repoPath: tempCaseRoot, + repoName: 'cli', + packageRoot: tempCaseRoot, + dataDir: tempCaseRoot, + maxRetries: 1, + dryRun: false, + runtime: mockRuntime as never, + ...overrides, + }; +} + +const completed: AgentResult = { + status: 'completed', + summary: 'Done', + artifacts: { + commit: 'abc', + filesChanged: [], + testsPassed: true, + screenshotUrls: [], + evidenceMarkers: [], + prUrl: null, + prNumber: null, + }, + error: null, +}; + +const prResult: AgentResult = { + ...completed, + summary: 'PR created', + artifacts: { ...completed.artifacts, prUrl: 'https://github.com/workos/cli/pull/42', prNumber: 42 }, +}; + +const verifierFail: AgentResult = { + ...completed, + rubric: { + role: 'verifier', + categories: [{ category: 'edge-case-checked', verdict: 'fail', detail: 'missing null check' }], + }, +}; + +const reviewerSoftFail: AgentResult = { + ...completed, + rubric: { + role: 'reviewer', + categories: [ + { category: 'principle-compliance', verdict: 'pass', detail: 'OK' }, + { category: 'test-sufficiency', verdict: 'fail', detail: 'needs tests' }, + { category: 'scope-discipline', verdict: 'pass', detail: 'OK' }, + { category: 'pattern-fit', verdict: 'pass', detail: 'OK' }, + ], + }, +}; + +function agentRaw(result: AgentResult): string { + return `\n<<>>\n`; +} +function spawn(result: AgentResult) { + return { raw: agentRaw(result), result, durationMs: 100 }; +} +const scoutResult: AgentResult = { + ...completed, + summary: 'Scout found 0 relevant files', + findings: { relevantFiles: [], patterns: [], constraints: [] } as never, +}; + +type SpawnSpec = ReturnType; + +/** Run one pipeline through the chosen engine and return the phaseEnd sequence. */ +async function runEngine( + engine: 'legacy' | 'langgraph', + specs: SpawnSpec[], + overrides: Partial = {}, +): Promise { + mockSpawnAgent.mockReset(); + for (const s of specs) mockSpawnAgent.mockResolvedValueOnce(s); + + const seq: string[] = []; + const notifier = capturingNotifier(seq); + + const prev = process.env.CASE_ENGINE; + if (engine === 'langgraph') process.env.CASE_ENGINE = 'langgraph'; + else delete process.env.CASE_ENGINE; + try { + await runPipeline(makeConfig({ notifier: notifier as never, ...overrides })); + } finally { + if (prev === undefined) delete process.env.CASE_ENGINE; + else process.env.CASE_ENGINE = prev; + } + return seq; +} + +/** Run a case through both engines, assert they match each other and `expected`. */ +async function assertParity( + specs: SpawnSpec[], + expected: string[], + overrides: Partial = {}, +): Promise { + const legacy = await runEngine('legacy', specs, overrides); + const langgraph = await runEngine('langgraph', specs, overrides); + expect(legacy).toEqual(expected); + expect(langgraph).toEqual(expected); +} + +describe('LangGraph ↔ legacy executor parity', () => { + beforeEach(async () => { + mockSpawnAgent.mockReset(); + mockRunCommand.mockReset(); + mockWriteRunMetrics.mockReset(); + mockGetCurrentPromptVersions.mockReset(); + mockFindPriorRunId.mockReset(); + mockStoreRead.mockReset(); + mockStoreSetPendingRevision.mockReset(); + + mockStoreRead.mockResolvedValue(mockTask); + mockStoreSetPendingRevision.mockResolvedValue(undefined); + mockRunCommand.mockResolvedValue({ stdout: '{}', stderr: '', exitCode: 0 }); + mockGatherSessionContext.mockReset(); + mockGatherSessionContext.mockResolvedValue({}); + mockAnalyzeFailure.mockReset(); + mockAnalyzeFailure.mockResolvedValue({ + failureClass: 'unknown', + failedAgent: 'implementer', + errorSummary: 'error', + filesInvolved: [], + whatWasTried: [], + suggestedFocus: 'try again', + retryViable: true, + }); + mockWriteRunMetrics.mockResolvedValue(undefined); + mockGetCurrentPromptVersions.mockResolvedValue({}); + mockFindPriorRunId.mockResolvedValue(null); + + await setupTempFiles(); + }); + + afterAll(async () => { + await rm(tempCaseRoot, { recursive: true, force: true }); + }); + + it('standard profile happy path', async () => { + await assertParity( + [spawn(scoutResult), spawn(completed), spawn(completed), spawn(completed), spawn(prResult), spawn(completed)], + [ + 'scout:completed', + 'implement:completed', + 'verify:completed', + 'review:completed', + 'close:completed', + 'retrospective:completed', + ], + ); + }); + + it('tiny profile skips scout + verify', async () => { + mockStoreRead.mockResolvedValue({ ...mockTask, profile: 'tiny' as const }); + await assertParity( + [spawn(completed), spawn(completed), spawn(prResult), spawn(completed)], + ['implement:completed', 'review:completed', 'close:completed', 'retrospective:completed'], + ); + }); + + it('verifier revision cycle (verify fails once, then clean)', async () => { + await assertParity( + [ + spawn(scoutResult), // scout + spawn(completed), // implement c0 + spawn(verifierFail), // verify c0 → revision + spawn(completed), // implement c1 + spawn(completed), // verify c1 clean + spawn(completed), // review + spawn(prResult), // close + spawn(completed), // retrospective + ], + [ + 'scout:completed', + 'implement:completed', + 'verify:completed', + 'implement:completed', + 'verify:completed', + 'review:completed', + 'close:completed', + 'retrospective:completed', + ], + ); + }); + + it('reviewer soft-fail revision cycle', async () => { + await assertParity( + [ + spawn(scoutResult), // scout + spawn(completed), // implement c0 + spawn(completed), // verify c0 clean + spawn(reviewerSoftFail), // review c0 → revision + spawn(completed), // implement c1 + spawn(completed), // verify c1 + spawn(completed), // review c1 clean + spawn(prResult), // close + spawn(completed), // retrospective + ], + [ + 'scout:completed', + 'implement:completed', + 'verify:completed', + 'review:completed', + 'implement:completed', + 'verify:completed', + 'review:completed', + 'close:completed', + 'retrospective:completed', + ], + ); + }); + + it('revision budget exhausted (maxRevisionCycles=1)', async () => { + await assertParity( + [ + spawn(scoutResult), // scout + spawn(completed), // implement c0 + spawn(verifierFail), // verify c0 → revision (cycle 1) + spawn(completed), // implement c1 + spawn(completed), // verify c1 clean + spawn(reviewerSoftFail), // review c1 soft-fail → budget exhausted → close + spawn(prResult), // close + spawn(completed), // retrospective + ], + [ + 'scout:completed', + 'implement:completed', + 'verify:completed', + 'implement:completed', + 'verify:completed', + 'review:completed', + 'close:completed', + 'retrospective:completed', + ], + { maxRevisionCycles: 1 }, + ); + }); + + it('fingerprint short-circuit (identical failure two cycles running)', async () => { + await assertParity( + [ + spawn(scoutResult), // scout + spawn(completed), // implement c0 + spawn(verifierFail), // verify c0 fail → revision (cycle 1) + spawn(completed), // implement c1 + spawn(verifierFail), // verify c1 same failure → fingerprint match → revision denied + spawn(completed), // review c1 (trailing review still runs, can't re-revise) + spawn(prResult), // close + spawn(completed), // retrospective + ], + [ + 'scout:completed', + 'implement:completed', + 'verify:completed', + 'implement:completed', + 'verify:completed', + 'review:completed', + 'close:completed', + 'retrospective:completed', + ], + ); + }); +}); diff --git a/src/dev/run-tests.ts b/src/dev/run-tests.ts index 800dd45..f22f5c4 100644 --- a/src/dev/run-tests.ts +++ b/src/dev/run-tests.ts @@ -1,6 +1,52 @@ +import { Glob } from 'bun'; +import { repoRoot } from './ast-grep.js'; import { runSequence } from './run-sequence.js'; -await runSequence([ - { label: 'unit tests', args: ['bun', 'test', './src/__tests__/'] }, - { label: 'standalone tests', args: ['bun', 'test', '--cwd', 'test/standalone'] }, -]); +/** + * Unit specs are run **one file per process**. Several specs install + * process-global module mocks at top level via Bun's `mock.module()` (e.g. + * `pipeline.spec` mocks `state/task-store`, `pipeline-tool.spec` mocks + * `pipeline.js`). Bun applies those mocks at file-load for the whole process + * and never tears them down, so loading every spec into a single `bun test` + * run cross-contaminates unrelated files. Isolating each file sidesteps the + * leakage without forcing every spec to hand-roll mock teardown. + */ +const CONCURRENCY = 8; + +async function runIsolatedSpecs(files: string[]): Promise { + let next = 0; + const failures: string[] = []; + + async function worker(): Promise { + while (next < files.length) { + const file = files[next++]; + const proc = Bun.spawn(['bun', 'test', file], { cwd: repoRoot, stdout: 'pipe', stderr: 'pipe' }); + const [stdout, stderr, exitCode] = await Promise.all([ + new Response(proc.stdout).text(), + new Response(proc.stderr).text(), + proc.exited, + ]); + if (exitCode === 0) { + process.stdout.write(`✓ ${file}\n`); + } else { + failures.push(file); + process.stdout.write(`\n✗ ${file}\n${stderr || stdout}\n`); + } + } + } + + await Promise.all(Array.from({ length: Math.min(CONCURRENCY, files.length) }, worker)); + + if (failures.length > 0) { + process.stdout.write(`\n${failures.length} test file(s) failed:\n${failures.map((f) => ` - ${f}`).join('\n')}\n`); + process.exit(1); + } +} + +const glob = new Glob('src/__tests__/**/*.spec.ts'); +const specFiles = (await Array.fromAsync(glob.scan({ cwd: repoRoot }))).sort(); + +process.stdout.write(`\n=== unit tests (${specFiles.length} files, isolated) ===\n`); +await runIsolatedSpecs(specFiles); + +await runSequence([{ label: 'standalone tests', args: ['bun', 'test', '--cwd', 'test/standalone'] }]); diff --git a/src/langgraph/engine.ts b/src/langgraph/engine.ts new file mode 100644 index 0000000..5b318e9 --- /dev/null +++ b/src/langgraph/engine.ts @@ -0,0 +1,310 @@ +import { StateGraph, START, END } from '@langchain/langgraph'; +import type { AgentName, AgentResult, PipelinePhase, PipelineProfile, RevisionRequest, TaskStatus } from '../types.js'; +import { PROFILE_PHASES } from '../types.js'; +import type { Notifier } from '../notify.js'; +import type { EventAppender } from '../events/appender.js'; +import type { DispatchNodeRef } from '../pipeline-dispatch.js'; +import { computeFingerprint, fingerprintsMatch } from '../dag/fingerprint.js'; +import { mergeRevisionRequests } from '../dag/merge.js'; +import { createLogger } from '../util/logger.js'; +import { CaseGraphState, type CaseGraphStateType } from './state.js'; + +const log = createLogger(); + +export type DispatchFn = (node: DispatchNodeRef, revision?: RevisionRequest) => Promise; + +export interface LangGraphEngineArgs { + profile: PipelineProfile; + maxRevisionCycles: number; + appender: EventAppender; + notifier: Notifier; + /** Bound per-phase dispatcher (same closure the legacy executor uses). */ + dispatch: DispatchFn; + /** + * Mark the run failed on the shared pipeline closure (sets outcome + + * failedAgent). Called whenever a dispatched phase returns a non-completed + * result — mirrors the legacy executor's end-of-run failed-node scan. + */ + onPhaseFailed: (agent: AgentName) => void; + /** Seed from a td-persisted pending revision (resume-at-implement). */ + initialPendingRevision?: RevisionRequest | null; +} + +/** Maps a running phase to the TaskStatus the td mirror should show. */ +function phaseStatus(phase: PipelinePhase, state: CaseGraphStateType): TaskStatus | null { + switch (phase) { + case 'implement': + return 'implementing'; + case 'verify': + return 'verifying'; + case 'review': + return 'reviewing'; + case 'close': + return 'closing'; + case 'retrospective': + // After a successful close, the PR is open while the retrospective runs. + return state.last?.phase === 'close' && state.last.status === 'completed' ? 'pr-opened' : null; + default: + // scout has no dedicated status — the run stays `active`. + return null; + } +} + +function rubricFailed(result: AgentResult): boolean { + return result.rubric?.categories.some((c) => c.verdict === 'fail') ?? false; +} + +/** + * Derive a fingerprint from a single evaluator's revision request. Mirrors the + * legacy executor's `computeFingerprintFromRequests` (returns undefined when + * there are no failed categories to hash). + */ +function fingerprintFor(request: RevisionRequest): string | undefined { + const failedCategories = request.failedCategories.map((c) => c.category); + if (failedCategories.length === 0) return undefined; + return computeFingerprint({ failedCategories, errorSummary: request.summary ?? '' }); +} + +/** + * Run a Case pipeline through a LangGraph `StateGraph`. Drives the same + * scout → implement → verify → review → close → retrospective flow (with the + * revision loop, fingerprint short-circuit, and revision-budget cap) as the + * legacy DAG executor, emitting the identical event stream through the shared + * `EventAppender` so td-status, evidence markers, metrics, and `runs.jsonl` + * stay correct. + */ +export async function executeLangGraph(args: LangGraphEngineArgs): Promise { + const { appender, notifier, dispatch, onPhaseFailed, maxRevisionCycles } = args; + const phases = PROFILE_PHASES[args.profile]; + const hasScout = phases.includes('scout'); + const hasVerify = phases.includes('verify'); + + let currentStatus: TaskStatus = appender.getState().status; + + async function emitStatus(phase: PipelinePhase, state: CaseGraphStateType): Promise { + const next = phaseStatus(phase, state); + if (!next || next === currentStatus) return; + await appender.append({ event: 'status_changed', from: currentStatus, to: next }); + currentStatus = next; + } + + /** Shared per-phase wrapper: events + notifier + heartbeat around dispatch. */ + async function runPhase( + phase: PipelinePhase, + agent: AgentName | 'retrospective', + state: CaseGraphStateType, + revision?: RevisionRequest, + ): Promise { + const startedAt = new Date().toISOString(); + await appender.append({ event: 'phase_start', phase, agent }); + notifier.phaseStart(phase, agent); + await emitStatus(phase, state); + + notifier.startHeartbeat(); + let result: AgentResult; + try { + result = await dispatch({ phase, startedAt }, revision); + } finally { + notifier.stopHeartbeat(); + } + + const elapsed = Date.now() - Date.parse(startedAt); + const outcome = result.status === 'completed' ? 'completed' : 'failed'; + await appender.append({ event: 'phase_end', phase, agent, outcome, durationMs: elapsed, result }); + notifier.phaseEnd(phase, agent, elapsed, outcome); + if (outcome === 'failed' && agent !== 'retrospective') onPhaseFailed(agent); + return result; + } + + // --- nodes ------------------------------------------------------------- + + async function scoutNode(state: CaseGraphStateType): Promise> { + const result = await runPhase('scout', 'scout', state); + return { + last: { phase: 'scout', status: result.status === 'completed' ? 'completed' : 'failed', rubricFailed: false }, + }; + } + + async function implementNode(state: CaseGraphStateType): Promise> { + const result = await runPhase('implement', 'implementer', state, state.pendingRevision ?? undefined); + return { + last: { phase: 'implement', status: result.status === 'completed' ? 'completed' : 'failed', rubricFailed: false }, + pendingRevision: null, + }; + } + + function evaluatorNode(phase: 'verify' | 'review', agent: AgentName) { + return async (state: CaseGraphStateType): Promise> => { + const result = await runPhase(phase, agent, state); + const failed = result.status !== 'completed'; + const failedRubric = !failed && rubricFailed(result); + return { + last: { phase, status: failed ? 'failed' : 'completed', rubricFailed: failedRubric }, + evaluator: failedRubric ? { phase, result } : null, + }; + }; + } + + /** + * Revision decision node. Reads the failing evaluator's output and decides + * whether to spend another implement cycle or close with warnings. Mirrors + * the legacy executor's `handleEvaluatorPairCompletion` for the sequential + * (one-evaluator-per-cycle) case the tiny/standard profiles exercise. + */ + async function reviseNode(state: CaseGraphStateType): Promise> { + const slot = state.evaluator; + if (!slot) { + // Defensive: no evaluator output to act on — close out. + return { decision: 'close' }; + } + const c = state.cycle; + const source: 'verifier' | 'reviewer' = slot.phase === 'verify' ? 'verifier' : 'reviewer'; + const request: RevisionRequest = { + source, + failedCategories: slot.result.rubric!.categories.filter((cat) => cat.verdict === 'fail'), + summary: slot.result.summary, + suggestedFocus: slot.result.artifacts?.filesChanged ?? [], + cycle: c + 1, + }; + const fingerprint = fingerprintFor(request); + + // When revision is denied, the legacy executor still runs the *current* + // cycle's review (if the trigger was a verify failure) before closing — + // skipping the next cycle unblocks the verify→review edge. A review trigger + // means review already ran, so close directly. + const denied: Partial = { + decision: slot.phase === 'verify' ? 'review' : 'close', + revisionClosed: true, + }; + + // Revision budget: implement nodes exist for cycles 0..maxRevisionCycles, so + // a next cycle is available iff c + 1 <= maxRevisionCycles. + if (c + 1 > maxRevisionCycles) { + await appender.append({ event: 'revision_budget_exhausted', cycles: c + 1 }); + notifier.send( + `Revision budget exhausted after cycle ${c}. ${source} found issues but no revision cycles remain. Proceeding with warnings.`, + ); + return denied; + } + + // Fingerprint short-circuit: the same failure signature two cycles running + // is unlikely to clear with another pass. + const previousFingerprint = c - 1 >= 0 ? state.fingerprints[c - 1] : undefined; + if (fingerprint && previousFingerprint && fingerprintsMatch(fingerprint, previousFingerprint)) { + await appender.append({ event: 'fingerprint_match', cycle: c + 1, fingerprint, previousCycle: c - 1 }); + await appender.append({ event: 'revision_budget_exhausted', cycles: c + 1 }); + notifier.send( + `Revision budget exhausted: fingerprint match (cycle ${c} matched cycle ${c - 1}, ${fingerprint}). Aborting revision cycle ${c + 1} and proceeding with warnings.`, + ); + return { ...denied, fingerprints: { [c]: fingerprint } }; + } + + const merged = mergeRevisionRequests([request]); + if (fingerprint) merged.fingerprint = fingerprint; + await appender.append({ + event: 'revision_requested', + source: merged.source, + cycle: c + 1, + failedCategories: merged.failedCategories, + }); + notifier.send(`Revision cycle ${c + 1}: ${source} found fixable issues, re-implementing`); + + return { + decision: 'implement', + pendingRevision: merged, + cycle: c + 1, + revisionCycles: c + 1, + evaluator: null, + ...(fingerprint ? { fingerprints: { [c]: fingerprint } } : {}), + }; + } + + async function closeNode(state: CaseGraphStateType): Promise> { + const result = await runPhase('close', 'closer', state); + return { + last: { phase: 'close', status: result.status === 'completed' ? 'completed' : 'failed', rubricFailed: false }, + }; + } + + async function retrospectiveNode(state: CaseGraphStateType): Promise> { + await runPhase('retrospective', 'retrospective', state); + return {}; + } + + // --- routers ----------------------------------------------------------- + + const entry = (state: CaseGraphStateType): string => + state.pendingRevision ? 'implement' : hasScout ? 'scout' : 'implement'; + + const afterImplement = (state: CaseGraphStateType): string => { + if (state.last?.status === 'failed') return 'retrospective'; + return hasVerify ? 'verify' : 'review'; + }; + + const afterVerify = (state: CaseGraphStateType): string => { + if (state.last?.status === 'failed') return 'retrospective'; + return state.last?.rubricFailed ? 'revise' : 'review'; + }; + + const afterReview = (state: CaseGraphStateType): string => { + if (state.last?.status === 'failed') return 'retrospective'; + // A trailing review after revision was denied can no longer revise. + if (state.revisionClosed) return 'close'; + return state.last?.rubricFailed ? 'revise' : 'close'; + }; + + const afterRevise = (state: CaseGraphStateType): string => state.decision ?? 'close'; + + // --- graph assembly ---------------------------------------------------- + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const g = new StateGraph(CaseGraphState) as any; + + if (hasScout) g.addNode('scout', scoutNode); + g.addNode('implement', implementNode); + if (hasVerify) g.addNode('verify', evaluatorNode('verify', 'verifier')); + g.addNode('review', evaluatorNode('review', 'reviewer')); + g.addNode('revise', reviseNode); + g.addNode('close', closeNode); + g.addNode('retrospective', retrospectiveNode); + + const entryTargets = hasScout ? { scout: 'scout', implement: 'implement' } : { implement: 'implement' }; + g.addConditionalEdges(START, entry, entryTargets); + if (hasScout) g.addEdge('scout', 'implement'); + + g.addConditionalEdges( + 'implement', + afterImplement, + hasVerify + ? { verify: 'verify', retrospective: 'retrospective' } + : { review: 'review', retrospective: 'retrospective' }, + ); + if (hasVerify) { + g.addConditionalEdges('verify', afterVerify, { + review: 'review', + revise: 'revise', + retrospective: 'retrospective', + }); + } + g.addConditionalEdges('review', afterReview, { + close: 'close', + revise: 'revise', + retrospective: 'retrospective', + }); + g.addConditionalEdges('revise', afterRevise, { implement: 'implement', review: 'review', close: 'close' }); + g.addEdge('close', 'retrospective'); + g.addEdge('retrospective', END); + + const compiled = g.compile(); + + const seed = args.initialPendingRevision; + const initial: Partial = seed + ? { pendingRevision: seed, cycle: seed.cycle ?? 1, revisionCycles: seed.cycle ?? 1 } + : {}; + + log.info('langgraph engine started', { profile: args.profile, maxRevisionCycles, seeded: Boolean(seed) }); + + // recursionLimit as a runaway backstop only (RFC §5 decision 4); the explicit + // revision-budget cap is the real guard. + await compiled.invoke(initial, { recursionLimit: (maxRevisionCycles + 2) * 8 }); +} diff --git a/src/langgraph/state.ts b/src/langgraph/state.ts new file mode 100644 index 0000000..249af65 --- /dev/null +++ b/src/langgraph/state.ts @@ -0,0 +1,67 @@ +import { Annotation } from '@langchain/langgraph'; +import type { AgentResult, PipelinePhase, RevisionRequest } from '../types.js'; + +/** + * The phase that just completed, plus the routing-relevant facts the + * conditional edges key off. `status` mirrors the legacy executor's node + * state (`completed` iff the agent returned `status: 'completed'`); + * `rubricFailed` is true when an evaluator returned a rubric with ≥1 `fail` + * verdict (the revision trigger). + */ +export interface LastPhase { + phase: PipelinePhase; + status: 'completed' | 'failed'; + rubricFailed: boolean; +} + +/** The failing evaluator's output, handed to the `revise` node. */ +export interface EvaluatorSlot { + phase: 'verify' | 'review'; + result: AgentResult; +} + +const replace = () => ({ reducer: (_a: T, b: T) => b }); + +/** + * LangGraph state channels for a Case run. These hold *orchestration* state + * only (cycle counters, the pending revision, per-cycle fingerprints, routing + * breadcrumbs). Agent context (scout findings, previousResults) and run-level + * outcome stay in the shared pipeline closure exactly as the legacy executor + * keeps them, so per-phase semantics are identical across engines. + * + * In Phase 1.2 this is what the SQLite checkpointer snapshots for resume. + */ +export const CaseGraphState = Annotation.Root({ + /** 0-based implement/verify/review cycle currently in flight. */ + cycle: Annotation({ ...replace(), default: () => 0 }), + /** Number of revision cycles taken (mirrors `PipelineState.revisionCycles`). */ + revisionCycles: Annotation({ ...replace(), default: () => 0 }), + /** Revision to apply on the next implement, or null. Cleared once consumed. */ + pendingRevision: Annotation({ + ...replace(), + default: () => null, + }), + /** Per-cycle failure fingerprints, keyed by the cycle that produced them. */ + fingerprints: Annotation>({ + reducer: (a, b) => ({ ...a, ...b }), + default: () => ({}), + }), + /** The phase that just ran (drives conditional edges). */ + last: Annotation({ ...replace(), default: () => null }), + /** The evaluator output awaiting a revision decision, or null. */ + evaluator: Annotation({ ...replace(), default: () => null }), + /** `revise` node's verdict: re-implement, run the trailing review, or close. */ + decision: Annotation<'implement' | 'review' | 'close' | null>({ + ...replace<'implement' | 'review' | 'close' | null>(), + default: () => null, + }), + /** + * Set once revision is denied (budget exhausted / fingerprint match). Mirrors + * the legacy executor: a denied *verify* failure still runs the current + * cycle's review before closing, but that review can no longer trigger a + * revision — this flag forces the post-review edge straight to `close`. + */ + revisionClosed: Annotation({ ...replace(), default: () => false }), +}); + +export type CaseGraphStateType = typeof CaseGraphState.State; diff --git a/src/pipeline-dispatch.ts b/src/pipeline-dispatch.ts new file mode 100644 index 0000000..5c7c9f8 --- /dev/null +++ b/src/pipeline-dispatch.ts @@ -0,0 +1,220 @@ +import type { AgentName, AgentResult, PipelineConfig, PipelinePhase, RevisionRequest, ScoutFindings } from './types.js'; +import type { TaskStore } from './state/task-store.js'; +import type { Notifier } from './notify.js'; +import { runImplementPhase } from './phases/implement.js'; +import { runScoutPhase } from './phases/scout.js'; +import { runVerifyPhase } from './phases/verify.js'; +import { runReviewPhase } from './phases/review.js'; +import { runClosePhase } from './phases/close.js'; +import { runRetrospectivePhase, type MetricsSnapshot } from './phases/retrospective.js'; +import { projectMetrics } from './events/projections.js'; +import { resolveOutcome } from './dag/outcome-table.js'; +import { createLogger } from './util/logger.js'; + +const log = createLogger(); + +/** + * Minimal node handle the dispatcher needs. The legacy executor passes a full + * `DagNode` (assignable to this); the LangGraph engine passes a literal. Only + * `phase` and `startedAt` are read. + */ +export interface DispatchNodeRef { + phase: PipelinePhase; + startedAt?: string; +} + +export interface PipelineCallbacks { + incrementHumanOverrides: () => void; + outcome: () => 'completed' | 'failed'; + setOutcome: (o: 'completed' | 'failed') => void; + setFailedAgent: (a: AgentName) => void; + getScoutFindings: () => ScoutFindings | null; + setScoutFindings: (f: ScoutFindings | null) => void; +} + +/** + * Validate a phase's typed outcome against the unified failure matrix. The + * matrix is the source of truth for `(phase, outcome) → next-action`; this + * call surfaces drift between a phase impl and the matrix immediately. The + * legacy `nextPhase` field still drives control flow until the executor is + * fully migrated. + */ +export function consultMatrix(outcome: import('./types.js').PhaseOutcome | undefined): void { + if (!outcome) return; + try { + resolveOutcome(outcome.phase, outcome.outcome); + } catch (err) { + log.error('outcome matrix lookup failed', { + phase: outcome.phase, + outcome: outcome.outcome, + error: (err as Error).message, + }); + } +} + +/** + * Run a single pipeline phase and return its `AgentResult`. Engine-agnostic: + * the legacy DAG executor and the LangGraph engine both dispatch through this + * function so per-phase semantics (matrix consult, abort prompts, scout + * findings hand-off, previousResults bookkeeping) stay identical across engines. + */ +export async function dispatchNode( + node: DispatchNodeRef, + config: PipelineConfig, + store: TaskStore, + previousResults: Map, + notifier: Notifier, + revision: RevisionRequest | undefined, + callbacks: PipelineCallbacks, +): Promise { + switch (node.phase) { + case 'scout': { + const output = await runScoutPhase(config, store); + consultMatrix(output.outcome); + callbacks.setScoutFindings(output.findings); + // Emit a lightweight audit event so cross-run analytics can track + // scout coverage without reading the phase_end payload. + if (config.eventAppender) { + const elapsedMs = output.result.summary.startsWith('[dry-run]') + ? 0 + : Date.now() - Date.parse(node.startedAt ?? new Date().toISOString()); + await config.eventAppender.append({ + event: 'scout_completed', + hasFindings: output.findings !== null, + relevantFileCount: output.findings?.relevantFiles.length ?? 0, + patternCount: output.findings?.patterns.length ?? 0, + durationMs: Math.max(0, elapsedMs), + }); + } + // Scout is non-blocking: always surface a `completed` status so the + // executor advances to implement_0 regardless of whether findings + // were produced. The typed outcome (consulted above) records the + // real success/failure for audit fidelity. + return { ...output.result, status: 'completed' }; + } + + case 'implement': { + if (revision) { + await store.setPendingRevision(revision); + } + const output = await runImplementPhase(config, store, previousResults, revision, callbacks.getScoutFindings()); + consultMatrix(output.outcome); + if (output.nextPhase === 'abort') { + const choice = await handleFailure(notifier, config, 'implementer', output.result, [ + 'Retry with guidance', + 'Abort', + ]); + if (choice === 'Abort') { + callbacks.setOutcome('failed'); + callbacks.setFailedAgent('implementer'); + return output.result; + } + return { ...output.result, status: 'completed' }; + } + await store.setPendingRevision(null); + previousResults.set('implementer', output.result); + return output.result; + } + + case 'verify': { + const output = await runVerifyPhase(config, store, previousResults); + consultMatrix(output.outcome); + if (output.nextPhase === 'abort') { + const choice = await handleFailure(notifier, config, 'verifier', output.result, [ + 'Re-implement and re-verify', + 'Skip verification', + 'Abort', + ]); + if (choice === 'Abort') { + callbacks.setOutcome('failed'); + callbacks.setFailedAgent('verifier'); + return output.result; + } + return { ...output.result, status: 'completed' }; + } + previousResults.set('verifier', output.result); + return output.result; + } + + case 'review': { + const output = await runReviewPhase(config, store, previousResults); + consultMatrix(output.outcome); + if (output.nextPhase === 'abort') { + const choice = await handleFailure(notifier, config, 'reviewer', output.result, [ + 'Re-implement and re-review', + 'Override and continue', + 'Abort', + ]); + if (choice === 'Abort') { + callbacks.setOutcome('failed'); + callbacks.setFailedAgent('reviewer'); + return output.result; + } + if (choice === 'Override and continue') { + callbacks.incrementHumanOverrides(); + } + return { ...output.result, status: 'completed' }; + } + previousResults.set('reviewer', output.result); + return output.result; + } + + case 'close': { + const output = await runClosePhase(config, store, previousResults); + consultMatrix(output.outcome); + if (output.nextPhase === 'abort') { + const choice = await handleFailure(notifier, config, 'closer', output.result, ['Retry', 'Abort']); + if (choice === 'Abort') { + callbacks.setOutcome('failed'); + callbacks.setFailedAgent('closer'); + return output.result; + } + return { ...output.result, status: 'completed' }; + } + const prUrl = output.result.artifacts.prUrl; + if (prUrl) notifier.send(`PR created: ${prUrl}`); + previousResults.set('closer', output.result); + return output.result; + } + + case 'retrospective': { + const appenderState = config.eventAppender!.getState(); + const metricsSnapshot: MetricsSnapshot = { + revisionCycles: appenderState.revisionCycles, + humanOverrides: 0, + profile: appenderState.profile, + evaluatorEffectiveness: projectMetrics(appenderState).evaluatorEffectiveness, + }; + await runRetrospectivePhase(config, store, previousResults, callbacks.outcome(), undefined, metricsSnapshot); + return { + status: 'completed', + summary: 'Retrospective complete', + artifacts: { + commit: null, + filesChanged: [], + testsPassed: null, + screenshotUrls: [], + evidenceMarkers: [], + prUrl: null, + prNumber: null, + }, + error: null, + }; + } + + default: + throw new Error(`Unknown phase: ${node.phase}`); + } +} + +export async function handleFailure( + notifier: Notifier, + config: PipelineConfig, + agent: AgentName, + result: AgentResult, + options: string[], +): Promise { + const errorMsg = result.error ?? result.summary ?? 'unknown error'; + const prompt = `${agent} failed: ${errorMsg}`; + return notifier.askUser(prompt, options); +} diff --git a/src/pipeline.ts b/src/pipeline.ts index 05b49f8..6193efb 100644 --- a/src/pipeline.ts +++ b/src/pipeline.ts @@ -5,12 +5,6 @@ import { formatDuration } from './notify.js'; import { createStructuredLogRenderer } from './render/structured-log.js'; import { createTuiRenderer, type TuiRenderer } from './render/tui-renderer.js'; import type { Notifier } from './notify.js'; -import { runImplementPhase } from './phases/implement.js'; -import { runScoutPhase } from './phases/scout.js'; -import { runVerifyPhase } from './phases/verify.js'; -import { runReviewPhase } from './phases/review.js'; -import { runClosePhase } from './phases/close.js'; -import { runRetrospectivePhase, type MetricsSnapshot } from './phases/retrospective.js'; import { writeRunMetrics } from './metrics/writer.js'; import { getCurrentPromptVersions, findPriorRunId } from './versioning/prompt-tracker.js'; import { EventAppender } from './events/appender.js'; @@ -20,8 +14,8 @@ import { PiRuntimeAdapter } from './agent/adapters/pi-adapter.js'; import { createLogger } from './util/logger.js'; import { buildGraph } from './dag/builder.js'; import { executeGraph, type ExecuteGraphContext } from './dag/executor.js'; -import { resolveOutcome } from './dag/outcome-table.js'; -import type { DagNode } from './dag/types.js'; +import { dispatchNode, type DispatchNodeRef } from './pipeline-dispatch.js'; +import { executeLangGraph } from './langgraph/engine.js'; import { loadEventsFromFile, reduceEvents } from './events/reducer.js'; import { restoreGraphState } from './dag/restore.js'; import type { PipelineGraph } from './dag/types.js'; @@ -103,55 +97,6 @@ async function runPipelineBody( await mkdirPlan(planDir, { recursive: true }); await writePlan(resolvePlan(planDir, 'plan.json'), JSON.stringify(plan, null, 2)); - const graph = buildGraph(profile, maxRevisionCycles); - - // Crash recovery: restore graph state from event log if a prior run didn't complete - const existingEventLogPath = resolvePlan(config.dataDir, '.case', task.id, 'events'); - let resumed = false; - try { - const { readdir: readdirFs } = await import('node:fs/promises'); - const files = await readdirFs(existingEventLogPath); - const latestLog = files - .filter((f) => f.endsWith('.jsonl')) - .sort() - .pop(); - if (latestLog) { - const events = await loadEventsFromFile(resolvePlan(existingEventLogPath, latestLog)); - if (events.length > 0) { - const state = reduceEvents(events); - // Resume if the prior run didn't complete (no pipeline_end event) - if (state.outcome === 'running') { - restoreGraphState(graph, state); - appender.restoreState(state); - resumed = true; - } - } - } - } catch { - // No existing event log — fresh start - } - - let initialRevisionRequests: Map | undefined; - - if (!resumed) { - await appender.append({ event: 'pipeline_start', taskId: task.id, profile, plan }); - - if (task.pendingRevision) { - const revCycle = task.pendingRevision.cycle ?? 1; - const prevCycle = revCycle - 1; - markCyclesCompleted(graph, profile, 0, prevCycle); - seedPendingRevision(graph, task.pendingRevision); - initialRevisionRequests = new Map([[prevCycle, [task.pendingRevision]]]); - const state = appender.getState(); - state.revisionCycles = revCycle; - state.pendingRevision = task.pendingRevision; - resumed = true; - } else if (task.status !== 'active') { - seedGraphFromTaskStatus(graph, profile, task.status); - resumed = true; - } - } - // Prompt versions are static package assets; run metrics are appended under the repo .case dir. const promptVersions = await getCurrentPromptVersions(config.packageRoot); let outcome: 'completed' | 'failed' = 'completed'; @@ -164,45 +109,117 @@ async function runPipelineBody( // same findings (scout runs once per pipeline). const scoutSlot: { current: ScoutFindings | null } = { current: null }; - const ctx: ExecuteGraphContext = { - graph, - appender, - config, - notifier, - initialRevisionRequests, - dispatchPhase: async (node: DagNode, revision?: RevisionRequest) => { - return dispatchNode(node, config, store, previousResults, notifier, revision, { - incrementHumanOverrides: () => { - humanOverrides++; - }, - outcome: () => outcome, - setOutcome: (o) => { - outcome = o; - }, - setFailedAgent: (a) => { - failedAgent = a; - }, - getScoutFindings: () => scoutSlot.current, - setScoutFindings: (f) => { - scoutSlot.current = f; - }, - }); - }, - }; + // Engine-agnostic per-phase dispatcher. Both the legacy DAG executor and the + // LangGraph engine call through this, so per-phase semantics (matrix consult, + // abort prompts, scout hand-off, previousResults bookkeeping) stay identical. + const dispatch = async (node: DispatchNodeRef, revision?: RevisionRequest): Promise => + dispatchNode(node, config, store, previousResults, notifier, revision, { + incrementHumanOverrides: () => { + humanOverrides++; + }, + outcome: () => outcome, + setOutcome: (o) => { + outcome = o; + }, + setFailedAgent: (a) => { + failedAgent = a; + }, + getScoutFindings: () => scoutSlot.current, + setScoutFindings: (f) => { + scoutSlot.current = f; + }, + }); - await executeGraph(ctx); + if (process.env.CASE_ENGINE === 'langgraph') { + // Phase 1.1: LangGraph owns orchestration. Fresh runs only — event-log + // crash-resume stays legacy until the 1.2 SQLite checkpointer lands. A + // td-persisted pendingRevision still seeds a resume-at-implement. + await appender.append({ event: 'pipeline_start', taskId: task.id, profile, plan }); + await executeLangGraph({ + profile, + maxRevisionCycles, + appender, + notifier, + dispatch, + onPhaseFailed: (agent) => { + outcome = 'failed'; + failedAgent = agent; + }, + initialPendingRevision: task.pendingRevision ?? null, + }); + } else { + const graph = buildGraph(profile, maxRevisionCycles); + + // Crash recovery: restore graph state from event log if a prior run didn't complete + const existingEventLogPath = resolvePlan(config.dataDir, '.case', task.id, 'events'); + let resumed = false; + try { + const { readdir: readdirFs } = await import('node:fs/promises'); + const files = await readdirFs(existingEventLogPath); + const latestLog = files + .filter((f) => f.endsWith('.jsonl')) + .sort() + .pop(); + if (latestLog) { + const events = await loadEventsFromFile(resolvePlan(existingEventLogPath, latestLog)); + if (events.length > 0) { + const state = reduceEvents(events); + // Resume if the prior run didn't complete (no pipeline_end event) + if (state.outcome === 'running') { + restoreGraphState(graph, state); + appender.restoreState(state); + resumed = true; + } + } + } + } catch { + // No existing event log — fresh start + } - const totalDurationMs = Date.now() - Date.parse(appender.getState().startedAt); + let initialRevisionRequests: Map | undefined; + + if (!resumed) { + await appender.append({ event: 'pipeline_start', taskId: task.id, profile, plan }); + + if (task.pendingRevision) { + const revCycle = task.pendingRevision.cycle ?? 1; + const prevCycle = revCycle - 1; + markCyclesCompleted(graph, profile, 0, prevCycle); + seedPendingRevision(graph, task.pendingRevision); + initialRevisionRequests = new Map([[prevCycle, [task.pendingRevision]]]); + const state = appender.getState(); + state.revisionCycles = revCycle; + state.pendingRevision = task.pendingRevision; + resumed = true; + } else if (task.status !== 'active') { + seedGraphFromTaskStatus(graph, profile, task.status); + resumed = true; + } + } - // Check if any node failed - for (const [, node] of graph.nodes) { - if (node.state === 'failed' && node.agent !== 'retrospective') { - outcome = 'failed'; - failedAgent = node.agent as AgentName; - break; + const ctx: ExecuteGraphContext = { + graph, + appender, + config, + notifier, + initialRevisionRequests, + dispatchPhase: dispatch, + }; + + await executeGraph(ctx); + + // Check if any node failed + for (const [, node] of graph.nodes) { + if (node.state === 'failed' && node.agent !== 'retrospective') { + outcome = 'failed'; + failedAgent = node.agent as AgentName; + break; + } } } + const totalDurationMs = Date.now() - Date.parse(appender.getState().startedAt); + await appender.append({ event: 'pipeline_end', outcome, failedAgent, durationMs: totalDurationMs }); const runMetrics = projectMetrics(appender.getState()); @@ -229,183 +246,9 @@ async function runPipelineBody( } } -interface PipelineCallbacks { - incrementHumanOverrides: () => void; - outcome: () => 'completed' | 'failed'; - setOutcome: (o: 'completed' | 'failed') => void; - setFailedAgent: (a: AgentName) => void; - getScoutFindings: () => ScoutFindings | null; - setScoutFindings: (f: ScoutFindings | null) => void; -} - -/** - * Validate a phase's typed outcome against the unified failure matrix. The - * matrix is the source of truth for `(phase, outcome) → next-action`; this - * call surfaces drift between a phase impl and the matrix immediately. The - * legacy `nextPhase` field still drives control flow until the executor is - * fully migrated. - */ -function consultMatrix(outcome: import('./types.js').PhaseOutcome | undefined): void { - if (!outcome) return; - try { - resolveOutcome(outcome.phase, outcome.outcome); - } catch (err) { - log.error('outcome matrix lookup failed', { - phase: outcome.phase, - outcome: outcome.outcome, - error: (err as Error).message, - }); - } -} - -async function dispatchNode( - node: DagNode, - config: PipelineConfig, - store: TaskStore, - previousResults: Map, - notifier: Notifier, - revision: RevisionRequest | undefined, - callbacks: PipelineCallbacks, -): Promise { - switch (node.phase) { - case 'scout': { - const output = await runScoutPhase(config, store); - consultMatrix(output.outcome); - callbacks.setScoutFindings(output.findings); - // Emit a lightweight audit event so cross-run analytics can track - // scout coverage without reading the phase_end payload. - if (config.eventAppender) { - const elapsedMs = output.result.summary.startsWith('[dry-run]') - ? 0 - : Date.now() - Date.parse(node.startedAt ?? new Date().toISOString()); - await config.eventAppender.append({ - event: 'scout_completed', - hasFindings: output.findings !== null, - relevantFileCount: output.findings?.relevantFiles.length ?? 0, - patternCount: output.findings?.patterns.length ?? 0, - durationMs: Math.max(0, elapsedMs), - }); - } - // Scout is non-blocking: always surface a `completed` status so the - // executor advances to implement_0 regardless of whether findings - // were produced. The typed outcome (consulted above) records the - // real success/failure for audit fidelity. - return { ...output.result, status: 'completed' }; - } - - case 'implement': { - if (revision) { - await store.setPendingRevision(revision); - } - const output = await runImplementPhase(config, store, previousResults, revision, callbacks.getScoutFindings()); - consultMatrix(output.outcome); - if (output.nextPhase === 'abort') { - const choice = await handleFailure(notifier, config, 'implementer', output.result, [ - 'Retry with guidance', - 'Abort', - ]); - if (choice === 'Abort') { - callbacks.setOutcome('failed'); - callbacks.setFailedAgent('implementer'); - return output.result; - } - return { ...output.result, status: 'completed' }; - } - await store.setPendingRevision(null); - previousResults.set('implementer', output.result); - return output.result; - } - - case 'verify': { - const output = await runVerifyPhase(config, store, previousResults); - consultMatrix(output.outcome); - if (output.nextPhase === 'abort') { - const choice = await handleFailure(notifier, config, 'verifier', output.result, [ - 'Re-implement and re-verify', - 'Skip verification', - 'Abort', - ]); - if (choice === 'Abort') { - callbacks.setOutcome('failed'); - callbacks.setFailedAgent('verifier'); - return output.result; - } - return { ...output.result, status: 'completed' }; - } - previousResults.set('verifier', output.result); - return output.result; - } - - case 'review': { - const output = await runReviewPhase(config, store, previousResults); - consultMatrix(output.outcome); - if (output.nextPhase === 'abort') { - const choice = await handleFailure(notifier, config, 'reviewer', output.result, [ - 'Re-implement and re-review', - 'Override and continue', - 'Abort', - ]); - if (choice === 'Abort') { - callbacks.setOutcome('failed'); - callbacks.setFailedAgent('reviewer'); - return output.result; - } - if (choice === 'Override and continue') { - callbacks.incrementHumanOverrides(); - } - return { ...output.result, status: 'completed' }; - } - previousResults.set('reviewer', output.result); - return output.result; - } - - case 'close': { - const output = await runClosePhase(config, store, previousResults); - consultMatrix(output.outcome); - if (output.nextPhase === 'abort') { - const choice = await handleFailure(notifier, config, 'closer', output.result, ['Retry', 'Abort']); - if (choice === 'Abort') { - callbacks.setOutcome('failed'); - callbacks.setFailedAgent('closer'); - return output.result; - } - return { ...output.result, status: 'completed' }; - } - const prUrl = output.result.artifacts.prUrl; - if (prUrl) notifier.send(`PR created: ${prUrl}`); - previousResults.set('closer', output.result); - return output.result; - } - - case 'retrospective': { - const appenderState = config.eventAppender!.getState(); - const metricsSnapshot: MetricsSnapshot = { - revisionCycles: appenderState.revisionCycles, - humanOverrides: 0, - profile: appenderState.profile, - evaluatorEffectiveness: projectMetrics(appenderState).evaluatorEffectiveness, - }; - await runRetrospectivePhase(config, store, previousResults, callbacks.outcome(), undefined, metricsSnapshot); - return { - status: 'completed', - summary: 'Retrospective complete', - artifacts: { - commit: null, - filesChanged: [], - testsPassed: null, - screenshotUrls: [], - evidenceMarkers: [], - prUrl: null, - prNumber: null, - }, - error: null, - }; - } - - default: - throw new Error(`Unknown phase: ${node.phase}`); - } -} +// Per-phase dispatch (scout/implement/verify/review/close/retrospective) lives +// in `pipeline-dispatch.ts` so the legacy DAG executor and the LangGraph engine +// share identical semantics. See `dispatchNode` / `PipelineCallbacks`. function markCyclesCompleted( graph: PipelineGraph, @@ -503,15 +346,3 @@ function seedPendingRevision(graph: PipelineGraph, revision: RevisionRequest): v }; } } - -async function handleFailure( - notifier: Notifier, - config: PipelineConfig, - agent: AgentName, - result: AgentResult, - options: string[], -): Promise { - const errorMsg = result.error ?? result.summary ?? 'unknown error'; - const prompt = `${agent} failed: ${errorMsg}`; - return notifier.askUser(prompt, options); -} From 5df965f5bcf727b3554eb3c764cffe8a4126f4cf Mon Sep 17 00:00:00 2001 From: Em Jones Date: Sun, 21 Jun 2026 15:58:13 -0700 Subject: [PATCH 04/17] feat(checkpoint): langgraph checkpointing --- MIGRATE_IMPLEMENTATION.md | 53 ++++- src/__tests__/checkpointer-resume.spec.ts | 163 ++++++++++++++ src/__tests__/checkpointer.spec.ts | 111 ++++++++++ src/langgraph/checkpointer.ts | 247 ++++++++++++++++++++++ src/langgraph/engine.ts | 47 +++- src/pipeline.ts | 13 +- 6 files changed, 619 insertions(+), 15 deletions(-) create mode 100644 src/__tests__/checkpointer-resume.spec.ts create mode 100644 src/__tests__/checkpointer.spec.ts create mode 100644 src/langgraph/checkpointer.ts diff --git a/MIGRATE_IMPLEMENTATION.md b/MIGRATE_IMPLEMENTATION.md index ae2b746..7270f09 100644 --- a/MIGRATE_IMPLEMENTATION.md +++ b/MIGRATE_IMPLEMENTATION.md @@ -1,6 +1,6 @@ # Migration: Custom DAG + Event-Sourcing → LangGraph + Langfuse -**Status:** In progress — Phase 1.1 complete (see §0). +**Status:** In progress — Phase 1.2 complete (see §0). **Author:** Case maintainers **Scope:** Replace Case's hand-rolled orchestration engine and granular event log with LangGraph (graph execution + checkpointing) and Langfuse (observability dispatch), without losing any existing feature. @@ -33,14 +33,51 @@ LangGraph (`@langchain/langgraph` 1.4.4 + peer `@langchain/core` 1.2.0, Bun-veri **Test-runner fix (`src/dev/run-tests.ts`) — required, not optional.** Bun's `mock.module()` is process-global and persists across files; `bun test ./src/__tests__/` loaded all specs into one process, so top-level mocks leaked (`pipeline-tool.spec`'s `pipeline.js` mock broke `pipeline.spec`/parity; `pipeline.spec`'s `task-store` mock broke `task-scanner`/`createTask`/`update-memory`). This was **pre-existing** (38 failures on clean HEAD). Fixed by running each unit spec in its own process (concurrency 8). Every spec passes in isolation; the suite is green. **Next session: keep specs isolated — do not collapse back to a single `bun test ` invocation.** -### ⏭ Next: Phase 1.2 — checkpointer + resume parity +### ✅ Phase 1.2 — Checkpointer + resume parity (additive, flag-gated) — **DONE** -- Add the LangGraph SQLite checkpointer; resolve the **§6 co-location verify** (live alongside td's SQLite in `/.todos/`, or sibling `case-checkpoints.db`). -- Wire checkpointed resume on the `langgraph` path; remove the 1.1 "fresh-runs-only" limitation. -- New oracle test: **checkpointer resume parity** — kill mid-`implement_1`, assert restored node set + `pendingRevision` match `reduceEvents` on the same crash point (replaces `events-reducer.spec` after the 1.3 cutover, not before). -- Snapshot target is `src/langgraph/state.ts`'s channels (already isolated to orchestration state for this purpose). +The LangGraph path now owns crash/abort resume via a SQLite checkpointer; the 1.1 "fresh-runs-only" limitation is gone. Legacy event-replay resume is untouched (still the default-engine path). `events-reducer.spec` is **retained** as the resume-correctness oracle until the 1.3 cutover. -**Not yet started:** Phase 1.3 (breaking cutover + legacy delete), all of Phase 2 (Langfuse). The `podman-compose.yaml` Langfuse stack is present but unused until Phase 2. +**Landed:** + +- **`src/langgraph/checkpointer.ts` (NEW).** `BunSqliteSaver extends BaseCheckpointSaver`, a faithful port of the upstream `@langchain/langgraph-checkpoint-sqlite` schema + serde contract onto **`bun:sqlite`**. `getTuple`/`list`/`put`/`putWrites`/`deleteThread` + default serde. `createSqliteCheckpointer(repoPath)` opens the DB at the **§6-decided** location. +- **`src/langgraph/engine.ts`.** `executeLangGraph` accepts `checkpointer` + `threadId`; compiles the graph with the checkpointer when present. Resume decision: `getState().next.length > 0` ⟹ a prior run was interrupted mid-superstep → `invoke(null)` (continue from saved state); otherwise `invoke(initial)` (td-seeded fresh run). `deleteThread` runs on **normal completion only**, so only a true crash/abort leaves a resumable checkpoint — this mirrors the legacy `outcome === 'running'` resume gate exactly. A stale terminal checkpoint (crash during a prior cleanup) is cleared before a fresh run. +- **`src/pipeline.ts`.** The `langgraph` branch constructs the checkpointer (`createSqliteCheckpointer(config.repoPath)`), passes `threadId: task.id`. td still seeds the first run's `pendingRevision`; the checkpoint is authoritative once a run has begun. +- **`src/__tests__/checkpointer.spec.ts` (NEW).** SQL-layer correctness: roundtrip, latest-wins ordering + parent linkage, pending writes, list ordering/limit, `deleteThread`, and **persistence across a reopen on the same file** (new `Database` instance = new-process resume). 6/6 green. +- **`src/__tests__/checkpointer-resume.spec.ts` (NEW).** The Phase 1.2 oracle: kill mid-`implement_1` (the implementer throws on the revision cycle, escaping `invoke` — `runPhase` wraps dispatch in `try/finally`, no catch). A second `executeLangGraph` over the same `MemorySaver` + thread resumes, re-enters at `implement` (not `scout`), carries the restored revision, and that restored `(revisionCycles, pendingRevision)` **matches `reduceEvents` on the pre-crash event stream**. Plus: a clean run drops its thread. 2/2 green. + +**Validation:** typecheck ✅ · `oxlint` 0 errors ✅ · AST self-lint ✅ · `oxfmt` (my files) ✅ · checkpointer 6/6 ✅ · resume-parity 2/2 ✅ · parity 6/6 unchanged ✅ · `pipeline.spec` unchanged ✅ · full suite green ✅. No manifest/lockfile churn (`@langchain/langgraph-checkpoint` was already a dep; the transient `better-sqlite3` add/trust was fully backed out, incl. `trustedDependencies`). + +**Change set:** `src/langgraph/checkpointer.ts` (NEW), `src/__tests__/checkpointer.spec.ts` (NEW), `src/__tests__/checkpointer-resume.spec.ts` (NEW), `src/langgraph/engine.ts` (edited), `src/pipeline.ts` (edited). Branch `docs/migrate-langgraph-langfuse-rfc` — **not yet committed** at handoff. + +**Deviations / decisions made during implementation:** + +1. **§6 co-location — RESOLVED to a sibling DB, not co-located.** td owns `/.todos/issues.db` and runs 29 versioned schema migrations with **no namespace isolation** (a future td migration could drop foreign tables). The checkpointer therefore lives in a **sibling** `/.todos/case-checkpoints.db` — the §6 fallback — keeping the two schemas independently owned and recoverable. +2. **Official SQLite checkpointer is unusable under Bun → custom `bun:sqlite` saver.** `@langchain/langgraph-checkpoint-sqlite@1.0.3` depends on `better-sqlite3`, whose native binding fails to load under Bun (`ERR_DLOPEN_FAILED`, oven-sh/bun#4290 — Bun itself recommends `bun:sqlite`). Ported the schema/serde contract by hand. Scope is **current format only (v4)**: the legacy `pending_sends` + `migratePendingSends` path (for v<4 checkpoints) and `list()` metadata filtering are omitted — the engine never persists v<4 nor lists by filter. Neither package nor `better-sqlite3` ships in `package.json`. +3. **`thread_id = task.id`; thread dropped on normal completion.** A stable per-task key lets an interrupted run of the same task resume; `deleteThread` on reaching `END` means a completed/failed run leaves nothing resumable (only crashes/aborts do). This reproduces the legacy "resume iff `outcome === 'running'`" semantics without a separate gate. +4. **Resume seed precedence.** The td-persisted `pendingRevision` seeds **fresh** runs only (`invoke(initial)`); on resume the checkpoint is authoritative and `invoke(null)` continues from it. +5. **No "dual-write" — parity proven by an in-process oracle instead.** §4 step 1.2 anticipated running both resume mechanisms side-by-side. What landed: each engine uses its own resume (legacy path = event replay; langgraph path = checkpointer); they are not both exercised in a single run. The §4 "assert restored graph state matches `reduceEvents` on the same crash point" guarantee is delivered by `checkpointer-resume.spec` — it crashes the LangGraph run mid-`implement_1` and asserts the checkpointer-restored `(revisionCycles, pendingRevision)` equals `reduceEvents` over the pre-crash event stream. Functionally the §4 acceptance; mechanically a test, not a runtime dual-write. + +### ⏭ Next: Phase 1.3 — ⚠ BREAKING: resume cutover + default flip + +> Self-contained handoff for a fresh session. The LangGraph engine is feature-complete (orchestration + checkpointed resume); 1.3 is **deletion + default flip + relocating two projection side-effects**, guarded by the now-green parity suite. + +**Starting state.** `src/pipeline.ts` `runPipelineBody` branches on `process.env.CASE_ENGINE === 'langgraph'` (langgraph branch first; legacy DAG in the `else`). The langgraph branch already wires the checkpointer + `threadId`. Legacy is still the default (flag unset → `else`). + +**Do, in order (each its own commit; the flip is the single ⚠ BREAKING commit):** + +1. **Flip default + delete legacy engine.** Make LangGraph unconditional; delete the `else` branch in `runPipelineBody` and the legacy resume block (`readdirFs` → `loadEventsFromFile` → `reduceEvents` → `restoreGraphState` → `appender.restoreState`, ~`src/pipeline.ts:153-198`), plus `src/dag/builder.ts`, `src/dag/executor.ts`, `src/dag/restore.ts`, and the legacy seed helpers (`markCyclesCompleted`/`seedGraphFromTaskStatus`/`seedPendingRevision`, ~`src/pipeline.ts:253-348`). Drop the `CASE_ENGINE` env read. Resume is checkpointer-only after this. + - **KEEP (MOVE-verbatim, §9):** `src/dag/fingerprint.ts`, `src/dag/outcome-table.ts`, `src/dag/merge.ts` — still referenced by the engine/dispatch. Verify importers before deleting anything under `src/dag/`. +2. **Relocate td-mirror + marker writes to node-direct.** Today `EventAppender` derives td status (`projectTaskJson`) and markers (`projectMarkers`) as a side-effect of `append()` (`src/events/appender.ts:72,76-84`). Move these to fire on **node completion** inside the engine (same synchronous point), then remove the derived writes from the appender. The raw JSONL appender **stays** (write-only observability sink until 2.2). Disk markers stay the gate truth (§1 constraint 4). +3. **Test triage (§9).** DIE now: `dag-builder.spec`, `dag-builder-scout.spec`, `dag-executor.spec`. PORT: `events-projections.spec` (`projectMarkers`/`projectTaskJson` → assert node-direct), `dag-status.spec` (→ LangGraph channels), `pipeline.spec` resume parts (→ checkpointer; split, don't blanket-delete). **Retire `events-reducer.spec` only after this cutover is green** — its oracle role is now held by `checkpointer-resume.spec`. NET-NEW: LangGraph graph-construction + conditional-edge routing test (routing still keys off `outcome-table`). + +*Acceptance (§4 1.3):* resume works with the replay path gone; td status + labels and marker files still update each phase; `runs.jsonl`/metrics unchanged; full suite green. + +**Gotchas for the next session:** +- Keep unit specs **process-isolated** (`src/dev/run-tests.ts`, concurrency 8) — Bun `mock.module()` leaks across files. Do not collapse to a single `bun test `. +- `better-sqlite3` does **not** load under Bun — never reach for the official sqlite checkpointer; the engine's checkpointer is the hand-rolled `BunSqliteSaver`. +- Deviation 4 (1.1): skipped-phase `phase_end` events still aren't emitted. If `projectMetrics.skippedPhases` fidelity is wanted, emit them when marker/td writes go node-direct here. + +**Not yet started:** Phase 1.3 (this), all of Phase 2 (Langfuse). The `podman-compose.yaml` Langfuse stack is present but unused until Phase 2. --- @@ -241,7 +278,7 @@ Delete `src/events/{schema,appender,reducer}.ts` and the now-orphaned `projectTa ## 6. Open Verifies (must confirm during Phase 1) -- **Checkpointer / td SQLite co-location.** Now load-bearing (§5 decision 1 commits to the checkpointer). Confirm the LangGraph SQLite checkpointer can live in `/.todos/` alongside td's schema (separate tables, no migration conflict), or fall back to a sibling DB file (`/.todos/case-checkpoints.db`) if td guards its schema. Resolve in step 1.2. +- **Checkpointer / td SQLite co-location. — RESOLVED (1.2): sibling DB.** td owns `/.todos/issues.db` and runs 29 versioned migrations with no namespace isolation, so co-locating checkpoint tables there risks a future td migration dropping them. The checkpointer lives in the sibling `/.todos/case-checkpoints.db` instead (the fallback this bullet anticipated). See §0 Phase 1.2 deviation 1. - **Outcome-matrix → conditional-edge re-expression.** Confirm every `(phase, outcome) → action` row maps to a deterministic edge function with no loss (esp. `abort`, `request-revision`, fingerprint short-circuit). - **pi LLM-call seam.** Confirmed available: `turn_end` carries `message.usage` with tokens **and** pre-computed `cost` (`pi-ai types.d.ts:144-157`); subscriber already exists at `pi-adapter.ts:68`. No pi patching required. diff --git a/src/__tests__/checkpointer-resume.spec.ts b/src/__tests__/checkpointer-resume.spec.ts new file mode 100644 index 0000000..6c59f20 --- /dev/null +++ b/src/__tests__/checkpointer-resume.spec.ts @@ -0,0 +1,163 @@ +import { describe, it, expect } from 'bun:test'; +import { MemorySaver } from '@langchain/langgraph-checkpoint'; +import { executeLangGraph, type DispatchFn } from '../langgraph/engine.js'; +import { reduceEvents } from '../events/reducer.js'; +import type { AgentResult, RevisionRequest } from '../types.js'; +import type { PipelineEvent } from '../events/types.js'; + +/** + * Phase 1.2 acceptance — checkpointer resume parity (the new oracle that will + * replace `events-reducer.spec` after the 1.3 cutover). + * + * A run is killed mid-`implement_1` (the implementer throws on the revision + * cycle, escaping `invoke` exactly as a process crash would). A *second* + * `executeLangGraph` over the SAME checkpointer + thread resumes. We assert the + * restored run: + * 1. re-enters at `implement` (not `scout`) — it did not restart from the top, + * 2. carries the restored pending revision into that implement, and + * 3. that restored revision matches what `reduceEvents` derives from the + * pre-crash event stream — i.e. the checkpointer snapshot and the legacy + * event-replay oracle agree on (revisionCycles, pendingRevision). + */ + +const completed: AgentResult = { + status: 'completed', + summary: 'done', + artifacts: { + commit: 'abc', + filesChanged: [], + testsPassed: true, + screenshotUrls: [], + evidenceMarkers: [], + prUrl: null, + prNumber: null, + }, + error: null, +}; + +const scoutResult: AgentResult = { + ...completed, + findings: { relevantFiles: [], patterns: [], constraints: [] } as never, +}; + +const verifierFail: AgentResult = { + ...completed, + rubric: { + role: 'verifier', + categories: [{ category: 'edge-case-checked', verdict: 'fail', detail: 'missing null check' }], + }, +}; + +/** A recording appender: collects what the engine emits, stamped like the real one. */ +function recordingAppender(events: PipelineEvent[]) { + let seq = 1; + return { + getState: () => ({ status: 'active' }), + append: async (e: Record) => { + events.push({ ...e, ts: new Date(0).toISOString(), sequence: seq++ } as unknown as PipelineEvent); + }, + }; +} + +const noopNotifier = { + send() {}, + phaseStart() {}, + phaseEnd() {}, + toolStart() {}, + toolEnd() {}, + stepIndicator() {}, + startHeartbeat() {}, + stopHeartbeat() {}, + askUser: async (_p: string, options: string[]) => options[options.length - 1], +}; + +function baseArgs(appender: unknown, dispatch: DispatchFn, checkpointer: MemorySaver) { + return { + profile: 'standard' as const, + maxRevisionCycles: 2, + appender: appender as never, + notifier: noopNotifier as never, + dispatch, + onPhaseFailed: () => {}, + checkpointer, + threadId: 'task-1', + }; +} + +describe('checkpointer resume parity', () => { + it('resumes mid-implement_1 with the restored pending revision (matches reduceEvents)', async () => { + const checkpointer = new MemorySaver(); + + // --- Run 1: crash on the second implement (the revision cycle). ---------- + const crashEvents: PipelineEvent[] = []; + let implementCalls = 0; + const crashDispatch: DispatchFn = async (node) => { + switch (node.phase) { + case 'scout': + return scoutResult; + case 'implement': + implementCalls += 1; + if (implementCalls === 2) throw new Error('simulated crash mid-implement_1'); + return completed; + case 'verify': + return verifierFail; // cycle 0 fails → revision requested → implement cycle 1 + default: + return completed; + } + }; + + await expect( + executeLangGraph(baseArgs(recordingAppender(crashEvents), crashDispatch, checkpointer)), + ).rejects.toThrow('simulated crash mid-implement_1'); + + // Legacy oracle: replay the pre-crash event stream the way resume used to. + const oracleStream: PipelineEvent[] = [ + { + event: 'pipeline_start', + runId: 'r1', + taskId: 'task-1', + profile: 'standard', + plan: {}, + ts: new Date(0).toISOString(), + sequence: 0, + } as unknown as PipelineEvent, + ...crashEvents, + ]; + const oracle = reduceEvents(oracleStream); + expect(oracle.revisionCycles).toBe(1); + expect(oracle.pendingRevision?.source).toBe('verifier'); + expect(oracle.pendingRevision?.cycle).toBe(1); + + // --- Run 2: resume over the same checkpointer + thread. ------------------ + const resumeCalls: { phase: string; revision: RevisionRequest | null }[] = []; + const resumeDispatch: DispatchFn = async (node, revision) => { + resumeCalls.push({ phase: node.phase, revision: revision ?? null }); + return completed; // implement clears, verify passes, review/close/retro proceed + }; + + await executeLangGraph(baseArgs(recordingAppender([]), resumeDispatch, checkpointer)); + + // It resumed at implement (no scout re-run) and ran the cycle to the end. + expect(resumeCalls.map((c) => c.phase)).toEqual(['implement', 'verify', 'review', 'close', 'retrospective']); + + // The restored implement carried the pending revision … + const firstRevision = resumeCalls[0]?.revision; + expect(firstRevision).not.toBeNull(); + expect(firstRevision?.source).toBe('verifier'); + expect(firstRevision?.cycle).toBe(1); + + // … and it agrees with the legacy event-replay oracle. + expect(firstRevision?.source).toBe(oracle.pendingRevision?.source); + expect(firstRevision?.cycle).toBe(oracle.pendingRevision?.cycle); + }); + + it('a clean run leaves no resumable checkpoint (thread dropped on completion)', async () => { + const checkpointer = new MemorySaver(); + const cleanDispatch: DispatchFn = async () => completed; + + await executeLangGraph(baseArgs(recordingAppender([]), cleanDispatch, checkpointer)); + + const tuple = await checkpointer.getTuple({ configurable: { thread_id: 'task-1', checkpoint_ns: '' } }); + expect(tuple).toBeUndefined(); + }); +}); diff --git a/src/__tests__/checkpointer.spec.ts b/src/__tests__/checkpointer.spec.ts new file mode 100644 index 0000000..58b1662 --- /dev/null +++ b/src/__tests__/checkpointer.spec.ts @@ -0,0 +1,111 @@ +import { describe, it, expect } from 'bun:test'; +import { Database } from 'bun:sqlite'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import type { Checkpoint, CheckpointMetadata } from '@langchain/langgraph-checkpoint'; +import { BunSqliteSaver } from '../langgraph/checkpointer.js'; + +/** + * SQL-layer correctness for the bun:sqlite checkpointer (Phase 1.2). Proves the + * port of the upstream schema/serde contract roundtrips checkpoints, parent + * links, pending writes, listing, deletion, and on-disk persistence — the + * guarantees the engine's resume path leans on. + */ + +const META: CheckpointMetadata = { source: 'input', step: 0, parents: {} }; + +function ckpt(id: string, channel_values: Record): Checkpoint { + return { + v: 4, + id, + ts: new Date(0).toISOString(), + channel_values, + channel_versions: Object.fromEntries(Object.keys(channel_values).map((k) => [k, 1])), + versions_seen: {}, + }; +} + +const cfg = (extra: Record = {}) => ({ + configurable: { thread_id: 'task-1', checkpoint_ns: '', ...extra }, +}); + +describe('BunSqliteSaver', () => { + it('roundtrips a checkpoint and restores channel values', async () => { + const saver = new BunSqliteSaver(new Database(':memory:')); + await saver.put(cfg(), ckpt('c1', { cycle: 2, pendingRevision: null }), META); + + const got = await saver.getTuple(cfg()); + expect(got?.checkpoint.id).toBe('c1'); + expect(got?.checkpoint.channel_values).toEqual({ cycle: 2, pendingRevision: null }); + expect(got?.parentConfig).toBeUndefined(); + }); + + it('latest-wins ordering and parent linkage', async () => { + const saver = new BunSqliteSaver(new Database(':memory:')); + await saver.put(cfg(), ckpt('c1', { cycle: 0 }), META); + await saver.put(cfg({ checkpoint_id: 'c1' }), ckpt('c2', { cycle: 1 }), META); + + // No checkpoint_id → newest (lexical/uuid6-ordered DESC). + const latest = await saver.getTuple(cfg()); + expect(latest?.checkpoint.id).toBe('c2'); + expect(latest?.parentConfig?.configurable?.checkpoint_id).toBe('c1'); + + // Explicit id → that exact checkpoint. + const first = await saver.getTuple(cfg({ checkpoint_id: 'c1' })); + expect(first?.checkpoint.id).toBe('c1'); + expect(first?.parentConfig).toBeUndefined(); + }); + + it('stores and returns pending writes', async () => { + const saver = new BunSqliteSaver(new Database(':memory:')); + await saver.put(cfg(), ckpt('c1', { cycle: 0 }), META); + await saver.putWrites(cfg({ checkpoint_id: 'c1' }), [['decision', { next: 'implement' }]], 'node-a'); + + const got = await saver.getTuple(cfg({ checkpoint_id: 'c1' })); + expect(got?.pendingWrites).toEqual([['node-a', 'decision', { next: 'implement' }]]); + }); + + it('lists checkpoints newest-first and honors limit', async () => { + const saver = new BunSqliteSaver(new Database(':memory:')); + await saver.put(cfg(), ckpt('c1', { cycle: 0 }), META); + await saver.put(cfg({ checkpoint_id: 'c1' }), ckpt('c2', { cycle: 1 }), META); + await saver.put(cfg({ checkpoint_id: 'c2' }), ckpt('c3', { cycle: 2 }), META); + + const all: string[] = []; + for await (const t of saver.list(cfg())) all.push(t.checkpoint.id); + expect(all).toEqual(['c3', 'c2', 'c1']); + + const limited: string[] = []; + for await (const t of saver.list(cfg(), { limit: 2 })) limited.push(t.checkpoint.id); + expect(limited).toEqual(['c3', 'c2']); + }); + + it('deleteThread removes checkpoints and writes', async () => { + const saver = new BunSqliteSaver(new Database(':memory:')); + await saver.put(cfg(), ckpt('c1', { cycle: 0 }), META); + await saver.putWrites(cfg({ checkpoint_id: 'c1' }), [['ch', { v: 1 }]], 'node-a'); + + await saver.deleteThread('task-1'); + + expect(await saver.getTuple(cfg())).toBeUndefined(); + expect(await saver.getTuple(cfg({ checkpoint_id: 'c1' }))).toBeUndefined(); + }); + + it('persists across reopen on the same file', async () => { + const dir = mkdtempSync(join(tmpdir(), 'case-ckpt-')); + const path = join(dir, 'cp.db'); + try { + const writer = BunSqliteSaver.fromPath(path); + await writer.put(cfg(), ckpt('c1', { cycle: 3, revisionCycles: 1 }), META); + + // Fresh saver instance, same file — simulates a new process resuming. + const reader = BunSqliteSaver.fromPath(path); + const got = await reader.getTuple(cfg()); + expect(got?.checkpoint.id).toBe('c1'); + expect(got?.checkpoint.channel_values).toEqual({ cycle: 3, revisionCycles: 1 }); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); +}); diff --git a/src/langgraph/checkpointer.ts b/src/langgraph/checkpointer.ts new file mode 100644 index 0000000..908fefd --- /dev/null +++ b/src/langgraph/checkpointer.ts @@ -0,0 +1,247 @@ +import { Database, type SQLQueryBindings } from 'bun:sqlite'; +import { mkdirSync } from 'node:fs'; +import { join } from 'node:path'; +import type { RunnableConfig } from '@langchain/core/runnables'; +import { + BaseCheckpointSaver, + copyCheckpoint, + WRITES_IDX_MAP, + type Checkpoint, + type CheckpointListOptions, + type CheckpointMetadata, + type CheckpointTuple, + type PendingWrite, + type SerializerProtocol, +} from '@langchain/langgraph-checkpoint'; + +/** + * SQLite checkpointer for the LangGraph engine, backed by `bun:sqlite`. + * + * The official `@langchain/langgraph-checkpoint-sqlite` saver is unusable here: + * it depends on `better-sqlite3`, whose native binding fails to load under Bun + * (`ERR_DLOPEN_FAILED`, oven-sh/bun#4290). This is a faithful port of that + * saver's schema + serde contract onto Bun's built-in SQLite driver. + * + * Scope: current checkpoint format only (v4). The legacy `pending_sends` + * subquery + `migratePendingSends` path that the upstream saver carries for + * v<4 checkpoints is intentionally omitted — this engine only ever persists + * the version the installed `@langchain/langgraph` writes. `metadata` filtering + * in `list()` is likewise omitted (the engine never lists by filter). + */ +export class BunSqliteSaver extends BaseCheckpointSaver { + private readonly db: Database; + + constructor(db: Database, serde?: SerializerProtocol) { + super(serde); + this.db = db; + this.db.exec('PRAGMA journal_mode = WAL;'); + this.db.exec(` + CREATE TABLE IF NOT EXISTS checkpoints ( + thread_id TEXT NOT NULL, + checkpoint_ns TEXT NOT NULL DEFAULT '', + checkpoint_id TEXT NOT NULL, + parent_checkpoint_id TEXT, + type TEXT, + checkpoint BLOB, + metadata BLOB, + PRIMARY KEY (thread_id, checkpoint_ns, checkpoint_id) + ); + `); + this.db.exec(` + CREATE TABLE IF NOT EXISTS writes ( + thread_id TEXT NOT NULL, + checkpoint_ns TEXT NOT NULL DEFAULT '', + checkpoint_id TEXT NOT NULL, + task_id TEXT NOT NULL, + idx INTEGER NOT NULL, + channel TEXT NOT NULL, + type TEXT, + value BLOB, + PRIMARY KEY (thread_id, checkpoint_ns, checkpoint_id, task_id, idx) + ); + `); + } + + /** Open (creating if needed) a checkpoint DB at the given filesystem path. */ + static fromPath(path: string, serde?: SerializerProtocol): BunSqliteSaver { + return new BunSqliteSaver(new Database(path, { create: true }), serde); + } + + /** Deserialize one checkpoints-table row into a CheckpointTuple. */ + private async rowToTuple( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + row: any, + checkpoint_ns: string, + ): Promise { + // pending_writes is json_group_array(...) → a JSON string (or '[]' when empty). + const rawWrites = JSON.parse(row.pending_writes ?? '[]') as Array<{ + task_id: string; + channel: string; + type: string | null; + value: string | null; + }>; + const pendingWrites: [string, string, unknown][] = await Promise.all( + rawWrites.map( + async (w) => + [w.task_id, w.channel, await this.serde.loadsTyped(w.type ?? 'json', w.value ?? '')] as [ + string, + string, + unknown, + ], + ), + ); + + const checkpoint = (await this.serde.loadsTyped(row.type ?? 'json', row.checkpoint)) as Checkpoint; + const metadata = (await this.serde.loadsTyped(row.type ?? 'json', row.metadata)) as CheckpointMetadata; + + return { + config: { + configurable: { thread_id: row.thread_id, checkpoint_ns, checkpoint_id: row.checkpoint_id }, + }, + checkpoint, + metadata, + parentConfig: row.parent_checkpoint_id + ? { + configurable: { + thread_id: row.thread_id, + checkpoint_ns, + checkpoint_id: row.parent_checkpoint_id, + }, + } + : undefined, + pendingWrites, + }; + } + + private static readonly SELECT = ` + SELECT thread_id, checkpoint_ns, checkpoint_id, parent_checkpoint_id, type, checkpoint, metadata, + ( + SELECT json_group_array(json_object( + 'task_id', pw.task_id, 'channel', pw.channel, 'type', pw.type, 'value', CAST(pw.value AS TEXT) + )) + FROM writes AS pw + WHERE pw.thread_id = checkpoints.thread_id + AND pw.checkpoint_ns = checkpoints.checkpoint_ns + AND pw.checkpoint_id = checkpoints.checkpoint_id + ) AS pending_writes + FROM checkpoints`; + + async getTuple(config: RunnableConfig): Promise { + const thread_id = config.configurable?.thread_id; + const checkpoint_ns = config.configurable?.checkpoint_ns ?? ''; + const checkpoint_id = config.configurable?.checkpoint_id; + + const sql = checkpoint_id + ? `${BunSqliteSaver.SELECT} WHERE thread_id = ? AND checkpoint_ns = ? AND checkpoint_id = ?` + : `${BunSqliteSaver.SELECT} WHERE thread_id = ? AND checkpoint_ns = ? ORDER BY checkpoint_id DESC LIMIT 1`; + + const params: SQLQueryBindings[] = checkpoint_id + ? [thread_id ?? '', checkpoint_ns, checkpoint_id] + : [thread_id ?? '', checkpoint_ns]; + const row = this.db.query(sql).get(...params); + if (row == null) return undefined; + return this.rowToTuple(row, checkpoint_ns); + } + + async *list(config: RunnableConfig, options?: CheckpointListOptions): AsyncGenerator { + const { limit, before } = options ?? {}; + const thread_id = config.configurable?.thread_id; + const checkpoint_ns = config.configurable?.checkpoint_ns ?? ''; + + let sql = `${BunSqliteSaver.SELECT} WHERE thread_id = ? AND checkpoint_ns = ?`; + const params: SQLQueryBindings[] = [thread_id ?? '', checkpoint_ns]; + if (before?.configurable?.checkpoint_id) { + sql += ' AND checkpoint_id < ?'; + params.push(before.configurable.checkpoint_id); + } + sql += ' ORDER BY checkpoint_id DESC'; + if (limit) sql += ` LIMIT ${parseInt(String(limit), 10)}`; + + const rows = this.db.query(sql).all(...params); + for (const row of rows) { + yield await this.rowToTuple(row, checkpoint_ns); + } + } + + async put(config: RunnableConfig, checkpoint: Checkpoint, metadata: CheckpointMetadata): Promise { + const thread_id = config.configurable?.thread_id; + const checkpoint_ns = config.configurable?.checkpoint_ns ?? ''; + const parent_checkpoint_id = config.configurable?.checkpoint_id; + if (!thread_id) throw new Error('Missing "thread_id" field in config.configurable.'); + + const [[type1, serializedCheckpoint], [type2, serializedMetadata]] = await Promise.all([ + this.serde.dumpsTyped(copyCheckpoint(checkpoint)), + this.serde.dumpsTyped(metadata), + ]); + if (type1 !== type2) { + throw new Error('Mismatched checkpoint/metadata serializer types.'); + } + + this.db + .query( + `INSERT OR REPLACE INTO checkpoints + (thread_id, checkpoint_ns, checkpoint_id, parent_checkpoint_id, type, checkpoint, metadata) + VALUES (?, ?, ?, ?, ?, ?, ?)`, + ) + .run( + thread_id, + checkpoint_ns, + checkpoint.id, + parent_checkpoint_id ?? null, + type1, + serializedCheckpoint, + serializedMetadata, + ); + + return { configurable: { thread_id, checkpoint_ns, checkpoint_id: checkpoint.id } }; + } + + async putWrites(config: RunnableConfig, writes: PendingWrite[], taskId: string): Promise { + const thread_id = config.configurable?.thread_id; + const checkpoint_ns = config.configurable?.checkpoint_ns ?? ''; + const checkpoint_id = config.configurable?.checkpoint_id; + if (!thread_id) throw new Error('Missing "thread_id" field in config.configurable.'); + if (!checkpoint_id) throw new Error('Missing "checkpoint_id" field in config.configurable.'); + + // Special (reserved) channels overwrite by their fixed slot; regular writes + // are positional and must not clobber an existing slot — mirrors upstream. + const allSpecial = writes.every(([channel]) => channel in WRITES_IDX_MAP); + const stmt = this.db.query( + `INSERT OR ${allSpecial ? 'REPLACE' : 'IGNORE'} INTO writes + (thread_id, checkpoint_ns, checkpoint_id, task_id, idx, channel, type, value) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, + ); + + const rows = await Promise.all( + writes.map(async ([channel, value], i) => { + const idx = WRITES_IDX_MAP[channel] ?? i; + const [type, serialized] = await this.serde.dumpsTyped(value); + return [thread_id, checkpoint_ns, checkpoint_id, taskId, idx, channel, type, serialized] as const; + }), + ); + + this.db.transaction((batch: (typeof rows)[number][]) => { + for (const row of batch) stmt.run(...row); + })(rows); + } + + async deleteThread(threadId: string): Promise { + this.db.transaction(() => { + this.db.query('DELETE FROM checkpoints WHERE thread_id = ?').run(threadId); + this.db.query('DELETE FROM writes WHERE thread_id = ?').run(threadId); + })(); + } +} + +/** + * Construct the engine's checkpointer at the §6-decided location: a sibling DB + * alongside td's SQLite, NOT inside td's own `issues.db`. td owns and migrates + * `issues.db` (29 versioned migrations, no namespace isolation), so co-locating + * checkpoint tables there risks a future td migration dropping them. A separate + * file keeps the two schemas independently owned and recoverable. + */ +export function createSqliteCheckpointer(repoPath: string): BunSqliteSaver { + const dir = join(repoPath, '.todos'); + mkdirSync(dir, { recursive: true }); + return BunSqliteSaver.fromPath(join(dir, 'case-checkpoints.db')); +} diff --git a/src/langgraph/engine.ts b/src/langgraph/engine.ts index 5b318e9..c5132d7 100644 --- a/src/langgraph/engine.ts +++ b/src/langgraph/engine.ts @@ -1,4 +1,5 @@ import { StateGraph, START, END } from '@langchain/langgraph'; +import type { BaseCheckpointSaver } from '@langchain/langgraph-checkpoint'; import type { AgentName, AgentResult, PipelinePhase, PipelineProfile, RevisionRequest, TaskStatus } from '../types.js'; import { PROFILE_PHASES } from '../types.js'; import type { Notifier } from '../notify.js'; @@ -28,6 +29,14 @@ export interface LangGraphEngineArgs { onPhaseFailed: (agent: AgentName) => void; /** Seed from a td-persisted pending revision (resume-at-implement). */ initialPendingRevision?: RevisionRequest | null; + /** + * Engine-state checkpointer (RFC §5 decision 1). When present, the graph is + * compiled with it and the run resumes from a prior interrupted checkpoint. + * Absent → 1.1 behavior (fresh in-memory run, no crash resume). + */ + checkpointer?: BaseCheckpointSaver; + /** Stable per-task thread key for the checkpointer. Required with `checkpointer`. */ + threadId?: string; } /** Maps a running phase to the TaskStatus the td mirror should show. */ @@ -295,16 +304,46 @@ export async function executeLangGraph(args: LangGraphEngineArgs): Promise g.addEdge('close', 'retrospective'); g.addEdge('retrospective', END); - const compiled = g.compile(); + const { checkpointer, threadId } = args; + const compiled = checkpointer ? g.compile({ checkpointer }) : g.compile(); const seed = args.initialPendingRevision; const initial: Partial = seed ? { pendingRevision: seed, cycle: seed.cycle ?? 1, revisionCycles: seed.cycle ?? 1 } : {}; - log.info('langgraph engine started', { profile: args.profile, maxRevisionCycles, seeded: Boolean(seed) }); - // recursionLimit as a runaway backstop only (RFC §5 decision 4); the explicit // revision-budget cap is the real guard. - await compiled.invoke(initial, { recursionLimit: (maxRevisionCycles + 2) * 8 }); + const runConfig: Record = { recursionLimit: (maxRevisionCycles + 2) * 8 }; + if (checkpointer && threadId) runConfig.configurable = { thread_id: threadId }; + + // Resume decision. `deleteThread` runs only on normal completion, so any + // checkpoint that still has pending next-nodes is a genuinely interrupted run + // (crash/abort) — this mirrors the legacy `outcome === 'running'` resume gate. + // Resuming runs invoke with `null` (continue from saved state); the td-seeded + // `initial` applies to fresh runs only. + let resuming = false; + if (checkpointer && threadId) { + const snapshot = await compiled.getState(runConfig); + resuming = snapshot.next.length > 0; + if (!resuming && snapshot.config.configurable?.checkpoint_id) { + // Stale terminal checkpoint (e.g. a crash during a prior cleanup): clear it + // so this run starts genuinely fresh rather than re-applying a done state. + await checkpointer.deleteThread(threadId); + } + } + + log.info('langgraph engine started', { + profile: args.profile, + maxRevisionCycles, + seeded: Boolean(seed), + resuming, + }); + if (resuming) notifier.send('Resuming interrupted run from checkpoint.'); + + await compiled.invoke(resuming ? null : initial, runConfig); + + // Reached END normally — drop the thread so a future run of this task starts + // fresh. Only an escaping error/abort leaves a resumable checkpoint behind. + if (checkpointer && threadId) await checkpointer.deleteThread(threadId); } diff --git a/src/pipeline.ts b/src/pipeline.ts index 6193efb..2347391 100644 --- a/src/pipeline.ts +++ b/src/pipeline.ts @@ -16,6 +16,7 @@ import { buildGraph } from './dag/builder.js'; import { executeGraph, type ExecuteGraphContext } from './dag/executor.js'; import { dispatchNode, type DispatchNodeRef } from './pipeline-dispatch.js'; import { executeLangGraph } from './langgraph/engine.js'; +import { createSqliteCheckpointer } from './langgraph/checkpointer.js'; import { loadEventsFromFile, reduceEvents } from './events/reducer.js'; import { restoreGraphState } from './dag/restore.js'; import type { PipelineGraph } from './dag/types.js'; @@ -131,10 +132,14 @@ async function runPipelineBody( }); if (process.env.CASE_ENGINE === 'langgraph') { - // Phase 1.1: LangGraph owns orchestration. Fresh runs only — event-log - // crash-resume stays legacy until the 1.2 SQLite checkpointer lands. A - // td-persisted pendingRevision still seeds a resume-at-implement. + // Phase 1.2: LangGraph owns orchestration AND crash/abort resume via the + // SQLite checkpointer (sibling DB in /.todos/, RFC §6). The thread is + // keyed by task id, so an interrupted run of the same task resumes from its + // last superstep; the engine drops the thread on normal completion. td still + // seeds the first run's pending revision (resume-at-implement) for a fresh + // start — the checkpoint is authoritative once a run has begun. await appender.append({ event: 'pipeline_start', taskId: task.id, profile, plan }); + const checkpointer = createSqliteCheckpointer(config.repoPath); await executeLangGraph({ profile, maxRevisionCycles, @@ -146,6 +151,8 @@ async function runPipelineBody( failedAgent = agent; }, initialPendingRevision: task.pendingRevision ?? null, + checkpointer, + threadId: task.id, }); } else { const graph = buildGraph(profile, maxRevisionCycles); From 5e82ce15cb0db0ea8af8255502c38a88b772fb99 Mon Sep 17 00:00:00 2001 From: Em Jones Date: Sun, 21 Jun 2026 16:40:32 -0700 Subject: [PATCH 05/17] feat(checkpoint): finish 1.3 --- MIGRATE_IMPLEMENTATION.md | 55 ++- src/__tests__/checkpointer-resume.spec.ts | 16 +- src/__tests__/dag-builder-scout.spec.ts | 79 ---- src/__tests__/dag-builder.spec.ts | 125 ------- src/__tests__/dag-executor.spec.ts | 390 -------------------- src/__tests__/dag-status.spec.ts | 100 ----- src/__tests__/events-appender.spec.ts | 185 +--------- src/__tests__/langgraph-parity.spec.ts | 55 ++- src/__tests__/node-projection.spec.ts | 133 +++++++ src/__tests__/phase-status.spec.ts | 57 +++ src/__tests__/pipeline.spec.ts | 28 +- src/dag/builder.ts | 236 ------------ src/dag/executor.ts | 429 ---------------------- src/dag/restore.ts | 44 --- src/dag/status.ts | 82 ----- src/dag/types.ts | 30 -- src/events/appender.ts | 53 +-- src/langgraph/engine.ts | 27 +- src/langgraph/projection.ts | 37 ++ src/pipeline.ts | 242 +++--------- 20 files changed, 397 insertions(+), 2006 deletions(-) delete mode 100644 src/__tests__/dag-builder-scout.spec.ts delete mode 100644 src/__tests__/dag-builder.spec.ts delete mode 100644 src/__tests__/dag-executor.spec.ts delete mode 100644 src/__tests__/dag-status.spec.ts create mode 100644 src/__tests__/node-projection.spec.ts create mode 100644 src/__tests__/phase-status.spec.ts delete mode 100644 src/dag/builder.ts delete mode 100644 src/dag/executor.ts delete mode 100644 src/dag/restore.ts delete mode 100644 src/dag/status.ts delete mode 100644 src/dag/types.ts create mode 100644 src/langgraph/projection.ts diff --git a/MIGRATE_IMPLEMENTATION.md b/MIGRATE_IMPLEMENTATION.md index 7270f09..899ce8f 100644 --- a/MIGRATE_IMPLEMENTATION.md +++ b/MIGRATE_IMPLEMENTATION.md @@ -1,6 +1,6 @@ # Migration: Custom DAG + Event-Sourcing → LangGraph + Langfuse -**Status:** In progress — Phase 1.2 complete (see §0). +**Status:** In progress — Phase 1.3 complete; **Phase 1 done** (see §0). Next: Phase 2.1 (Langfuse). **Author:** Case maintainers **Scope:** Replace Case's hand-rolled orchestration engine and granular event log with LangGraph (graph execution + checkpointing) and Langfuse (observability dispatch), without losing any existing feature. @@ -57,27 +57,54 @@ The LangGraph path now owns crash/abort resume via a SQLite checkpointer; the 1. 4. **Resume seed precedence.** The td-persisted `pendingRevision` seeds **fresh** runs only (`invoke(initial)`); on resume the checkpoint is authoritative and `invoke(null)` continues from it. 5. **No "dual-write" — parity proven by an in-process oracle instead.** §4 step 1.2 anticipated running both resume mechanisms side-by-side. What landed: each engine uses its own resume (legacy path = event replay; langgraph path = checkpointer); they are not both exercised in a single run. The §4 "assert restored graph state matches `reduceEvents` on the same crash point" guarantee is delivered by `checkpointer-resume.spec` — it crashes the LangGraph run mid-`implement_1` and asserts the checkpointer-restored `(revisionCycles, pendingRevision)` equals `reduceEvents` over the pre-crash event stream. Functionally the §4 acceptance; mechanically a test, not a runtime dual-write. -### ⏭ Next: Phase 1.3 — ⚠ BREAKING: resume cutover + default flip +### ✅ Phase 1.3 — ⚠ BREAKING: resume cutover + default flip — **DONE** -> Self-contained handoff for a fresh session. The LangGraph engine is feature-complete (orchestration + checkpointed resume); 1.3 is **deletion + default flip + relocating two projection side-effects**, guarded by the now-green parity suite. +LangGraph is now **unconditional**. The legacy DAG executor/builder, the event-replay resume path, and the `CASE_ENGINE` flag are gone; resume is checkpointer-only; the td mirror + evidence markers are written **node-direct** by the engine. The granular `run-*.jsonl` is still **written** (write-only observability sink until 2.2). Full suite green. -**Starting state.** `src/pipeline.ts` `runPipelineBody` branches on `process.env.CASE_ENGINE === 'langgraph'` (langgraph branch first; legacy DAG in the `else`). The langgraph branch already wires the checkpointer + `threadId`. Legacy is still the default (flag unset → `else`). +**Landed (two commits' worth; the flip is the single ⚠ BREAKING change):** -**Do, in order (each its own commit; the flip is the single ⚠ BREAKING commit):** +- **`src/pipeline.ts`.** `runPipelineBody` no longer branches on `CASE_ENGINE` — the LangGraph path is the only path. Deleted: the `else` block, the legacy resume block (`readdirFs`→`loadEventsFromFile`→`reduceEvents`→`restoreGraphState`→`appender.restoreState`), and the three seed helpers (`markCyclesCompleted`/`seedGraphFromTaskStatus`/`seedPendingRevision`). A td-persisted `pendingRevision` now seeds **`appender.getState().revisionCycles`** directly (ported from the legacy lines 197-199) so metrics + the retrospective snapshot see the pre-crash cycles even though no new `revision_requested` fires on a resumed run. The engine receives `store` + `caseRoot` for the node-direct writes. +- **Deleted modules:** `src/dag/{builder,executor,restore,status,types}.ts`. **KEPT (MOVE-verbatim, §9):** `src/dag/{fingerprint,merge,outcome-table}.ts` — still imported by the engine/dispatch. `src/dag/` now holds only those three. +- **`src/langgraph/projection.ts` (NEW).** `projectNodeState(state, store, caseRoot)` — the td-mirror + marker writer lifted verbatim out of `EventAppender.runProjections`. The engine calls it twice per phase in `runPhase`: once after `phase_start`+`emitStatus` (surfaces the running phase/status to td before the long dispatch) and once after `phase_end` (flips agent status to completed/failed and drops the `tested`/`reviewed` marker file in the same tick). Read source is still `PipelineState` via `appender.getState()` (the appender keeps maintaining it until 2.2); only the call site moved off the event hop. +- **`src/events/appender.ts`.** Now a **write-only JSONL sink + state container**: `runProjections` and the `projectTaskJson`/`projectMarkers` imports are gone, the `taskStore` ctor param is gone, and `restoreState` (dead with replay resume) was removed. `append()` = validate → write line → `applyEvent`. `getState()` still backs metrics + retrospective. +- **`src/langgraph/engine.ts`.** `LangGraphEngineArgs` gains `store` + `caseRoot`; `phaseStatus` is now **exported** (ported status-projection oracle). -1. **Flip default + delete legacy engine.** Make LangGraph unconditional; delete the `else` branch in `runPipelineBody` and the legacy resume block (`readdirFs` → `loadEventsFromFile` → `reduceEvents` → `restoreGraphState` → `appender.restoreState`, ~`src/pipeline.ts:153-198`), plus `src/dag/builder.ts`, `src/dag/executor.ts`, `src/dag/restore.ts`, and the legacy seed helpers (`markCyclesCompleted`/`seedGraphFromTaskStatus`/`seedPendingRevision`, ~`src/pipeline.ts:253-348`). Drop the `CASE_ENGINE` env read. Resume is checkpointer-only after this. - - **KEEP (MOVE-verbatim, §9):** `src/dag/fingerprint.ts`, `src/dag/outcome-table.ts`, `src/dag/merge.ts` — still referenced by the engine/dispatch. Verify importers before deleting anything under `src/dag/`. -2. **Relocate td-mirror + marker writes to node-direct.** Today `EventAppender` derives td status (`projectTaskJson`) and markers (`projectMarkers`) as a side-effect of `append()` (`src/events/appender.ts:72,76-84`). Move these to fire on **node completion** inside the engine (same synchronous point), then remove the derived writes from the appender. The raw JSONL appender **stays** (write-only observability sink until 2.2). Disk markers stay the gate truth (§1 constraint 4). -3. **Test triage (§9).** DIE now: `dag-builder.spec`, `dag-builder-scout.spec`, `dag-executor.spec`. PORT: `events-projections.spec` (`projectMarkers`/`projectTaskJson` → assert node-direct), `dag-status.spec` (→ LangGraph channels), `pipeline.spec` resume parts (→ checkpointer; split, don't blanket-delete). **Retire `events-reducer.spec` only after this cutover is green** — its oracle role is now held by `checkpointer-resume.spec`. NET-NEW: LangGraph graph-construction + conditional-edge routing test (routing still keys off `outcome-table`). +**Test triage (§9):** -*Acceptance (§4 1.3):* resume works with the replay path gone; td status + labels and marker files still update each phase; `runs.jsonl`/metrics unchanged; full suite green. +- **DIE (deleted):** `dag-builder.spec`, `dag-builder-scout.spec`, `dag-executor.spec`. +- **PORT:** `dag-status.spec` → **`phase-status.spec`** (asserts the engine's exported `phaseStatus` phase→status map; the legacy concurrent `evaluating` + graph-derived `merged` are intentionally absent — 1.1 deviation 3). `events-projections.spec` **kept as-is** (the projection functions are pure and unchanged until 2.2); the node-direct *write* behavior is the new `node-projection.spec`. `pipeline.spec` resume parts: the pendingRevision-seed resume tests **pass unchanged** (engine seeds from `initialPendingRevision`); the legacy **status-only** re-entry test was **deleted** (see deviation 1). +- **Converted:** `langgraph-parity.spec` → single-engine **routing oracle** (the legacy arm it compared against is gone; the 6 pinned `(phase, outcome)` sequences now stand alone as the conditional-edge contract — this is the §9 NET-NEW routing test). +- **Trimmed:** `events-appender.spec` lost its 3 projection/marker tests (moved to `node-projection.spec`) and the `restoreState` test; the append/sequence/runId/state coverage stays. +- **NET-NEW:** `node-projection.spec` (td write + marker-file drop + re-projection + dedupe — the evidence-gate coverage §9 requires node-direct). +- **Retained:** `events-reducer.spec` — `reducer.ts` is alive until 2.2 (the appender's `applyEvent` + the `checkpointer-resume.spec` oracle both depend on it). Retire with the rest at 2.2. +- **Fixed:** `checkpointer-resume.spec` now passes the engine a no-op `store` + `caseRoot` and a valid-enough stub state (empty phases/markers → no marker files, one no-op td write). + +**Validation:** typecheck ✅ · `oxlint` 0 errors (1 pre-existing warning in `interview/session.ts`) ✅ · AST self-lint ✅ · `oxfmt` ✅ · full suite **47 unit specs + 9 standalone, 0 fail** (`src/dev/run-tests.ts`, process-isolated) ✅. Branch `docs/migrate-langgraph-langfuse-rfc` — **not yet committed** at handoff (consistent with 1.1/1.2). + +**Deviations / decisions made during implementation:** + +1. **Legacy status-only resume dropped (by design).** `seedGraphFromTaskStatus` let a run resume mid-pipeline from a coarse td status with **no checkpoint** (e.g. td says `verifying` → skip to verify). Checkpointer-only resume removes this: with no checkpoint, a run starts fresh from scout. This is intentional per §5 decision 1 (td is a human mirror, **not** a resume source) — a genuinely interrupted run *has* a checkpoint and resumes correctly (`checkpointer-resume.spec`). The `pipeline.spec` test `re-entry from verifying status skips implement phase` was deleted; td-persisted **pendingRevision** seeding survives. +2. **Two projections per phase, not per event.** `projectNodeState` fires at `phase_start` (running mirror) and `phase_end` (completed + markers), vs the appender's old fire-on-every-`append`. This preserves the live "running" td status while dropping the event-hop coupling. `pendingRevision` in td is now written at the next implement's `phase_start` (state carries it from the `revision_requested` reducer) plus dispatch's direct `store.setPendingRevision` calls — net final td state unchanged. +3. **TS narrowing workaround.** With the legacy in-scope failed-node loop gone, TS control-flow analysis narrows `outcome` to its `'completed'` initializer (it can't see the dispatch/`onPhaseFailed` closures mutate it). The final `if` reads `(outcome as string) === 'failed'` to keep the runtime failure branch. +4. **Carried open item (1.1 deviation 4):** skipped-phase `phase_end` events are **still not emitted**. `projectMetrics.skippedPhases` fidelity is therefore unchanged by this phase. If wanted, emit them from the engine when a profile bypasses a node — deferred (no current consumer). + +### ⏭ Next: Phase 2.1 — Add Langfuse dispatch at the subscriber seam + +> Self-contained handoff. Phase 1 is done: LangGraph + checkpointer own orchestration/resume; the event log is a **write-only** sink; td/markers are node-direct. Phase 2 swaps observability to Langfuse. 2.1 is **additive, fire-and-forget, reversible** — no orchestration change, no cutover. + +**Do (RFC §4 2.1):** In `src/agent/adapters/pi-adapter.ts:68` (the existing `agent.subscribe(event)` seam — the single observability seam, §3), map pi events to Langfuse: `agent_start/end` → span (phase); `turn_start/turn_end` → **generation** (`message.usage` = tokens **and** pre-computed `cost`, confirmed at `pi-ai types.d.ts:144-157`); `tool_execution_start/end` → nested span; domain events → `event()`; verifier/reviewer rubrics → `score()`. Keep `onToolActivity`/`onAgentHeartbeat` feeding the TUI untouched (§1 constraint 3 — Langfuse can't drive a live local UI). **Langfuse failure must not affect the run** (§1 constraint 1, §7): wrap dispatch so a dropped/slow/unreachable Langfuse is a no-op for orchestration. Observability is **dual** (JSONL + Langfuse) after this — the log is deleted only in 2.2. + +**Deployment:** self-hosted via `podman-compose.yaml` (present, currently unused). Dispatch target is configurable — `LANGFUSE_HOST`/`LANGFUSE_BASE_URL` + public/secret keys, default to the compose service (§ Deployment). + +*Acceptance (§4 2.1):* a run produces a complete Langfuse trace with per-call token + cost; with Langfuse unreachable, the run still completes and the TUI feed is intact. **NET-NEW test:** assert *run completes + TUI feed intact with Langfuse unreachable* (§9, §7 risk row). **Gotchas for the next session:** -- Keep unit specs **process-isolated** (`src/dev/run-tests.ts`, concurrency 8) — Bun `mock.module()` leaks across files. Do not collapse to a single `bun test `. -- `better-sqlite3` does **not** load under Bun — never reach for the official sqlite checkpointer; the engine's checkpointer is the hand-rolled `BunSqliteSaver`. -- Deviation 4 (1.1): skipped-phase `phase_end` events still aren't emitted. If `projectMetrics.skippedPhases` fidelity is wanted, emit them when marker/td writes go node-direct here. +- **Run tests with `bun run test` (= `bun src/dev/run-tests.ts`, process-isolated, concurrency 8). This is the green, authoritative command.** A naive `bun test src/__tests__/` loads all specs into **one** process and Bun's process-global `mock.module()` leaks across files → **43 false failures** (was 38 pre-1.3; grew because 1.3 added `node-projection.spec` + converted `langgraph-parity.spec` + trimmed `events-appender.spec`, all of which register top-level mocks). Every spec passes in isolation; the isolated runner is **0 fail / 47 specs**. The 43 are leak victims (`createTask`, pipeline phase cases, …), not real regressions. *(If naive-`bun test` parity is ever wanted, add `mock.restore()` in an `afterAll` to the specs that `mock.module(...)` at top level — deferred; not blocking.)* +- `better-sqlite3` does **not** load under Bun — the engine's checkpointer is the hand-rolled `BunSqliteSaver`. +- The control path must **never read back from Langfuse** (§1 constraint 1, §7): the retrospective reads local `runs.jsonl` only. +- **Uncommitted:** all of Phase 1 (1.1 → 1.3) is on branch `docs/migrate-langgraph-langfuse-rfc`, **not yet committed**. 1.3 is staged as two logical commits (1: ⚠ BREAKING flip+delete+test-triage · 2: node-direct projections + `node-projection.spec`). Commit before starting 2.1 for a clean bisect. -**Not yet started:** Phase 1.3 (this), all of Phase 2 (Langfuse). The `podman-compose.yaml` Langfuse stack is present but unused until Phase 2. +**Not yet started:** Phase 2.1 (this) + Phase 2.2 (⚠ BREAKING: delete `src/events/{schema,appender,reducer}.ts` + `projectTaskJson`/`projectMarkers`/`projectMetrics`, re-point `ca watch` at the in-process callback stream per §5 decision 3, retire the DIE-at-2.2 event specs). The `podman-compose.yaml` Langfuse stack is present but unused until Phase 2. --- diff --git a/src/__tests__/checkpointer-resume.spec.ts b/src/__tests__/checkpointer-resume.spec.ts index 6c59f20..e91f9de 100644 --- a/src/__tests__/checkpointer-resume.spec.ts +++ b/src/__tests__/checkpointer-resume.spec.ts @@ -52,13 +52,25 @@ const verifierFail: AgentResult = { function recordingAppender(events: PipelineEvent[]) { let seq = 1; return { - getState: () => ({ status: 'active' }), + // Minimal-but-valid PipelineState shape for the node-direct projection + // (empty phases/markers → no marker files, a single no-op td write). + getState: () => ({ + status: 'active', + taskId: 'task-1', + profile: 'standard', + phases: new Map(), + markers: new Set(), + pendingRevision: null, + }), append: async (e: Record) => { events.push({ ...e, ts: new Date(0).toISOString(), sequence: seq++ } as unknown as PipelineEvent); }, }; } +/** Node-direct projection sink — the engine writes the td mirror here. */ +const noopStore = { writeFromProjection: async () => {} }; + const noopNotifier = { send() {}, phaseStart() {}, @@ -76,6 +88,8 @@ function baseArgs(appender: unknown, dispatch: DispatchFn, checkpointer: MemoryS profile: 'standard' as const, maxRevisionCycles: 2, appender: appender as never, + store: noopStore as never, + caseRoot: '/tmp/case-resume-spec-unused', notifier: noopNotifier as never, dispatch, onPhaseFailed: () => {}, diff --git a/src/__tests__/dag-builder-scout.spec.ts b/src/__tests__/dag-builder-scout.spec.ts deleted file mode 100644 index c1ce502..0000000 --- a/src/__tests__/dag-builder-scout.spec.ts +++ /dev/null @@ -1,79 +0,0 @@ -import { describe, test, expect } from 'bun:test'; -import { buildGraph, nodeId } from '../dag/builder.js'; - -describe('buildGraph — scout integration', () => { - describe('standard profile', () => { - const graph = buildGraph('standard', 2); - - test('scout_0 node exists and is the only root', () => { - expect(graph.nodes.has('scout_0')).toBe(true); - const scoutNode = graph.nodes.get('scout_0')!; - expect(scoutNode.phase).toBe('scout'); - expect(scoutNode.agent).toBe('scout'); - expect(scoutNode.cycle).toBe(0); - expect(scoutNode.state).toBe('pending'); - - // Scout has no incoming edges — it is the new root. - const incoming = graph.edges.filter((e) => e.to === 'scout_0'); - expect(incoming).toHaveLength(0); - }); - - test('scout_0 has an unconditional edge to implement_0', () => { - const edge = graph.edges.find((e) => e.from === 'scout_0' && e.to === 'implement_0'); - expect(edge).toBeDefined(); - expect(edge!.predicate).toBeUndefined(); - }); - - test('scout is added at cycle 0 only (no scout_1, scout_2)', () => { - expect(graph.nodes.has('scout_1')).toBe(false); - expect(graph.nodes.has('scout_2')).toBe(false); - }); - - test('revision cycles still wire correctly with scout present', () => { - // verify_0 → implement_1 (revision) — predicate guards - const toImpl1 = graph.edges.filter((e) => e.to === 'implement_1'); - expect(toImpl1.length).toBeGreaterThanOrEqual(2); - expect(toImpl1.every((e) => e.predicate !== undefined)).toBe(true); - }); - }); - - describe('tiny profile', () => { - const graph = buildGraph('tiny', 2); - - test('does NOT include a scout node', () => { - expect(graph.nodes.has('scout_0')).toBe(false); - for (const [id] of graph.nodes) { - expect(id.startsWith('scout_')).toBe(false); - } - }); - - test('implement_0 is the root', () => { - const incoming = graph.edges.filter((e) => e.to === 'implement_0'); - expect(incoming).toHaveLength(0); - }); - }); - - describe('zero revision cycles', () => { - const graph = buildGraph('standard', 0); - - test('scout still added before implement_0', () => { - expect(graph.nodes.has('scout_0')).toBe(true); - const edge = graph.edges.find((e) => e.from === 'scout_0' && e.to === 'implement_0'); - expect(edge).toBeDefined(); - }); - }); - - describe('cycle detection', () => { - test('graph with scout passes topological sort', () => { - expect(() => buildGraph('standard', 2)).not.toThrow(); - expect(() => buildGraph('standard', 0)).not.toThrow(); - expect(() => buildGraph('standard', 5)).not.toThrow(); - }); - }); - - describe('nodeId helper continues to work for scout', () => { - test('scout_0 follows the same naming convention', () => { - expect(nodeId('scout', 0)).toBe('scout_0'); - }); - }); -}); diff --git a/src/__tests__/dag-builder.spec.ts b/src/__tests__/dag-builder.spec.ts deleted file mode 100644 index efbf028..0000000 --- a/src/__tests__/dag-builder.spec.ts +++ /dev/null @@ -1,125 +0,0 @@ -import { describe, test, expect } from 'bun:test'; -import { buildGraph, nodeId } from '../dag/builder.js'; - -describe('buildGraph', () => { - describe('standard profile', () => { - const graph = buildGraph('standard', 2); - - test('has implement_0, verify_0, review_0, close, retrospective as base nodes', () => { - expect(graph.nodes.has('implement_0')).toBe(true); - expect(graph.nodes.has('verify_0')).toBe(true); - expect(graph.nodes.has('review_0')).toBe(true); - expect(graph.nodes.has('close')).toBe(true); - expect(graph.nodes.has('retrospective')).toBe(true); - }); - - test('has revision nodes up to maxRevisionCycles', () => { - expect(graph.nodes.has('implement_1')).toBe(true); - expect(graph.nodes.has('verify_1')).toBe(true); - expect(graph.nodes.has('review_1')).toBe(true); - expect(graph.nodes.has('implement_2')).toBe(true); - expect(graph.nodes.has('verify_2')).toBe(true); - expect(graph.nodes.has('review_2')).toBe(true); - }); - - test('total node count matches: scout + 3 per cycle * 3 cycles + close + retrospective', () => { - // scout_0 + 3 nodes per cycle (impl, verify, review) * 3 cycles + close + retro = 12 - expect(graph.nodes.size).toBe(12); - }); - - test('all nodes start as pending', () => { - for (const [, node] of graph.nodes) { - expect(node.state).toBe('pending'); - } - }); - - test('implement_0 has edge to verify_0, verify_0 has edge to review_0', () => { - const implEdges = graph.edges.filter((e) => e.from === 'implement_0'); - const implTargets = implEdges.map((e) => e.to); - expect(implTargets).toContain('verify_0'); - expect(implTargets).not.toContain('review_0'); - - const verifyEdges = graph.edges.filter((e) => e.from === 'verify_0' && e.to === 'review_0'); - expect(verifyEdges.length).toBe(1); - expect(verifyEdges[0].predicate).toBeDefined(); - }); - - test('verify_0 and review_0 have predicated edges to close', () => { - const toClose = graph.edges.filter((e) => e.to === 'close'); - const fromVerify0 = toClose.find((e) => e.from === 'verify_0'); - const fromReview0 = toClose.find((e) => e.from === 'review_0'); - expect(fromVerify0).toBeDefined(); - expect(fromReview0).toBeDefined(); - expect(fromVerify0!.predicate).toBeDefined(); - expect(fromReview0!.predicate).toBeDefined(); - }); - - test('evaluators have predicated edges to implement_1 for revision', () => { - const toImpl1 = graph.edges.filter((e) => e.to === 'implement_1'); - expect(toImpl1.length).toBe(2); // verify_0 → impl_1, review_0 → impl_1 - expect(toImpl1.every((e) => e.predicate !== undefined)).toBe(true); - }); - - test('close has unconditional edge to retrospective', () => { - const closeToRetro = graph.edges.find((e) => e.from === 'close' && e.to === 'retrospective'); - expect(closeToRetro).toBeDefined(); - expect(closeToRetro!.predicate).toBeUndefined(); - }); - - test('cycle field is set correctly on nodes', () => { - expect(graph.nodes.get('implement_0')!.cycle).toBe(0); - expect(graph.nodes.get('verify_1')!.cycle).toBe(1); - expect(graph.nodes.get('review_2')!.cycle).toBe(2); - }); - }); - - describe('tiny profile', () => { - const graph = buildGraph('tiny', 2); - - test('has no verify nodes', () => { - for (const [id] of graph.nodes) { - expect(id.startsWith('verify_')).toBe(false); - } - }); - - test('has implement and review nodes', () => { - expect(graph.nodes.has('implement_0')).toBe(true); - expect(graph.nodes.has('review_0')).toBe(true); - }); - - test('implement_0 has edge directly to review_0', () => { - const implToReview = graph.edges.find((e) => e.from === 'implement_0' && e.to === 'review_0'); - expect(implToReview).toBeDefined(); - }); - - test('total node count: 2 per cycle * 3 cycles + close + retro = 8', () => { - expect(graph.nodes.size).toBe(8); - }); - }); - - describe('zero revision cycles', () => { - const graph = buildGraph('standard', 0); - - test('has only scout + cycle 0 nodes plus close and retrospective', () => { - expect(graph.nodes.size).toBe(6); // scout_0, impl_0, verify_0, review_0, close, retro - }); - - test('no revision edges exist', () => { - const revisionEdges = graph.edges.filter((e) => e.to.startsWith('implement_1')); - expect(revisionEdges.length).toBe(0); - }); - }); - - describe('validation', () => { - test('graph passes cycle detection', () => { - expect(() => buildGraph('standard', 2)).not.toThrow(); - }); - }); - - describe('nodeId helper', () => { - test('formats as phase_cycle', () => { - expect(nodeId('implement', 0)).toBe('implement_0'); - expect(nodeId('verify', 2)).toBe('verify_2'); - }); - }); -}); diff --git a/src/__tests__/dag-executor.spec.ts b/src/__tests__/dag-executor.spec.ts deleted file mode 100644 index 63984cb..0000000 --- a/src/__tests__/dag-executor.spec.ts +++ /dev/null @@ -1,390 +0,0 @@ -import { describe, test, expect, beforeEach } from 'bun:test'; -import { buildGraph } from '../dag/builder.js'; -import { executeGraph, findReadyNodes } from '../dag/executor.js'; -import type { ExecuteGraphContext } from '../dag/executor.js'; -import type { AgentResult, PipelineConfig } from '../types.js'; -import type { DagNode, PipelineGraph } from '../dag/types.js'; -import type { PipelineState } from '../events/types.js'; -import type { PlanArtifact } from '../events/plan.js'; - -const PLAN: PlanArtifact = { - runId: 'run-1', - taskId: 'task-1', - profile: 'standard', - phases: [], - revisionBudget: 2, - modelConfig: {}, - generatedAt: '2026-01-01T00:00:00Z', -}; - -function makePassResult(overrides?: Partial): AgentResult { - return { - status: 'completed', - summary: 'done', - artifacts: { - commit: null, - filesChanged: [], - testsPassed: true, - screenshotUrls: [], - evidenceMarkers: [], - prUrl: null, - prNumber: null, - }, - error: null, - ...overrides, - }; -} - -function makeRevisionResult(source: 'verifier' | 'reviewer'): AgentResult { - return { - status: 'completed', - summary: `${source} found issues`, - artifacts: { - commit: null, - filesChanged: ['src/foo.ts'], - testsPassed: false, - screenshotUrls: [], - evidenceMarkers: [], - prUrl: null, - prNumber: null, - }, - rubric: { - role: source === 'verifier' ? 'verifier' : 'reviewer', - categories: [{ category: 'reproduced-scenario', verdict: 'fail', detail: 'test not passing' }], - }, - error: null, - }; -} - -class MockAppender { - events: Array<{ event: string; [key: string]: any }> = []; - private state: PipelineState = { - runId: 'run-1', - taskId: 'task-1', - profile: 'standard', - plan: PLAN, - status: 'active', - phases: new Map(), - currentPhase: null, - runningPhases: new Set(), - revisionCycles: 0, - pendingRevision: null, - markers: new Set(), - outcome: 'running', - startedAt: new Date().toISOString(), - lastSequence: 0, - }; - - async append(partial: any) { - this.events.push(partial); - if (partial.event === 'status_changed') { - this.state = { ...this.state, status: partial.to }; - } - } - - getState(): PipelineState { - return this.state; - } -} - -class MockNotifier { - messages: string[] = []; - phaseStart() {} - phaseEnd() {} - send(msg: string) { - this.messages.push(msg); - } - askUser() { - return Promise.resolve('Abort'); - } - toolStart() {} - toolEnd() {} - stepIndicator() {} - startHeartbeat() {} - stopHeartbeat() {} -} - -describe('findReadyNodes', () => { - test('returns root nodes (no incoming edges) that are pending — scout in standard profile', () => { - const graph = buildGraph('standard', 2); - const ready = findReadyNodes(graph); - expect(ready).toHaveLength(1); - expect(ready[0].id).toBe('scout_0'); - }); - - test('returns nothing when root node is already running', () => { - const graph = buildGraph('standard', 2); - graph.nodes.get('scout_0')!.state = 'running'; - const ready = findReadyNodes(graph); - expect(ready).toHaveLength(0); - }); - - test('after scout completes, implement_0 becomes ready', () => { - const graph = buildGraph('standard', 2); - graph.nodes.get('scout_0')!.state = 'completed'; - const ready = findReadyNodes(graph); - expect(ready.map((n) => n.id)).toEqual(['implement_0']); - }); - - test('returns only verify_0 when implement_0 is completed (review waits for verify)', () => { - const graph = buildGraph('standard', 2); - graph.nodes.get('scout_0')!.state = 'completed'; - graph.nodes.get('implement_0')!.state = 'completed'; - const ready = findReadyNodes(graph); - const ids = ready.map((n) => n.id).sort(); - expect(ids).toEqual(['verify_0']); - }); - - test('returns nothing when evaluators complete but predicates not satisfied', () => { - const graph = buildGraph('standard', 2); - graph.nodes.get('scout_0')!.state = 'completed'; - graph.nodes.get('implement_0')!.state = 'completed'; - graph.nodes.get('verify_0')!.state = 'completed'; - // review_0 still pending — close predicate needs both - const ready = findReadyNodes(graph); - // review_0 should be ready (implement_0 completed), but no others beyond that - expect(ready.map((n) => n.id)).toEqual(['review_0']); - }); -}); - -describe('executeGraph', () => { - let appender: MockAppender; - let notifier: MockNotifier; - - beforeEach(() => { - appender = new MockAppender(); - notifier = new MockNotifier(); - }); - - function makeContext(graph: PipelineGraph, phaseResponses: Map): ExecuteGraphContext { - return { - graph, - appender: appender as any, - config: {} as PipelineConfig, - notifier: notifier as any, - dispatchPhase: async (node: DagNode) => { - return phaseResponses.get(node.id) ?? makePassResult(); - }, - }; - } - - test('happy path: all phases pass, close and retrospective run', async () => { - const graph = buildGraph('standard', 2); - const responses = new Map(); - // All default to pass - - const ctx = makeContext(graph, responses); - await executeGraph(ctx); - - // impl_0, verify_0, review_0, close, retrospective should all be completed - expect(graph.nodes.get('implement_0')!.state).toBe('completed'); - expect(graph.nodes.get('verify_0')!.state).toBe('completed'); - expect(graph.nodes.get('review_0')!.state).toBe('completed'); - expect(graph.nodes.get('close')!.state).toBe('completed'); - expect(graph.nodes.get('retrospective')!.state).toBe('completed'); - - // Revision nodes should be skipped - expect(graph.nodes.get('implement_1')!.state).toBe('skipped'); - expect(graph.nodes.get('implement_2')!.state).toBe('skipped'); - }); - - test('verify and review run concurrently (both dispatched in same batch)', async () => { - const graph = buildGraph('standard', 0); - const dispatchOrder: string[] = []; - const ctx: ExecuteGraphContext = { - graph, - appender: appender as any, - config: {} as PipelineConfig, - notifier: notifier as any, - dispatchPhase: async (node: DagNode) => { - dispatchOrder.push(node.id); - return makePassResult(); - }, - }; - - await executeGraph(ctx); - - // verify_0 and review_0 should appear consecutively in dispatch order - const verifyIdx = dispatchOrder.indexOf('verify_0'); - const reviewIdx = dispatchOrder.indexOf('review_0'); - expect(verifyIdx).toBeGreaterThan(-1); - expect(reviewIdx).toBeGreaterThan(-1); - // They should be dispatched in the same batch (before close) - const closeIdx = dispatchOrder.indexOf('close'); - expect(verifyIdx).toBeLessThan(closeIdx); - expect(reviewIdx).toBeLessThan(closeIdx); - }); - - test('revision: verifier requests revision → implement_1 runs', async () => { - const graph = buildGraph('standard', 2); - const responses = new Map(); - responses.set('verify_0', makeRevisionResult('verifier')); - - const ctx = makeContext(graph, responses); - await executeGraph(ctx); - - expect(graph.nodes.get('implement_1')!.state).toBe('completed'); - expect(graph.nodes.get('verify_1')!.state).toBe('completed'); - expect(graph.nodes.get('review_1')!.state).toBe('completed'); - }); - - test('both evaluators request revision → merged revision request', async () => { - const graph = buildGraph('standard', 2); - const responses = new Map(); - responses.set('verify_0', makeRevisionResult('verifier')); - responses.set('review_0', makeRevisionResult('reviewer')); - - const ctx = makeContext(graph, responses); - await executeGraph(ctx); - - // revision_requested event should have been emitted - const revisionEvents = appender.events.filter((e) => e.event === 'revision_requested'); - expect(revisionEvents.length).toBeGreaterThanOrEqual(1); - - expect(graph.nodes.get('implement_1')!.state).toBe('completed'); - }); - - test('revision budget exhausted → close runs, remaining nodes skipped', async () => { - const graph = buildGraph('standard', 1); // only 1 revision cycle allowed - const responses = new Map(); - responses.set('verify_0', makeRevisionResult('verifier')); - responses.set('verify_1', makeRevisionResult('verifier')); - - const ctx = makeContext(graph, responses); - await executeGraph(ctx); - - // After revision at cycle 0, implement_1 runs. After revision at cycle 1, - // no implement_2 exists (maxRevisionCycles=1), so close should run - expect(graph.nodes.get('close')!.state).toBe('completed'); - expect(graph.nodes.get('retrospective')!.state).toBe('completed'); - }); - - test('implement fails → node marked failed, pipeline terminates', async () => { - const graph = buildGraph('standard', 2); - const responses = new Map(); - responses.set('implement_0', { - ...makePassResult(), - status: 'failed', - error: 'agent crashed', - }); - - const ctx = makeContext(graph, responses); - await executeGraph(ctx); - - expect(graph.nodes.get('implement_0')!.state).toBe('failed'); - // Downstream nodes should be skipped - expect(graph.nodes.get('verify_0')!.state).toBe('skipped'); - expect(graph.nodes.get('review_0')!.state).toBe('skipped'); - }); - - test('fingerprint match: identical failures across cycles → abort, emit fingerprint_match', async () => { - // Two cycles, both verify_0 and verify_1 return identical failure rubric. - const graph = buildGraph('standard', 2); - const responses = new Map(); - responses.set('verify_0', makeRevisionResult('verifier')); - responses.set('verify_1', makeRevisionResult('verifier')); - - const ctx = makeContext(graph, responses); - await executeGraph(ctx); - - const fpMatches = appender.events.filter((e) => e.event === 'fingerprint_match'); - expect(fpMatches.length).toBeGreaterThanOrEqual(1); - const match = fpMatches[0]; - expect(match.cycle).toBe(2); - expect(match.previousCycle).toBe(0); - expect(typeof match.fingerprint).toBe('string'); - expect((match.fingerprint as string).length).toBe(16); - - // After cycle-1 fingerprint match, implement_2 must not run. - // (Cycles 0 and 1 already completed before the fingerprint comparison - // detected the identical failure signature.) - expect(graph.nodes.get('implement_2')!.state).not.toBe('completed'); - expect(graph.nodes.get('verify_2')!.state).not.toBe('completed'); - expect(graph.nodes.get('close')!.state).toBe('completed'); - expect(graph.nodes.get('retrospective')!.state).toBe('completed'); - - // Budget-exhausted event should also be emitted alongside the match. - const budgetEvents = appender.events.filter((e) => e.event === 'revision_budget_exhausted'); - expect(budgetEvents.length).toBeGreaterThanOrEqual(1); - }); - - test('different failures across cycles → no fingerprint match, normal flow continues', async () => { - const graph = buildGraph('standard', 2); - const responses = new Map(); - // Cycle 0: verifier fails on reproduced-scenario - responses.set('verify_0', makeRevisionResult('verifier')); - // Cycle 1: different failed category — should NOT match - responses.set('verify_1', { - status: 'completed', - summary: 'verifier found different issues', - artifacts: { - commit: null, - filesChanged: ['src/bar.ts'], - testsPassed: false, - screenshotUrls: [], - evidenceMarkers: [], - prUrl: null, - prNumber: null, - }, - rubric: { - role: 'verifier', - categories: [{ category: 'edge-case-checked', verdict: 'fail', detail: 'missing edge case' }], - }, - error: null, - }); - - const ctx = makeContext(graph, responses); - await executeGraph(ctx); - - // No fingerprint_match event — fingerprints differ. - const fpMatches = appender.events.filter((e) => e.event === 'fingerprint_match'); - expect(fpMatches).toHaveLength(0); - - // Pipeline should proceed through cycle 2's implement (revision dispatched normally). - expect(graph.nodes.get('implement_2')!.state).toBe('completed'); - }); - - test('single-cycle pipeline (maxRevisionCycles=0): no fingerprint comparison runs', async () => { - const graph = buildGraph('standard', 0); - const responses = new Map(); - // Even if verify fails, there's no next cycle to compare against. - responses.set('verify_0', makeRevisionResult('verifier')); - - const ctx = makeContext(graph, responses); - await executeGraph(ctx); - - const fpMatches = appender.events.filter((e) => e.event === 'fingerprint_match'); - expect(fpMatches).toHaveLength(0); - }); - - test('evaluator passes (no revision request) → no fingerprint comparison runs', async () => { - const graph = buildGraph('standard', 2); - const ctx = makeContext(graph, new Map()); - await executeGraph(ctx); - - const fpMatches = appender.events.filter((e) => e.event === 'fingerprint_match'); - expect(fpMatches).toHaveLength(0); - }); - - test('tiny profile: no verify nodes, review runs directly after implement', async () => { - const graph = buildGraph('tiny', 1); - const dispatchOrder: string[] = []; - const ctx: ExecuteGraphContext = { - graph, - appender: appender as any, - config: {} as PipelineConfig, - notifier: notifier as any, - dispatchPhase: async (node: DagNode) => { - dispatchOrder.push(node.id); - return makePassResult(); - }, - }; - - await executeGraph(ctx); - - expect(dispatchOrder).toContain('implement_0'); - expect(dispatchOrder).toContain('review_0'); - expect(dispatchOrder).not.toContain('verify_0'); - expect(graph.nodes.get('close')!.state).toBe('completed'); - }); -}); diff --git a/src/__tests__/dag-status.spec.ts b/src/__tests__/dag-status.spec.ts deleted file mode 100644 index e6bf912..0000000 --- a/src/__tests__/dag-status.spec.ts +++ /dev/null @@ -1,100 +0,0 @@ -import { describe, test, expect } from 'bun:test'; -import { projectStatusFromGraph } from '../dag/status.js'; -import { buildGraph } from '../dag/builder.js'; -import type { PipelineGraph, DagNode } from '../dag/types.js'; - -function setNodeState(graph: PipelineGraph, nodeId: string, state: DagNode['state']) { - const node = graph.nodes.get(nodeId); - if (!node) throw new Error(`Node ${nodeId} not found`); - node.state = state; -} - -describe('projectStatusFromGraph', () => { - test('returns active when no nodes are running and first node is pending', () => { - const graph = buildGraph('standard', 2); - expect(projectStatusFromGraph(graph)).toBe('active'); - }); - - test('returns implementing when implement_0 is running', () => { - const graph = buildGraph('standard', 2); - setNodeState(graph, 'implement_0', 'running'); - expect(projectStatusFromGraph(graph)).toBe('implementing'); - }); - - test('returns verifying when only verify_0 is running', () => { - const graph = buildGraph('standard', 2); - setNodeState(graph, 'implement_0', 'completed'); - setNodeState(graph, 'verify_0', 'running'); - expect(projectStatusFromGraph(graph)).toBe('verifying'); - }); - - test('returns reviewing when only review_0 is running', () => { - const graph = buildGraph('standard', 2); - setNodeState(graph, 'implement_0', 'completed'); - setNodeState(graph, 'review_0', 'running'); - expect(projectStatusFromGraph(graph)).toBe('reviewing'); - }); - - test('returns evaluating when both verify_0 and review_0 are running', () => { - const graph = buildGraph('standard', 2); - setNodeState(graph, 'implement_0', 'completed'); - setNodeState(graph, 'verify_0', 'running'); - setNodeState(graph, 'review_0', 'running'); - expect(projectStatusFromGraph(graph)).toBe('evaluating'); - }); - - test('returns evaluating when both evaluators complete and close is pending', () => { - const graph = buildGraph('standard', 2); - setNodeState(graph, 'implement_0', 'completed'); - setNodeState(graph, 'verify_0', 'completed'); - setNodeState(graph, 'review_0', 'completed'); - expect(projectStatusFromGraph(graph)).toBe('evaluating'); - }); - - test('returns closing when close is running', () => { - const graph = buildGraph('standard', 2); - setNodeState(graph, 'implement_0', 'completed'); - setNodeState(graph, 'verify_0', 'completed'); - setNodeState(graph, 'review_0', 'completed'); - setNodeState(graph, 'close', 'running'); - expect(projectStatusFromGraph(graph)).toBe('closing'); - }); - - test('returns pr-opened when close is completed but retrospective is pending', () => { - const graph = buildGraph('standard', 2); - setNodeState(graph, 'implement_0', 'completed'); - setNodeState(graph, 'verify_0', 'completed'); - setNodeState(graph, 'review_0', 'completed'); - setNodeState(graph, 'close', 'completed'); - // skip unused revision nodes - for (let c = 1; c <= 2; c++) { - setNodeState(graph, `implement_${c}`, 'skipped'); - setNodeState(graph, `verify_${c}`, 'skipped'); - setNodeState(graph, `review_${c}`, 'skipped'); - } - expect(projectStatusFromGraph(graph)).toBe('pr-opened'); - }); - - test('returns merged when all nodes are completed/skipped', () => { - const graph = buildGraph('standard', 2); - setNodeState(graph, 'scout_0', 'completed'); - setNodeState(graph, 'implement_0', 'completed'); - setNodeState(graph, 'verify_0', 'completed'); - setNodeState(graph, 'review_0', 'completed'); - setNodeState(graph, 'close', 'completed'); - setNodeState(graph, 'retrospective', 'completed'); - for (let c = 1; c <= 2; c++) { - setNodeState(graph, `implement_${c}`, 'skipped'); - setNodeState(graph, `verify_${c}`, 'skipped'); - setNodeState(graph, `review_${c}`, 'skipped'); - } - expect(projectStatusFromGraph(graph)).toBe('merged'); - }); - - test('tiny profile: review_0 completed marks evaluating (no verify)', () => { - const graph = buildGraph('tiny', 2); - setNodeState(graph, 'implement_0', 'completed'); - setNodeState(graph, 'review_0', 'completed'); - expect(projectStatusFromGraph(graph)).toBe('evaluating'); - }); -}); diff --git a/src/__tests__/events-appender.spec.ts b/src/__tests__/events-appender.spec.ts index 7937628..c003636 100644 --- a/src/__tests__/events-appender.spec.ts +++ b/src/__tests__/events-appender.spec.ts @@ -1,10 +1,13 @@ import { describe, test, expect, afterAll, beforeEach } from 'bun:test'; -import { readFile, mkdir, rm, writeFile } from 'node:fs/promises'; +import { readFile, mkdir, rm } from 'node:fs/promises'; import { resolve } from 'node:path'; import { EventAppender } from '../events/appender.js'; import { LifecycleValidationError } from '../events/errors.js'; import type { PlanArtifact } from '../events/plan.js'; -import type { TaskJson } from '../types.js'; + +// Phase 1.3: the appender is now a write-only JSONL sink + state container. +// td-mirror / marker projection moved to node-direct writes — see +// node-projection.spec for that coverage. const PLAN: PlanArtifact = { runId: 'run-1', @@ -17,59 +20,9 @@ const PLAN: PlanArtifact = { }; const tmpDir = resolve(process.env.TMPDIR ?? '/tmp', `case-appender-test-${Date.now()}`); -let taskJsonPath: string; -let writtenProjections: Array>; - -class MockTaskStore { - taskJsonPath: string; - - constructor(path: string) { - this.taskJsonPath = path; - } - - async read(): Promise { - const raw = await readFile(this.taskJsonPath, 'utf-8'); - return JSON.parse(raw); - } - - async writeFromProjection(projected: Partial): Promise { - writtenProjections.push(projected); - const task = await this.read(); - Object.assign(task, projected); - await writeFile(this.taskJsonPath, JSON.stringify(task, null, 2) + '\n'); - } - - async readStatus() { - return (await this.read()).status; - } - async setStatus() {} - async setAgentPhase() {} - async setField() {} - async setPendingRevision() {} -} beforeEach(async () => { - writtenProjections = []; await mkdir(tmpDir, { recursive: true }); - taskJsonPath = resolve(tmpDir, '.task.json'); - await writeFile( - taskJsonPath, - JSON.stringify( - { - id: 'task-1', - status: 'active', - created: '2026-01-01T00:00:00Z', - repo: 'test-repo', - agents: {}, - tested: false, - manualTested: false, - prUrl: null, - prNumber: null, - }, - null, - 2, - ) + '\n', - ); }); afterAll(async () => { @@ -78,8 +31,7 @@ afterAll(async () => { describe('EventAppender', () => { test('appends valid event sequence to NDJSON file', async () => { - const store = new MockTaskStore(taskJsonPath) as any; - const appender = new EventAppender(tmpDir, 'task-1', 'run-1', store); + const appender = new EventAppender(tmpDir, 'task-1', 'run-1'); await appender.append({ event: 'pipeline_start', taskId: 'task-1', profile: 'standard', plan: PLAN }); await appender.append({ event: 'phase_start', phase: 'implement', agent: 'implementer' }); @@ -102,8 +54,7 @@ describe('EventAppender', () => { }); test('assigns monotonically increasing sequence numbers', async () => { - const store = new MockTaskStore(taskJsonPath) as any; - const appender = new EventAppender(tmpDir, 'task-1', 'run-2', store); + const appender = new EventAppender(tmpDir, 'task-1', 'run-2'); await appender.append({ event: 'pipeline_start', taskId: 'task-1', profile: 'standard', plan: PLAN }); await appender.append({ event: 'phase_start', phase: 'implement', agent: 'implementer' }); @@ -119,8 +70,7 @@ describe('EventAppender', () => { }); test('assigns consistent runId across all events', async () => { - const store = new MockTaskStore(taskJsonPath) as any; - const appender = new EventAppender(tmpDir, 'task-1', 'run-3', store); + const appender = new EventAppender(tmpDir, 'task-1', 'run-3'); await appender.append({ event: 'pipeline_start', taskId: 'task-1', profile: 'standard', plan: PLAN }); await appender.append({ event: 'phase_start', phase: 'implement', agent: 'implementer' }); @@ -136,8 +86,7 @@ describe('EventAppender', () => { }); test('allows concurrent phase starts (pipeline executor)', async () => { - const store = new MockTaskStore(taskJsonPath) as any; - const appender = new EventAppender(tmpDir, 'task-1', 'run-4', store); + const appender = new EventAppender(tmpDir, 'task-1', 'run-4'); await appender.append({ event: 'pipeline_start', taskId: 'task-1', profile: 'standard', plan: PLAN }); await appender.append({ event: 'phase_start', phase: 'implement', agent: 'implementer' }); @@ -149,8 +98,7 @@ describe('EventAppender', () => { }); test('rejects events after pipeline end', async () => { - const store = new MockTaskStore(taskJsonPath) as any; - const appender = new EventAppender(tmpDir, 'task-1', 'run-4b', store); + const appender = new EventAppender(tmpDir, 'task-1', 'run-4b'); await appender.append({ event: 'pipeline_start', taskId: 'task-1', profile: 'standard', plan: PLAN }); await appender.append({ event: 'pipeline_end', outcome: 'completed', durationMs: 100 }); @@ -161,8 +109,7 @@ describe('EventAppender', () => { }); test('updates in-memory state after each append', async () => { - const store = new MockTaskStore(taskJsonPath) as any; - const appender = new EventAppender(tmpDir, 'task-1', 'run-5', store); + const appender = new EventAppender(tmpDir, 'task-1', 'run-5'); await appender.append({ event: 'pipeline_start', taskId: 'task-1', profile: 'standard', plan: PLAN }); @@ -174,117 +121,9 @@ describe('EventAppender', () => { expect(appender.getState().currentPhase).toBe('implement_0'); }); - test('calls writeFromProjection on TaskStore after each event', async () => { - const store = new MockTaskStore(taskJsonPath) as any; - const appender = new EventAppender(tmpDir, 'task-1', 'run-6', store); - - await appender.append({ event: 'pipeline_start', taskId: 'task-1', profile: 'standard', plan: PLAN }); - await appender.append({ event: 'phase_start', phase: 'implement', agent: 'implementer' }); - - expect(writtenProjections.length).toBeGreaterThanOrEqual(2); - }); - test('throws when getState called before any events', () => { - const store = new MockTaskStore(taskJsonPath) as any; - const appender = new EventAppender(tmpDir, 'task-1', 'run-7', store); + const appender = new EventAppender(tmpDir, 'task-1', 'run-7'); expect(() => appender.getState()).toThrow('No events appended yet'); }); - - test('writes tested marker file on verify phase_end completed', async () => { - const store = new MockTaskStore(taskJsonPath) as any; - const appender = new EventAppender(tmpDir, 'task-1', 'run-marker-1', store); - - await appender.append({ event: 'pipeline_start', taskId: 'task-1', profile: 'standard', plan: PLAN }); - await appender.append({ event: 'phase_start', phase: 'implement', agent: 'implementer' }); - await appender.append({ - event: 'phase_end', - phase: 'implement', - agent: 'implementer', - outcome: 'completed', - durationMs: 100, - }); - await appender.append({ event: 'phase_start', phase: 'verify', agent: 'verifier' }); - await appender.append({ - event: 'phase_end', - phase: 'verify', - agent: 'verifier', - outcome: 'completed', - durationMs: 100, - }); - - const { existsSync } = await import('node:fs'); - const markerPath = resolve(tmpDir, '.case/task-1/tested'); - expect(existsSync(markerPath)).toBe(true); - - expect(appender.getState().markers.has('tested')).toBe(true); - - const lastProjection = writtenProjections[writtenProjections.length - 1]; - expect(lastProjection.tested).toBe(true); - }); - - test('writes reviewed marker file on review phase_end completed', async () => { - const store = new MockTaskStore(taskJsonPath) as any; - const appender = new EventAppender(tmpDir, 'task-1', 'run-marker-2', store); - - await appender.append({ event: 'pipeline_start', taskId: 'task-1', profile: 'standard', plan: PLAN }); - await appender.append({ event: 'phase_start', phase: 'implement', agent: 'implementer' }); - await appender.append({ - event: 'phase_end', - phase: 'implement', - agent: 'implementer', - outcome: 'completed', - durationMs: 100, - }); - await appender.append({ event: 'phase_start', phase: 'verify', agent: 'verifier' }); - await appender.append({ - event: 'phase_end', - phase: 'verify', - agent: 'verifier', - outcome: 'completed', - durationMs: 100, - }); - await appender.append({ event: 'phase_start', phase: 'review', agent: 'reviewer' }); - await appender.append({ - event: 'phase_end', - phase: 'review', - agent: 'reviewer', - outcome: 'completed', - durationMs: 100, - }); - - const { existsSync } = await import('node:fs'); - expect(existsSync(resolve(tmpDir, '.case/task-1/reviewed'))).toBe(true); - expect(appender.getState().markers.has('reviewed')).toBe(true); - }); - - test('restoreState allows resuming from existing state', async () => { - const store = new MockTaskStore(taskJsonPath) as any; - const appender = new EventAppender(tmpDir, 'task-1', 'run-8', store); - - const existingState = { - runId: 'run-8', - taskId: 'task-1', - profile: 'standard' as const, - plan: PLAN, - status: 'implementing' as const, - phases: new Map([ - ['implement_0', { phase: 'implement' as const, agent: 'implementer' as const, status: 'completed' as const }], - ]), - currentPhase: null, - runningPhases: new Set(), - revisionCycles: 0, - pendingRevision: null, - markers: new Set(), - outcome: 'running' as const, - startedAt: '2026-01-01T00:00:00Z', - lastSequence: 5, - }; - - appender.restoreState(existingState); - - await appender.append({ event: 'phase_start', phase: 'verify', agent: 'verifier' }); - const state = appender.getState(); - expect(state.currentPhase).toBe('verify_0'); - }); }); diff --git a/src/__tests__/langgraph-parity.spec.ts b/src/__tests__/langgraph-parity.spec.ts index 1573b22..fc92323 100644 --- a/src/__tests__/langgraph-parity.spec.ts +++ b/src/__tests__/langgraph-parity.spec.ts @@ -13,11 +13,13 @@ import { mkdir, rm } from 'node:fs/promises'; import { join } from 'node:path'; /** - * Phase 1.1 acceptance: a run through the LangGraph engine (`CASE_ENGINE=langgraph`) - * produces the *same phase outcomes* as the legacy DAG executor, over an identical - * mock runtime. Each case runs both engines against the same queued spawn results - * and asserts the `notifier.phaseEnd(phase, …, outcome)` sequence matches — and - * matches an explicit expected sequence (so the parity is pinned, not just mutual). + * LangGraph conditional-edge routing oracle (NET-NEW for Phase 1.3, §9). Each + * case drives the engine over a fixed queue of mock spawn results and pins the + * resulting `notifier.phaseEnd(phase, …, outcome)` sequence — covering the + * scout→implement→verify→review→close→retrospective flow plus the revision loop, + * budget cap, and fingerprint short-circuit. Originally the 1.1 cross-engine + * parity suite; the legacy executor it compared against was deleted in 1.3, so + * the pinned expected sequences now stand alone as the routing contract. */ // --- Pipeline-specific mocks (mirror pipeline.spec) --- @@ -161,43 +163,28 @@ const scoutResult: AgentResult = { type SpawnSpec = ReturnType; -/** Run one pipeline through the chosen engine and return the phaseEnd sequence. */ -async function runEngine( - engine: 'legacy' | 'langgraph', - specs: SpawnSpec[], - overrides: Partial = {}, -): Promise { +/** Run one pipeline through the engine and return the phaseEnd sequence. */ +async function runEngine(specs: SpawnSpec[], overrides: Partial = {}): Promise { mockSpawnAgent.mockReset(); for (const s of specs) mockSpawnAgent.mockResolvedValueOnce(s); const seq: string[] = []; const notifier = capturingNotifier(seq); - - const prev = process.env.CASE_ENGINE; - if (engine === 'langgraph') process.env.CASE_ENGINE = 'langgraph'; - else delete process.env.CASE_ENGINE; - try { - await runPipeline(makeConfig({ notifier: notifier as never, ...overrides })); - } finally { - if (prev === undefined) delete process.env.CASE_ENGINE; - else process.env.CASE_ENGINE = prev; - } + await runPipeline(makeConfig({ notifier: notifier as never, ...overrides })); return seq; } -/** Run a case through both engines, assert they match each other and `expected`. */ -async function assertParity( +/** Run a case and assert its (phase, outcome) sequence matches `expected`. */ +async function assertSequence( specs: SpawnSpec[], expected: string[], overrides: Partial = {}, ): Promise { - const legacy = await runEngine('legacy', specs, overrides); - const langgraph = await runEngine('langgraph', specs, overrides); - expect(legacy).toEqual(expected); - expect(langgraph).toEqual(expected); + const seq = await runEngine(specs, overrides); + expect(seq).toEqual(expected); } -describe('LangGraph ↔ legacy executor parity', () => { +describe('LangGraph engine routing (phase-outcome sequences)', () => { beforeEach(async () => { mockSpawnAgent.mockReset(); mockRunCommand.mockReset(); @@ -234,7 +221,7 @@ describe('LangGraph ↔ legacy executor parity', () => { }); it('standard profile happy path', async () => { - await assertParity( + await assertSequence( [spawn(scoutResult), spawn(completed), spawn(completed), spawn(completed), spawn(prResult), spawn(completed)], [ 'scout:completed', @@ -249,14 +236,14 @@ describe('LangGraph ↔ legacy executor parity', () => { it('tiny profile skips scout + verify', async () => { mockStoreRead.mockResolvedValue({ ...mockTask, profile: 'tiny' as const }); - await assertParity( + await assertSequence( [spawn(completed), spawn(completed), spawn(prResult), spawn(completed)], ['implement:completed', 'review:completed', 'close:completed', 'retrospective:completed'], ); }); it('verifier revision cycle (verify fails once, then clean)', async () => { - await assertParity( + await assertSequence( [ spawn(scoutResult), // scout spawn(completed), // implement c0 @@ -281,7 +268,7 @@ describe('LangGraph ↔ legacy executor parity', () => { }); it('reviewer soft-fail revision cycle', async () => { - await assertParity( + await assertSequence( [ spawn(scoutResult), // scout spawn(completed), // implement c0 @@ -308,7 +295,7 @@ describe('LangGraph ↔ legacy executor parity', () => { }); it('revision budget exhausted (maxRevisionCycles=1)', async () => { - await assertParity( + await assertSequence( [ spawn(scoutResult), // scout spawn(completed), // implement c0 @@ -334,7 +321,7 @@ describe('LangGraph ↔ legacy executor parity', () => { }); it('fingerprint short-circuit (identical failure two cycles running)', async () => { - await assertParity( + await assertSequence( [ spawn(scoutResult), // scout spawn(completed), // implement c0 diff --git a/src/__tests__/node-projection.spec.ts b/src/__tests__/node-projection.spec.ts new file mode 100644 index 0000000..6c61064 --- /dev/null +++ b/src/__tests__/node-projection.spec.ts @@ -0,0 +1,133 @@ +import { describe, test, expect, afterAll, beforeEach, mock } from 'bun:test'; +import { mkdir, rm, readFile, access } from 'node:fs/promises'; +import { resolve } from 'node:path'; +import { projectNodeState } from '../langgraph/projection.js'; +import type { TaskStore } from '../state/task-store.js'; +import type { PipelineState, PhaseState } from '../events/types.js'; +import type { PlanArtifact } from '../events/plan.js'; + +// Phase 1.3 step 2: the td mirror + evidence markers are written node-direct by +// the LangGraph engine via projectNodeState (relocated from EventAppender). This +// asserts the relocated write actually hits td and drops marker files — the +// guarantee §9 flags as must-stay-tested (markers are the evidence gates). + +const PLAN: PlanArtifact = { + runId: 'run-1', + taskId: 'task-1', + profile: 'standard', + phases: [], + revisionBudget: 2, + modelConfig: {}, + generatedAt: '2026-01-01T00:00:00Z', +}; + +const tmpDir = resolve(process.env.TMPDIR ?? '/tmp', `case-node-projection-${Date.now()}`); + +function makeState(overrides: Partial = {}): PipelineState { + return { + runId: 'run-1', + taskId: 'task-1', + profile: 'standard', + plan: PLAN, + status: 'verifying', + phases: new Map(), + currentPhase: null, + runningPhases: new Set(), + revisionCycles: 0, + pendingRevision: null, + markers: new Set(), + outcome: 'running', + startedAt: '2026-01-01T00:00:00Z', + lastSequence: 0, + ...overrides, + }; +} + +function makeStore() { + const writeFromProjection = mock(() => Promise.resolve(undefined)); + return { store: { writeFromProjection } as unknown as TaskStore, writeFromProjection }; +} + +const exists = (p: string) => + access(p).then( + () => true, + () => false, + ); + +beforeEach(async () => { + await mkdir(tmpDir, { recursive: true }); +}); + +afterAll(async () => { + await rm(tmpDir, { recursive: true, force: true }); +}); + +describe('projectNodeState', () => { + test('writes the td mirror from current pipeline state', async () => { + const { store, writeFromProjection } = makeStore(); + const state = makeState({ status: 'reviewing' }); + + await projectNodeState(state, store, tmpDir); + + expect(writeFromProjection).toHaveBeenCalled(); + const projected = writeFromProjection.mock.calls[0][0] as { id: string; status: string }; + expect(projected.id).toBe('task-1'); + expect(projected.status).toBe('reviewing'); + }); + + test('drops the tested marker file when verify completed', async () => { + const { store } = makeStore(); + const phases = new Map([ + ['verify_0', { phase: 'verify', agent: 'verifier', status: 'completed' }], + ]); + const state = makeState({ phases }); + + await projectNodeState(state, store, tmpDir); + + const markerPath = resolve(tmpDir, '.case/task-1/tested'); + expect(await exists(markerPath)).toBe(true); + expect((await readFile(markerPath, 'utf-8')).length).toBeGreaterThan(0); + // marker recorded in state so it isn't re-written + expect(state.markers.has('tested')).toBe(true); + }); + + test('drops the reviewed marker file when review completed', async () => { + const { store } = makeStore(); + const phases = new Map([ + ['review_0', { phase: 'review', agent: 'reviewer', status: 'completed' }], + ]); + const state = makeState({ phases }); + + await projectNodeState(state, store, tmpDir); + + expect(await exists(resolve(tmpDir, '.case/task-1/reviewed'))).toBe(true); + }); + + test('re-projects td after a marker lands so tested flag is fresh', async () => { + const { store, writeFromProjection } = makeStore(); + const phases = new Map([ + ['verify_0', { phase: 'verify', agent: 'verifier', status: 'completed' }], + ]); + const state = makeState({ phases }); + + await projectNodeState(state, store, tmpDir); + + // one write before the marker, one after + expect(writeFromProjection).toHaveBeenCalledTimes(2); + const last = writeFromProjection.mock.calls[1][0] as { tested: boolean }; + expect(last.tested).toBe(true); + }); + + test('does not re-write a marker already in state', async () => { + const { store, writeFromProjection } = makeStore(); + const phases = new Map([ + ['verify_0', { phase: 'verify', agent: 'verifier', status: 'completed' }], + ]); + const state = makeState({ phases, markers: new Set(['tested']) }); + + await projectNodeState(state, store, tmpDir); + + // marker already present → single td write, no re-projection + expect(writeFromProjection).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/__tests__/phase-status.spec.ts b/src/__tests__/phase-status.spec.ts new file mode 100644 index 0000000..aaa485d --- /dev/null +++ b/src/__tests__/phase-status.spec.ts @@ -0,0 +1,57 @@ +import { describe, test, expect } from 'bun:test'; +import { phaseStatus } from '../langgraph/engine.js'; +import type { CaseGraphStateType, LastPhase } from '../langgraph/state.js'; + +/** + * Ported from the legacy `dag-status.spec` (`projectStatusFromGraph`). The + * LangGraph engine emits a TaskStatus per running phase via `phaseStatus` + * rather than projecting from a node graph, so this asserts the phase→status + * mapping the td mirror keys off. The legacy concurrent `evaluating` and the + * graph-derived terminal `merged` states are intentionally not part of this map + * (RFC §0 1.1 deviation 3): the sequential engine never runs verify+review + * concurrently, and run completion is recorded via `pipeline_end`, not a status. + */ +function makeState(last: LastPhase | null = null): CaseGraphStateType { + return { + cycle: 0, + revisionCycles: 0, + pendingRevision: null, + fingerprints: {}, + last, + evaluator: null, + decision: null, + revisionClosed: false, + }; +} + +describe('phaseStatus', () => { + test('implement → implementing', () => { + expect(phaseStatus('implement', makeState())).toBe('implementing'); + }); + + test('verify → verifying', () => { + expect(phaseStatus('verify', makeState())).toBe('verifying'); + }); + + test('review → reviewing', () => { + expect(phaseStatus('review', makeState())).toBe('reviewing'); + }); + + test('close → closing', () => { + expect(phaseStatus('close', makeState())).toBe('closing'); + }); + + test('scout has no dedicated status (run stays active)', () => { + expect(phaseStatus('scout', makeState())).toBeNull(); + }); + + test('retrospective after a completed close → pr-opened', () => { + const state = makeState({ phase: 'close', status: 'completed', rubricFailed: false }); + expect(phaseStatus('retrospective', state)).toBe('pr-opened'); + }); + + test('retrospective on a failure path (close did not complete) → no status', () => { + const state = makeState({ phase: 'implement', status: 'failed', rubricFailed: false }); + expect(phaseStatus('retrospective', state)).toBeNull(); + }); +}); diff --git a/src/__tests__/pipeline.spec.ts b/src/__tests__/pipeline.spec.ts index 4461670..131e6bf 100644 --- a/src/__tests__/pipeline.spec.ts +++ b/src/__tests__/pipeline.spec.ts @@ -290,28 +290,12 @@ describe('runPipeline', () => { expect(mockSpawnAgent).toHaveBeenCalledTimes(3); }); - it('re-entry from verifying status skips implement phase', async () => { - const verifyingTask = { - ...mockTask, - status: 'verifying' as const, - agents: { verifier: { started: null, completed: null, status: 'running' as const } }, - }; - mockStoreRead.mockResolvedValue(verifyingTask); - - mockSpawnAgent - .mockResolvedValueOnce({ raw: agentRaw(completedAgentOutput), result: completedAgentOutput, durationMs: 100 }) // verifier - .mockResolvedValueOnce({ raw: agentRaw(completedAgentOutput), result: completedAgentOutput, durationMs: 100 }) // reviewer - .mockResolvedValueOnce({ raw: agentRaw(prAgentOutput), result: prAgentOutput, durationMs: 100 }) // closer - .mockResolvedValueOnce({ raw: '', result: completedAgentOutput, durationMs: 100 }); // retrospective - - await runPipeline(makeConfig()); - - // 4 agents: verifier, reviewer, closer, retrospective (no implementer) - expect(mockSpawnAgent).toHaveBeenCalledTimes(4); - // First spawn should be verifier, not implementer — check the prompt contains verifier template - const firstPrompt = mockSpawnAgent.mock.calls[0][0].prompt; - expect(firstPrompt).toContain('# verifier'); - }); + // NOTE: legacy "re-entry from skips earlier phases" resume (the + // `seedGraphFromTaskStatus` path) was removed in Phase 1.3. Resume is now + // checkpointer-only — a coarse td status with no checkpoint restarts fresh + // (RFC §5 decision 1: td is a human mirror, not a resume source). Genuine + // crash/abort resume is covered by checkpointer-resume.spec. A td-persisted + // pendingRevision still seeds resume-at-implement (tests below). it('dry-run mode passes all phases without spawning agents', async () => { await runPipeline(makeConfig({ dryRun: true })); diff --git a/src/dag/builder.ts b/src/dag/builder.ts deleted file mode 100644 index b60a1a8..0000000 --- a/src/dag/builder.ts +++ /dev/null @@ -1,236 +0,0 @@ -import type { PipelineProfile } from '../types.js'; -import { PROFILE_PHASES } from '../types.js'; -import type { DagEdge, DagNode, NodeId, PipelineGraph } from './types.js'; - -export function buildGraph(profile: PipelineProfile, maxRevisionCycles: number): PipelineGraph { - const nodes = new Map(); - const edges: DagEdge[] = []; - const phases = PROFILE_PHASES[profile]; - const hasVerify = phases.includes('verify'); - const hasScout = phases.includes('scout'); - - // Scout runs once per pipeline (cycle 0 only). Its findings are stable - // across revision cycles, so re-running it on every cycle would be wasted - // work. The scout node is added before `implement_0` and wires an - // unconditional edge into it — scout failure is non-blocking and the - // executor routes the implementer through regardless. - if (hasScout) { - nodes.set(nodeId('scout', 0), { - id: nodeId('scout', 0), - phase: 'scout', - agent: 'scout', - cycle: 0, - state: 'pending', - }); - } - - for (let cycle = 0; cycle <= maxRevisionCycles; cycle++) { - const implId = nodeId('implement', cycle); - nodes.set(implId, { - id: implId, - phase: 'implement', - agent: 'implementer', - cycle, - state: 'pending', - }); - - // Wire scout → implement_0 once the implement_0 node exists. - if (hasScout && cycle === 0) { - edges.push({ - from: nodeId('scout', 0), - to: implId, - }); - } - - if (hasVerify) { - const verifyId = nodeId('verify', cycle); - nodes.set(verifyId, { - id: verifyId, - phase: 'verify', - agent: 'verifier', - cycle, - state: 'pending', - }); - edges.push({ - from: implId, - to: verifyId, - }); - } - - const reviewId = nodeId('review', cycle); - nodes.set(reviewId, { - id: reviewId, - phase: 'review', - agent: 'reviewer', - cycle, - state: 'pending', - }); - - if (hasVerify) { - edges.push({ - from: nodeId('verify', cycle), - to: reviewId, - predicate: verifyPassedPredicate(cycle), - }); - } else { - edges.push({ - from: implId, - to: reviewId, - }); - } - - // Wire revision edges: evaluators at cycle N → implement at cycle N+1 - if (cycle < maxRevisionCycles) { - const nextImplId = nodeId('implement', cycle + 1); - if (hasVerify) { - edges.push({ - from: nodeId('verify', cycle), - to: nextImplId, - predicate: revisionRequestedPredicate(cycle, hasVerify), - }); - } - edges.push({ - from: nodeId('review', cycle), - to: nextImplId, - predicate: revisionRequestedPredicate(cycle, hasVerify), - }); - } - } - - // Evaluator completion edges → close directly. - for (let cycle = 0; cycle <= maxRevisionCycles; cycle++) { - const evaluatorIds = hasVerify ? [nodeId('verify', cycle), nodeId('review', cycle)] : [nodeId('review', cycle)]; - for (const evalId of evaluatorIds) { - edges.push({ - from: evalId, - to: 'close', - predicate: noRevisionPredicate(cycle, hasVerify), - }); - } - } - - // Close + retrospective - nodes.set('close', { - id: 'close', - phase: 'close', - agent: 'closer', - cycle: 0, - state: 'pending', - }); - - nodes.set('retrospective', { - id: 'retrospective', - phase: 'retrospective', - agent: 'retrospective', - cycle: 0, - state: 'pending', - }); - - edges.push({ from: 'close', to: 'retrospective' }); - - validateGraph(nodes, edges); - - return { nodes, edges }; -} - -export function nodeId(phase: string, cycle: number): NodeId { - return `${phase}_${cycle}`; -} - -function verifyPassedPredicate(cycle: number) { - return (graph: PipelineGraph): boolean => { - const verifyNode = graph.nodes.get(nodeId('verify', cycle)); - if (!verifyNode || verifyNode.state !== 'completed') return false; - if (hasRevisionResult(verifyNode)) { - // Allow review to run when there's no next implement (budget - // exhausted) or when the next implement has been explicitly skipped - // (e.g. fingerprint-match short-circuit in the executor). - const nextImpl = graph.nodes.get(nodeId('implement', cycle + 1)); - return !nextImpl || nextImpl.state === 'skipped'; - } - return true; - }; -} - -function noRevisionPredicate(cycle: number, hasVerify: boolean) { - return (graph: PipelineGraph): boolean => { - const reviewNode = graph.nodes.get(nodeId('review', cycle)); - if (!reviewNode || reviewNode.state !== 'completed') return false; - - if (hasVerify) { - const verifyNode = graph.nodes.get(nodeId('verify', cycle)); - if (!verifyNode || verifyNode.state !== 'completed') return false; - } - - // Check that no evaluator at this cycle has a failed rubric - const evaluators = hasVerify - ? [graph.nodes.get(nodeId('verify', cycle))!, graph.nodes.get(nodeId('review', cycle))!] - : [graph.nodes.get(nodeId('review', cycle))!]; - - if (evaluators.some((node) => hasRevisionResult(node))) { - // A revision was requested — don't proceed to close unless either - // (a) no next implement node exists (budget exhausted) or - // (b) the next implement has been explicitly skipped (e.g. fingerprint - // match short-circuit in the executor). - const nextImpl = graph.nodes.get(nodeId('implement', cycle + 1)); - if (nextImpl && nextImpl.state !== 'skipped') return false; - } - - return true; - }; -} - -function revisionRequestedPredicate(cycle: number, hasVerify: boolean) { - return (graph: PipelineGraph): boolean => { - if (hasVerify) { - const verifyNode = graph.nodes.get(nodeId('verify', cycle)); - if (!verifyNode || verifyNode.state !== 'completed') return false; - if (hasRevisionResult(verifyNode)) return true; - } - - const reviewNode = graph.nodes.get(nodeId('review', cycle)); - if (!reviewNode || reviewNode.state !== 'completed') return false; - return hasRevisionResult(reviewNode); - }; -} - -function hasRevisionResult(node: DagNode): boolean { - if (!node.result) return false; - if (node.result.rubric) { - return node.result.rubric.categories.some((c) => c.verdict === 'fail'); - } - return false; -} - -function validateGraph(nodes: Map, edges: DagEdge[]): void { - // Verify all edge endpoints exist - for (const edge of edges) { - if (!nodes.has(edge.from)) throw new Error(`Edge references missing source node: ${edge.from}`); - if (!nodes.has(edge.to)) throw new Error(`Edge references missing target node: ${edge.to}`); - } - - // Simple cycle detection via topological sort attempt - const inDegree = new Map(); - for (const id of nodes.keys()) inDegree.set(id, 0); - // Only count unconditional edges for cycle detection (predicated edges may not fire) - const unconditionalEdges = edges.filter((e) => !e.predicate); - for (const edge of unconditionalEdges) { - inDegree.set(edge.to, (inDegree.get(edge.to) ?? 0) + 1); - } - const queue = [...inDegree.entries()].filter(([, d]) => d === 0).map(([id]) => id); - let visited = 0; - while (queue.length > 0) { - const id = queue.shift()!; - visited++; - for (const edge of unconditionalEdges) { - if (edge.from === id) { - const remaining = (inDegree.get(edge.to) ?? 1) - 1; - inDegree.set(edge.to, remaining); - if (remaining === 0) queue.push(edge.to); - } - } - } - if (visited < nodes.size) { - throw new Error('Cycle detected in pipeline graph'); - } -} diff --git a/src/dag/executor.ts b/src/dag/executor.ts deleted file mode 100644 index ce87a98..0000000 --- a/src/dag/executor.ts +++ /dev/null @@ -1,429 +0,0 @@ -import type { AgentResult, PipelineConfig, RevisionRequest } from '../types.js'; -import type { EventAppender } from '../events/appender.js'; -import type { Notifier } from '../notify.js'; -import type { DagNode, PipelineGraph } from './types.js'; -import { nodeId } from './builder.js'; -import { computeFingerprint, fingerprintsMatch } from './fingerprint.js'; -import { mergeRevisionRequests } from './merge.js'; -import { projectStatusFromGraph } from './status.js'; - -export interface ExecuteGraphContext { - graph: PipelineGraph; - appender: EventAppender; - config: PipelineConfig; - notifier: Notifier; - dispatchPhase: (node: DagNode, revision?: RevisionRequest) => Promise; - initialRevisionRequests?: Map; -} - -export async function executeGraph(ctx: ExecuteGraphContext): Promise { - const { graph, appender } = ctx; - const revisionRequests = new Map(ctx.initialRevisionRequests ?? []); - /** - * Per-cycle failure fingerprints. Keyed by the cycle that produced the - * fingerprint (0-indexed). Comparing the new cycle's fingerprint to the - * previous one's lets the executor abort early when the same failure - * signature repeats. - */ - const cycleFingerprints = new Map(); - - while (true) { - const readyNodes = findReadyNodes(graph); - - if (readyNodes.length === 0) { - const hasRunning = [...graph.nodes.values()].some((n) => n.state === 'running'); - if (!hasRunning) break; - // Shouldn't happen — readyNodes empty while nodes are running means we're waiting - // but all running nodes should resolve via Promise.all below - break; - } - - for (const node of readyNodes) { - node.state = 'ready'; - } - - for (const node of readyNodes) { - node.state = 'running'; - node.startedAt = new Date().toISOString(); - } - - // Step indicator: visible phases derived from the current cycle's ready nodes, - // not the full graph (revision cycles would inflate the count). - emitStepIndicator(ctx, readyNodes); - - for (const node of readyNodes) { - await appender.append({ event: 'phase_start', phase: node.phase, agent: node.agent }); - ctx.notifier.phaseStart(node.phase, node.agent); - } - - await emitStatusChange(ctx); - - ctx.notifier.startHeartbeat(); - let results: Array<{ node: DagNode; result: AgentResult }>; - try { - results = await Promise.all( - readyNodes.map(async (node) => { - const pendingRevision = getPendingRevisionForNode(node, revisionRequests); - const result = await ctx.dispatchPhase(node, pendingRevision); - return { node, result }; - }), - ); - } finally { - ctx.notifier.stopHeartbeat(); - } - - for (const { node, result } of results) { - const elapsed = Date.now() - Date.parse(node.startedAt!); - node.result = result; - - if (result.status === 'completed') { - node.state = 'completed'; - node.completedAt = new Date().toISOString(); - - await appender.append({ - event: 'phase_end', - phase: node.phase, - agent: node.agent, - outcome: 'completed', - durationMs: elapsed, - result, - }); - ctx.notifier.phaseEnd(node.phase, node.agent, elapsed, 'completed'); - } else { - node.state = 'failed'; - node.completedAt = new Date().toISOString(); - - await appender.append({ - event: 'phase_end', - phase: node.phase, - agent: node.agent, - outcome: 'failed', - durationMs: elapsed, - result, - }); - ctx.notifier.phaseEnd(node.phase, node.agent, elapsed, 'failed'); - } - } - - // After evaluator pair completes at a given cycle, handle revision detection - await handleEvaluatorPairCompletion(ctx, revisionRequests, cycleFingerprints); - - // If any node failed, skip to retrospective - const hasFailed = [...graph.nodes.values()].some((n) => n.state === 'failed'); - if (hasFailed) { - // Skip all pending nodes except retrospective - for (const [, node] of graph.nodes) { - if (node.state === 'pending' && node.id !== 'retrospective') { - node.state = 'skipped'; - await appender.append({ - event: 'phase_end', - phase: node.phase, - agent: node.agent, - outcome: 'skipped', - durationMs: 0, - }); - } - } - // Force retrospective to ready - const retro = graph.nodes.get('retrospective'); - if (retro && retro.state === 'pending') { - retro.state = 'ready'; - retro.startedAt = new Date().toISOString(); - retro.state = 'running'; - await appender.append({ event: 'phase_start', phase: 'retrospective', agent: 'retrospective' }); - ctx.notifier.phaseStart('retrospective', 'retrospective'); - ctx.notifier.startHeartbeat(); - let result: AgentResult; - try { - result = await ctx.dispatchPhase(retro); - } finally { - ctx.notifier.stopHeartbeat(); - } - const elapsed = Date.now() - Date.parse(retro.startedAt!); - retro.result = result; - retro.state = 'completed'; - retro.completedAt = new Date().toISOString(); - await appender.append({ - event: 'phase_end', - phase: 'retrospective', - agent: 'retrospective', - outcome: 'completed', - durationMs: elapsed, - result, - }); - ctx.notifier.phaseEnd('retrospective', 'retrospective', elapsed, 'completed'); - } - break; - } - - await emitStatusChange(ctx); - } - - // Skip all remaining pending nodes - for (const [, node] of graph.nodes) { - if (node.state === 'pending') { - node.state = 'skipped'; - await appender.append({ - event: 'phase_end', - phase: node.phase, - agent: node.agent, - outcome: 'skipped', - durationMs: 0, - }); - } - } -} - -export function findReadyNodes(graph: PipelineGraph): DagNode[] { - const ready: DagNode[] = []; - - for (const [, node] of graph.nodes) { - if (node.state !== 'pending') continue; - - const incomingEdges = graph.edges.filter((e) => e.to === node.id); - - if (incomingEdges.length === 0) { - // Root nodes are always ready if pending - ready.push(node); - continue; - } - - // A node is ready if at least one incoming edge has: - // 1. Source node completed/skipped - // 2. Predicate satisfied (or no predicate) - const anySatisfied = incomingEdges.some((edge) => { - const source = graph.nodes.get(edge.from); - if (!source) return false; - if (source.state !== 'completed' && source.state !== 'skipped') return false; - if (edge.predicate && !edge.predicate(graph)) return false; - return true; - }); - - if (anySatisfied) { - ready.push(node); - } - } - - return ready; -} - -function getPendingRevisionForNode( - node: DagNode, - revisionRequests: Map, -): RevisionRequest | undefined { - if (node.phase !== 'implement' || node.cycle === 0) return undefined; - const requests = revisionRequests.get(node.cycle - 1); - if (!requests || requests.length === 0) return undefined; - return mergeRevisionRequests(requests); -} - -async function handleEvaluatorPairCompletion( - ctx: ExecuteGraphContext, - revisionRequests: Map, - cycleFingerprints: Map, -): Promise { - const { graph, appender } = ctx; - - for (const [, node] of graph.nodes) { - if (node.phase !== 'verify' && node.phase !== 'review') continue; - if (node.state !== 'completed') continue; - - const cycle = node.cycle; - if (revisionRequests.has(cycle)) continue; - - const verifyNode = graph.nodes.get(nodeId('verify', cycle)); - const reviewNode = graph.nodes.get(nodeId('review', cycle)); - - // Collect revision requests from completed evaluators - const requests: RevisionRequest[] = []; - for (const evalNode of [verifyNode, reviewNode].filter(Boolean) as DagNode[]) { - if (evalNode.state !== 'completed') continue; - const revision = extractRevisionFromResult(evalNode, cycle); - if (revision) requests.push(revision); - } - - // If verify found issues, act immediately (don't wait for review) - if (requests.length === 0) { - // Both must be complete for "no revision" conclusion - if (verifyNode && verifyNode.state !== 'completed') continue; - if (reviewNode && reviewNode.state !== 'completed') continue; - } - - if (requests.length > 0) { - const nextImplNode = graph.nodes.get(nodeId('implement', cycle + 1)); - - // Compute the fingerprint for this cycle's failure signature so we can - // (a) compare against the previous cycle for early-abort and - // (b) attach it to the merged RevisionRequest for downstream consumers. - const fingerprint = computeFingerprintFromRequests(requests); - - if (!nextImplNode) { - revisionRequests.set(cycle, []); - if (fingerprint) cycleFingerprints.set(cycle, fingerprint); - const sources = [...new Set(requests.map((r) => r.source))].join(', '); - await appender.append({ - event: 'revision_budget_exhausted', - cycles: cycle + 1, - }); - ctx.notifier.send( - `Revision budget exhausted after cycle ${cycle}. ${sources} found issues but no revision cycles remain. Proceeding with warnings.`, - ); - continue; - } - - // Compare to previous cycle's fingerprint. If they match, the same - // failure already came back once — burning another implementer cycle - // is statistically unlikely to help, so route through the - // budget-exhausted path. - const previousCycle = cycle - 1; - const previousFingerprint = previousCycle >= 0 ? cycleFingerprints.get(previousCycle) : undefined; - if (fingerprint && previousFingerprint && fingerprintsMatch(fingerprint, previousFingerprint)) { - cycleFingerprints.set(cycle, fingerprint); - revisionRequests.set(cycle, []); - - // Actively skip the next revision cycle's nodes so the DAG's - // predicate-driven dispatch doesn't run them anyway. The graph - // wires `verify_N → implement_{N+1}` via `revisionRequestedPredicate` - // which only inspects rubric verdicts — without this skip step, - // implement_{N+1} would fire despite the fingerprint match. - await skipRevisionTail(ctx, cycle + 1); - - await appender.append({ - event: 'fingerprint_match', - cycle: cycle + 1, - fingerprint, - previousCycle, - }); - await appender.append({ - event: 'revision_budget_exhausted', - cycles: cycle + 1, - }); - ctx.notifier.send( - `Revision budget exhausted: fingerprint match (cycle ${cycle} matched cycle ${previousCycle}, ${fingerprint}). Aborting revision cycle ${cycle + 1} and proceeding with warnings.`, - ); - continue; - } - - if (fingerprint) cycleFingerprints.set(cycle, fingerprint); - const merged = mergeRevisionRequests(requests); - if (fingerprint) merged.fingerprint = fingerprint; - // Replace the stored requests with fingerprint-annotated copies so - // downstream readers (`getPendingRevisionForNode`) see the merged value. - revisionRequests.set( - cycle, - requests.map((r) => (fingerprint ? { ...r, fingerprint } : r)), - ); - const sources = [...new Set(requests.map((r) => r.source))].join(', '); - await appender.append({ - event: 'revision_requested', - source: merged.source, - cycle: cycle + 1, - failedCategories: merged.failedCategories, - }); - ctx.notifier.send(`Revision cycle ${cycle + 1}: ${sources} found fixable issues, re-implementing`); - } else { - revisionRequests.set(cycle, []); - } - } -} - -/** - * Mark every revision-cycle node from `startCycle` onward (implement/verify/ - * review) as `skipped` and emit a corresponding `phase_end` event. Used by the - * fingerprint-match early-abort path to prevent the predicate-driven DAG from - * dispatching another cycle after we've already decided the failure repeats. - * - * Idempotent — nodes that are not pending are left alone. - */ -async function skipRevisionTail(ctx: ExecuteGraphContext, startCycle: number): Promise { - const { graph, appender } = ctx; - for (const [, node] of graph.nodes) { - if (node.phase !== 'implement' && node.phase !== 'verify' && node.phase !== 'review') continue; - if (node.cycle < startCycle) continue; - if (node.state !== 'pending') continue; - node.state = 'skipped'; - await appender.append({ - event: 'phase_end', - phase: node.phase, - agent: node.agent, - outcome: 'skipped', - durationMs: 0, - }); - } -} - -/** - * Derive a fingerprint from a cycle's revision requests. Returns `undefined` - * when there are no failed categories to hash — guards against false matches - * on empty inputs (see Failure Modes in spec-phase-2.md). - */ -function computeFingerprintFromRequests(requests: RevisionRequest[]): string | undefined { - const failedCategories: string[] = []; - const summaries: string[] = []; - for (const r of requests) { - for (const c of r.failedCategories) { - failedCategories.push(c.category); - } - if (r.summary) summaries.push(r.summary); - } - if (failedCategories.length === 0) return undefined; - return computeFingerprint({ - failedCategories, - errorSummary: summaries.join('\n'), - }); -} - -function extractRevisionFromResult(node: DagNode, cycle: number): RevisionRequest | null { - if (!node.result?.rubric) return null; - const failedCategories = node.result.rubric.categories.filter((c) => c.verdict === 'fail'); - if (failedCategories.length === 0) return null; - - const source = node.phase === 'verify' ? 'verifier' : 'reviewer'; - return { - source: source as 'verifier' | 'reviewer', - failedCategories, - summary: node.result.summary, - suggestedFocus: node.result.artifacts?.filesChanged ?? [], - cycle: cycle + 1, - }; -} - -async function emitStatusChange(ctx: ExecuteGraphContext): Promise { - const status = projectStatusFromGraph(ctx.graph); - const currentStatus = ctx.appender.getState().status; - if (currentStatus !== status) { - await ctx.appender.append({ event: 'status_changed', from: currentStatus, to: status }); - } -} - -/** - * Emit a step indicator for the visible (current-cycle) phases. - * Revision cycles create extra implement_N/verify_N/review_N nodes — we collapse - * them so the user sees a stable "5 phase" pipeline regardless of how many - * revision rounds happen. - */ -function emitStepIndicator(ctx: ExecuteGraphContext, readyNodes: DagNode[]): void { - const phases = visiblePhases(ctx.graph); - if (phases.length === 0) return; - - // Active phase = the first ready node's phase (or whichever is first by index). - const activePhase = readyNodes[0]?.phase ?? null; - const activeIdx = activePhase ? phases.indexOf(activePhase) : -1; - if (activeIdx < 0) return; - - const completed = phases.slice(0, activeIdx); - const pending = phases.slice(activeIdx + 1); - ctx.notifier.stepIndicator(completed, activePhase!, pending); -} - -/** - * Distinct ordered phase names from the graph (collapses cycle suffixes). - * Falls back to insertion order from graph.nodes. - */ -function visiblePhases(graph: import('./types.js').PipelineGraph): string[] { - const seen: string[] = []; - for (const [, node] of graph.nodes) { - if (!seen.includes(node.phase)) seen.push(node.phase); - } - return seen; -} diff --git a/src/dag/restore.ts b/src/dag/restore.ts deleted file mode 100644 index 1d19bbf..0000000 --- a/src/dag/restore.ts +++ /dev/null @@ -1,44 +0,0 @@ -import type { PipelineState } from '../events/types.js'; -import type { PipelineGraph } from './types.js'; - -export function restoreGraphState(graph: PipelineGraph, state: PipelineState): void { - for (const [key, phaseState] of state.phases) { - // Phase keys in PipelineState use the same format as graph node IDs: "phase_cycle" - const node = graph.nodes.get(key); - if (!node) { - // Try terminal nodes (close, retrospective) that don't have cycle suffixes. - const terminalNode = graph.nodes.get(phaseState.phase); - if (terminalNode) { - applyPhaseState(terminalNode, phaseState); - } - continue; - } - applyPhaseState(node, phaseState); - } -} - -function applyPhaseState( - node: import('./types.js').DagNode, - phaseState: import('../events/types.js').PhaseState, -): void { - switch (phaseState.status) { - case 'completed': - node.state = 'completed'; - node.startedAt = phaseState.startedAt; - node.completedAt = phaseState.completedAt; - if (phaseState.result) node.result = phaseState.result; - break; - case 'failed': - node.state = 'failed'; - node.startedAt = phaseState.startedAt; - node.completedAt = phaseState.completedAt; - if (phaseState.result) node.result = phaseState.result; - break; - case 'skipped': - node.state = 'skipped'; - break; - case 'running': - node.state = 'pending'; - break; - } -} diff --git a/src/dag/status.ts b/src/dag/status.ts deleted file mode 100644 index adbbd3f..0000000 --- a/src/dag/status.ts +++ /dev/null @@ -1,82 +0,0 @@ -import type { TaskStatus } from '../types.js'; -import type { PipelineGraph } from './types.js'; - -export function projectStatusFromGraph(graph: PipelineGraph): TaskStatus { - const running: string[] = []; - const runningPhases = new Set(); - - for (const [, node] of graph.nodes) { - if (node.state === 'running') { - running.push(node.id); - runningPhases.add(node.phase); - } - } - - // Both verify and review running concurrently - if (runningPhases.has('verify') && runningPhases.has('review')) return 'evaluating'; - - // Single running node - if (running.length > 0) { - const node = graph.nodes.get(running[0])!; - switch (node.phase) { - case 'implement': - return 'implementing'; - case 'verify': - return 'verifying'; - case 'review': - return 'reviewing'; - case 'close': - return 'closing'; - } - } - - // Both evaluators completed, close not yet started - const hasCompletedEvaluatorPair = findCompletedEvaluatorPair(graph); - if (hasCompletedEvaluatorPair) { - const closeNode = graph.nodes.get('close'); - if (closeNode && closeNode.state === 'pending') return 'evaluating'; - } - - // Close completed - const closeNode = graph.nodes.get('close'); - if (closeNode?.state === 'completed') { - // Check if all nodes are done - let allDone = true; - for (const [, node] of graph.nodes) { - if (node.state !== 'completed' && node.state !== 'skipped') { - allDone = false; - break; - } - } - if (allDone) return 'merged'; - return 'pr-opened'; - } - - return 'active'; -} - -function findCompletedEvaluatorPair(graph: PipelineGraph): boolean { - for (const [, node] of graph.nodes) { - if (node.phase === 'verify' && node.state === 'completed') { - const reviewNode = findMatchingReview(graph, node.cycle); - if (reviewNode?.state === 'completed') return true; - } - if (node.phase === 'review' && node.state === 'completed') { - // For tiny profile with no verify, check if close is pending - const verifyNode = findMatchingVerify(graph, node.cycle); - if (!verifyNode) { - // No verify in this graph — review alone is the evaluator pair - return true; - } - } - } - return false; -} - -function findMatchingReview(graph: PipelineGraph, cycle: number) { - return graph.nodes.get(`review_${cycle}`); -} - -function findMatchingVerify(graph: PipelineGraph, cycle: number) { - return graph.nodes.get(`verify_${cycle}`); -} diff --git a/src/dag/types.ts b/src/dag/types.ts deleted file mode 100644 index e8048b9..0000000 --- a/src/dag/types.ts +++ /dev/null @@ -1,30 +0,0 @@ -import type { AgentName, PipelinePhase } from '../types.js'; -import type { AgentResult } from '../types.js'; - -export type NodeId = string; - -export type NodeState = 'pending' | 'ready' | 'running' | 'completed' | 'failed' | 'skipped'; - -export interface DagNode { - id: NodeId; - phase: PipelinePhase; - agent: AgentName | 'retrospective'; - cycle: number; - state: NodeState; - result?: AgentResult; - startedAt?: string; - completedAt?: string; -} - -export type EdgePredicate = (graph: PipelineGraph) => boolean; - -export interface DagEdge { - from: NodeId; - to: NodeId; - predicate?: EdgePredicate; -} - -export interface PipelineGraph { - nodes: Map; - edges: DagEdge[]; -} diff --git a/src/events/appender.ts b/src/events/appender.ts index 6f37e36..b195c80 100644 --- a/src/events/appender.ts +++ b/src/events/appender.ts @@ -1,29 +1,25 @@ -import { appendFile, mkdir, writeFile } from 'node:fs/promises'; +import { appendFile, mkdir } from 'node:fs/promises'; import { resolve } from 'node:path'; -import type { TaskStore } from '../state/task-store.js'; import type { PipelineEvent, PipelineEventInput } from './schema.js'; import type { PipelineState } from './types.js'; import { validateTransition } from './errors.js'; import { applyEvent } from './reducer.js'; -import { projectTaskJson, projectMarkers } from './projections.js'; +/** + * Write-only JSONL event sink + in-memory `PipelineState` container. Phase 1.3 + * relocated the td-mirror + marker projections out of `append()` to node-direct + * writes in the LangGraph engine (see `langgraph/projection.ts`); the raw log + * stays as the observability sink until it is deleted in Phase 2.2. `getState()` + * still backs metrics and the retrospective snapshot. + */ export class EventAppender { private readonly filePath: string; - private readonly caseRoot: string; - private readonly taskSlug: string; private readonly runId: string; private state: PipelineState | null = null; private sequence = 0; private dirReady: Promise | null = null; - constructor( - caseRoot: string, - taskSlug: string, - runId: string, - private readonly taskStore: TaskStore, - ) { - this.caseRoot = caseRoot; - this.taskSlug = taskSlug; + constructor(caseRoot: string, taskSlug: string, runId: string) { this.runId = runId; const eventDir = resolve(caseRoot, '.case', taskSlug, 'events'); this.filePath = resolve(eventDir, `run-${runId}.jsonl`); @@ -48,8 +44,6 @@ export class EventAppender { await appendFile(this.filePath, JSON.stringify(event) + '\n'); this.state = applyEvent(this.state, event); - - await this.runProjections(); } getState(): PipelineState { @@ -60,33 +54,4 @@ export class EventAppender { get path(): string { return this.filePath; } - - restoreState(state: PipelineState): void { - this.state = state; - this.sequence = state.lastSequence; - } - - private async runProjections(): Promise { - if (!this.state) return; - - const taskJson = projectTaskJson(this.state); - await this.taskStore.writeFromProjection(taskJson); - - const markers = projectMarkers(this.state); - for (const marker of markers) { - if (!this.state.markers.has(marker.name)) { - const markerPath = resolve(this.caseRoot, marker.path); - const markerDir = resolve(markerPath, '..'); - await mkdir(markerDir, { recursive: true }); - await writeFile(markerPath, new Date().toISOString()); - this.state.markers.add(marker.name); - } - } - - // Re-project TaskJson now that markers are updated - if (markers.length > 0) { - const updatedTaskJson = projectTaskJson(this.state); - await this.taskStore.writeFromProjection(updatedTaskJson); - } - } } diff --git a/src/langgraph/engine.ts b/src/langgraph/engine.ts index c5132d7..cf2d37f 100644 --- a/src/langgraph/engine.ts +++ b/src/langgraph/engine.ts @@ -4,7 +4,9 @@ import type { AgentName, AgentResult, PipelinePhase, PipelineProfile, RevisionRe import { PROFILE_PHASES } from '../types.js'; import type { Notifier } from '../notify.js'; import type { EventAppender } from '../events/appender.js'; +import type { TaskStore } from '../state/task-store.js'; import type { DispatchNodeRef } from '../pipeline-dispatch.js'; +import { projectNodeState } from './projection.js'; import { computeFingerprint, fingerprintsMatch } from '../dag/fingerprint.js'; import { mergeRevisionRequests } from '../dag/merge.js'; import { createLogger } from '../util/logger.js'; @@ -18,8 +20,12 @@ export interface LangGraphEngineArgs { profile: PipelineProfile; maxRevisionCycles: number; appender: EventAppender; + /** Task-grain store — receives the node-direct td mirror (RFC §1.3 step 2). */ + store: TaskStore; + /** Repo data dir; marker files are written under `/.case//`. */ + caseRoot: string; notifier: Notifier; - /** Bound per-phase dispatcher (same closure the legacy executor uses). */ + /** Bound per-phase dispatcher (the engine-agnostic seam in pipeline-dispatch). */ dispatch: DispatchFn; /** * Mark the run failed on the shared pipeline closure (sets outcome + @@ -39,8 +45,14 @@ export interface LangGraphEngineArgs { threadId?: string; } -/** Maps a running phase to the TaskStatus the td mirror should show. */ -function phaseStatus(phase: PipelinePhase, state: CaseGraphStateType): TaskStatus | null { +/** + * Maps a running phase to the TaskStatus the td mirror should show. Exported for + * the status-projection spec (ported from the legacy `projectStatusFromGraph`): + * the LangGraph path emits status per-phase rather than scanning a node graph, + * so the legacy concurrent `evaluating` status is intentionally absent (RFC §0 + * 1.1 deviation 3). + */ +export function phaseStatus(phase: PipelinePhase, state: CaseGraphStateType): TaskStatus | null { switch (phase) { case 'implement': return 'implementing'; @@ -83,7 +95,7 @@ function fingerprintFor(request: RevisionRequest): string | undefined { * stay correct. */ export async function executeLangGraph(args: LangGraphEngineArgs): Promise { - const { appender, notifier, dispatch, onPhaseFailed, maxRevisionCycles } = args; + const { appender, store, caseRoot, notifier, dispatch, onPhaseFailed, maxRevisionCycles } = args; const phases = PROFILE_PHASES[args.profile]; const hasScout = phases.includes('scout'); const hasVerify = phases.includes('verify'); @@ -108,6 +120,9 @@ export async function executeLangGraph(args: LangGraphEngineArgs): Promise await appender.append({ event: 'phase_start', phase, agent }); notifier.phaseStart(phase, agent); await emitStatus(phase, state); + // Node-direct td mirror at phase start: surfaces the running phase + its new + // status to td/humans before the (possibly long) dispatch (RFC §1.3 step 2). + await projectNodeState(appender.getState(), store, caseRoot); notifier.startHeartbeat(); let result: AgentResult; @@ -120,6 +135,10 @@ export async function executeLangGraph(args: LangGraphEngineArgs): Promise const elapsed = Date.now() - Date.parse(startedAt); const outcome = result.status === 'completed' ? 'completed' : 'failed'; await appender.append({ event: 'phase_end', phase, agent, outcome, durationMs: elapsed, result }); + // Node-direct td mirror + evidence markers on completion: agent status flips + // to completed/failed and a passed verify/review drops its tested/reviewed + // marker file in the same tick. + await projectNodeState(appender.getState(), store, caseRoot); notifier.phaseEnd(phase, agent, elapsed, outcome); if (outcome === 'failed' && agent !== 'retrospective') onPhaseFailed(agent); return result; diff --git a/src/langgraph/projection.ts b/src/langgraph/projection.ts new file mode 100644 index 0000000..2982128 --- /dev/null +++ b/src/langgraph/projection.ts @@ -0,0 +1,37 @@ +import { mkdir, writeFile } from 'node:fs/promises'; +import { resolve } from 'node:path'; +import type { TaskStore } from '../state/task-store.js'; +import type { PipelineState } from '../events/types.js'; +import { projectTaskJson, projectMarkers } from '../events/projections.js'; + +/** + * Node-direct projection of the td mirror + evidence markers (RFC §1.3 step 2). + * + * Phase 1.2 derived these as a side-effect of every `EventAppender.append()`. + * Phase 1.3 moves the trigger to node completion inside the engine — the same + * synchronous point — so the writes no longer ride the event hop and survive the + * appender's eventual deletion in 2.2. The disk markers remain the gate truth + * (§1 constraint 4); td remains the coarse human mirror. + * + * The read source is still `PipelineState` (the appender keeps maintaining it via + * `applyEvent` until 2.2); only the call site moved. `state.markers` is mutated + * here to dedupe repeat writes, exactly as the appender did. + */ +export async function projectNodeState(state: PipelineState, store: TaskStore, caseRoot: string): Promise { + await store.writeFromProjection(projectTaskJson(state)); + + const markers = projectMarkers(state); + let wroteMarker = false; + for (const marker of markers) { + if (state.markers.has(marker.name)) continue; + const markerPath = resolve(caseRoot, marker.path); + await mkdir(resolve(markerPath, '..'), { recursive: true }); + await writeFile(markerPath, new Date().toISOString()); + state.markers.add(marker.name); + wroteMarker = true; + } + + // Re-project once markers landed so the td mirror's tested/manual-tested flags + // reflect the freshly-written evidence in the same node tick. + if (wroteMarker) await store.writeFromProjection(projectTaskJson(state)); +} diff --git a/src/pipeline.ts b/src/pipeline.ts index 2347391..4310a47 100644 --- a/src/pipeline.ts +++ b/src/pipeline.ts @@ -1,5 +1,4 @@ import type { AgentName, AgentResult, PipelineConfig, RevisionRequest, ScoutFindings } from './types.js'; -import { PROFILE_PHASES } from './types.js'; import { TaskStore } from './state/task-store.js'; import { formatDuration } from './notify.js'; import { createStructuredLogRenderer } from './render/structured-log.js'; @@ -12,14 +11,9 @@ import { generatePlan } from './events/plan.js'; import { projectMetrics } from './events/projections.js'; import { PiRuntimeAdapter } from './agent/adapters/pi-adapter.js'; import { createLogger } from './util/logger.js'; -import { buildGraph } from './dag/builder.js'; -import { executeGraph, type ExecuteGraphContext } from './dag/executor.js'; import { dispatchNode, type DispatchNodeRef } from './pipeline-dispatch.js'; import { executeLangGraph } from './langgraph/engine.js'; import { createSqliteCheckpointer } from './langgraph/checkpointer.js'; -import { loadEventsFromFile, reduceEvents } from './events/reducer.js'; -import { restoreGraphState } from './dag/restore.js'; -import type { PipelineGraph } from './dag/types.js'; const log = createLogger(); @@ -86,7 +80,7 @@ async function runPipelineBody( config.runtime ??= new PiRuntimeAdapter(); // Event log is mutable runtime state — lives under /.case//events/. - const appender = new EventAppender(config.dataDir, task.id, runId, store); + const appender = new EventAppender(config.dataDir, task.id, runId); config.eventAppender = appender; const plan = generatePlan(task, config, runId); @@ -131,100 +125,44 @@ async function runPipelineBody( }, }); - if (process.env.CASE_ENGINE === 'langgraph') { - // Phase 1.2: LangGraph owns orchestration AND crash/abort resume via the - // SQLite checkpointer (sibling DB in /.todos/, RFC §6). The thread is - // keyed by task id, so an interrupted run of the same task resumes from its - // last superstep; the engine drops the thread on normal completion. td still - // seeds the first run's pending revision (resume-at-implement) for a fresh - // start — the checkpoint is authoritative once a run has begun. - await appender.append({ event: 'pipeline_start', taskId: task.id, profile, plan }); - const checkpointer = createSqliteCheckpointer(config.repoPath); - await executeLangGraph({ - profile, - maxRevisionCycles, - appender, - notifier, - dispatch, - onPhaseFailed: (agent) => { - outcome = 'failed'; - failedAgent = agent; - }, - initialPendingRevision: task.pendingRevision ?? null, - checkpointer, - threadId: task.id, - }); - } else { - const graph = buildGraph(profile, maxRevisionCycles); - - // Crash recovery: restore graph state from event log if a prior run didn't complete - const existingEventLogPath = resolvePlan(config.dataDir, '.case', task.id, 'events'); - let resumed = false; - try { - const { readdir: readdirFs } = await import('node:fs/promises'); - const files = await readdirFs(existingEventLogPath); - const latestLog = files - .filter((f) => f.endsWith('.jsonl')) - .sort() - .pop(); - if (latestLog) { - const events = await loadEventsFromFile(resolvePlan(existingEventLogPath, latestLog)); - if (events.length > 0) { - const state = reduceEvents(events); - // Resume if the prior run didn't complete (no pipeline_end event) - if (state.outcome === 'running') { - restoreGraphState(graph, state); - appender.restoreState(state); - resumed = true; - } - } - } - } catch { - // No existing event log — fresh start - } - - let initialRevisionRequests: Map | undefined; - - if (!resumed) { - await appender.append({ event: 'pipeline_start', taskId: task.id, profile, plan }); - - if (task.pendingRevision) { - const revCycle = task.pendingRevision.cycle ?? 1; - const prevCycle = revCycle - 1; - markCyclesCompleted(graph, profile, 0, prevCycle); - seedPendingRevision(graph, task.pendingRevision); - initialRevisionRequests = new Map([[prevCycle, [task.pendingRevision]]]); - const state = appender.getState(); - state.revisionCycles = revCycle; - state.pendingRevision = task.pendingRevision; - resumed = true; - } else if (task.status !== 'active') { - seedGraphFromTaskStatus(graph, profile, task.status); - resumed = true; - } - } - - const ctx: ExecuteGraphContext = { - graph, - appender, - config, - notifier, - initialRevisionRequests, - dispatchPhase: dispatch, - }; - - await executeGraph(ctx); - - // Check if any node failed - for (const [, node] of graph.nodes) { - if (node.state === 'failed' && node.agent !== 'retrospective') { - outcome = 'failed'; - failedAgent = node.agent as AgentName; - break; - } - } + // LangGraph owns orchestration AND crash/abort resume via the SQLite + // checkpointer (sibling DB in /.todos/, RFC §6). The thread is keyed by + // task id, so an interrupted run of the same task resumes from its last + // superstep; the engine drops the thread on normal completion. td seeds the + // first run's pending revision (resume-at-implement); the checkpoint is + // authoritative once a run has begun. Resume is checkpointer-only — the legacy + // event-replay path was removed in Phase 1.3. + await appender.append({ event: 'pipeline_start', taskId: task.id, profile, plan }); + + // A td-persisted pending revision seeds the cumulative revision-cycle count so + // metrics + the retrospective snapshot see the pre-crash cycles even though no + // new `revision_requested` event fires on this resumed run. The graph state is + // seeded separately via `initialPendingRevision` (the engine routes to + // implement and carries the revision into the cycle counters). + if (task.pendingRevision) { + const seedState = appender.getState(); + seedState.revisionCycles = task.pendingRevision.cycle ?? 1; + seedState.pendingRevision = task.pendingRevision; } + const checkpointer = createSqliteCheckpointer(config.repoPath); + await executeLangGraph({ + profile, + maxRevisionCycles, + appender, + store, + caseRoot: config.dataDir, + notifier, + dispatch, + onPhaseFailed: (agent) => { + outcome = 'failed'; + failedAgent = agent; + }, + initialPendingRevision: task.pendingRevision ?? null, + checkpointer, + threadId: task.id, + }); + const totalDurationMs = Date.now() - Date.parse(appender.getState().startedAt); await appender.append({ event: 'pipeline_end', outcome, failedAgent, durationMs: totalDurationMs }); @@ -246,7 +184,10 @@ async function runPipelineBody( eventLog: appender.path, }); - if (outcome === 'failed') { + // `outcome` is mutated only via the dispatch/onPhaseFailed closures, which + // TS control-flow analysis can't see — it narrows `outcome` to its initializer + // here. Widen the read so the runtime 'failed' branch isn't compiled away. + if ((outcome as string) === 'failed') { notifier.send(`Pipeline failed at ${failedAgent ?? 'unknown'} phase.`); } else { notifier.send('Pipeline completed successfully.'); @@ -254,102 +195,5 @@ async function runPipelineBody( } // Per-phase dispatch (scout/implement/verify/review/close/retrospective) lives -// in `pipeline-dispatch.ts` so the legacy DAG executor and the LangGraph engine -// share identical semantics. See `dispatchNode` / `PipelineCallbacks`. - -function markCyclesCompleted( - graph: PipelineGraph, - profile: import('./types.js').PipelineProfile, - fromCycle: number, - toCycle: number, -): void { - const phases = PROFILE_PHASES[profile]; - // Scout runs only at cycle 0 and only once per pipeline. When the pending - // revision lives at cycle >= 1, scout has already completed. - if (fromCycle === 0 && phases.includes('scout')) { - const scoutNode = graph.nodes.get('scout_0'); - if (scoutNode && scoutNode.state === 'pending') { - scoutNode.state = 'completed'; - scoutNode.startedAt = new Date().toISOString(); - scoutNode.completedAt = new Date().toISOString(); - } - } - for (let c = fromCycle; c <= toCycle; c++) { - for (const phase of ['implement', 'verify', 'review']) { - if (phase === 'verify' && !phases.includes('verify')) continue; - const node = graph.nodes.get(`${phase}_${c}`); - if (node && node.state === 'pending') { - node.state = 'completed'; - node.startedAt = new Date().toISOString(); - node.completedAt = new Date().toISOString(); - } - } - } -} - -function seedGraphFromTaskStatus( - graph: PipelineGraph, - profile: import('./types.js').PipelineProfile, - status: import('./types.js').TaskStatus, -): void { - const phaseOrder = ['implementing', 'verifying', 'reviewing', 'evaluating', 'closing'] as const; - const phaseToNode: Record = { - implementing: 'implement_0', - verifying: 'verify_0', - reviewing: 'review_0', - evaluating: 'review_0', - closing: 'close', - }; - - // Scout has no dedicated TaskStatus — when we resume past `active`, the - // scout phase already ran (or was skipped because the profile didn't - // include it). Mark scout_0 completed so its outgoing edge to implement_0 - // is satisfied during resume. - if (status !== 'active' && PROFILE_PHASES[profile].includes('scout')) { - const scoutNode = graph.nodes.get('scout_0'); - if (scoutNode && scoutNode.state === 'pending') { - scoutNode.state = 'completed'; - scoutNode.startedAt = new Date().toISOString(); - scoutNode.completedAt = new Date().toISOString(); - } - } - - for (const phase of phaseOrder) { - if (phase === status) break; - const nodeId = phaseToNode[phase]; - if (!nodeId) continue; - if (phase === 'verifying' && !PROFILE_PHASES[profile].includes('verify')) continue; - const node = graph.nodes.get(nodeId); - if (node && node.state === 'pending') { - node.state = 'completed'; - node.startedAt = new Date().toISOString(); - node.completedAt = new Date().toISOString(); - } - } -} - -function seedPendingRevision(graph: PipelineGraph, revision: RevisionRequest): void { - const sourceCycle = (revision.cycle ?? 1) - 1; - const sourcePhase = revision.source === 'reviewer' ? 'review' : 'verify'; - const sourceNode = graph.nodes.get(`${sourcePhase}_${sourceCycle}`); - if (sourceNode) { - sourceNode.result = { - status: 'completed', - summary: revision.summary, - artifacts: { - commit: null, - filesChanged: revision.suggestedFocus, - testsPassed: null, - screenshotUrls: [], - evidenceMarkers: [], - prUrl: null, - prNumber: null, - }, - rubric: { - role: revision.source === 'reviewer' ? 'reviewer' : 'verifier', - categories: revision.failedCategories, - }, - error: null, - }; - } -} +// in `pipeline-dispatch.ts` so the LangGraph engine and the per-phase logic +// share one seam. See `dispatchNode` / `PipelineCallbacks`. From 72c08584bf59b9d623f89a724f0a616f61f6068c Mon Sep 17 00:00:00 2001 From: Em Jones Date: Mon, 22 Jun 2026 06:54:18 -0700 Subject: [PATCH 06/17] feat(langfuse): complete langfuse migration --- .env.example | 32 ++++ .gitignore | 3 + MIGRATE_IMPLEMENTATION.md | 33 +++- bun.lock | 5 + package.json | 3 + src/__tests__/langfuse-dispatch.spec.ts | 122 ++++++++++++ src/agent/adapters/pi-adapter.ts | 17 ++ src/phases/close.ts | 1 + src/phases/retrospective.ts | 1 + src/phases/review.ts | 1 + src/phases/scout.ts | 1 + src/phases/verify.ts | 1 + src/pipeline.ts | 10 + src/tracing/langfuse.ts | 213 +++++++++++++++++++++ src/types.ts | 4 + test/e2e/bunfig.toml | 4 + test/e2e/langfuse-llm-smoke.e2e.spec.ts | 63 ++++++ test/e2e/langfuse-mocked-agent.e2e.spec.ts | 131 +++++++++++++ test/e2e/readback.ts | 105 ++++++++++ 19 files changed, 741 insertions(+), 9 deletions(-) create mode 100644 .env.example create mode 100644 src/__tests__/langfuse-dispatch.spec.ts create mode 100644 src/tracing/langfuse.ts create mode 100644 test/e2e/bunfig.toml create mode 100644 test/e2e/langfuse-llm-smoke.e2e.spec.ts create mode 100644 test/e2e/langfuse-mocked-agent.e2e.spec.ts create mode 100644 test/e2e/readback.ts diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..3eeaa09 --- /dev/null +++ b/.env.example @@ -0,0 +1,32 @@ +# Template for podman-compose.yaml. Copy to `.env` and replace every secret. +# Generate strong values: `openssl rand -hex 32` +# cp .env.example .env + +# --- Core infra secrets (override the # CHANGEME defaults in the compose) --- +POSTGRES_PASSWORD=postgres +CLICKHOUSE_PASSWORD=clickhouse +MINIO_ROOT_PASSWORD=miniosecret +REDIS_AUTH=myredissecret +SALT=mysalt +ENCRYPTION_KEY=0000000000000000000000000000000000000000000000000000000000000000 +NEXTAUTH_SECRET=mysecret +NEXTAUTH_URL=http://localhost:3000 + +# --- Headless initialization --- +# On first boot langfuse-web auto-creates this org/project/user and provisions +# the API keys below. No manual UI signup needed. +# Docs: https://langfuse.com/self-hosting/administration/headless-initialization +LANGFUSE_INIT_ORG_ID=case +LANGFUSE_INIT_ORG_NAME=Case +LANGFUSE_INIT_PROJECT_ID=case +LANGFUSE_INIT_PROJECT_NAME=Case +LANGFUSE_INIT_PROJECT_PUBLIC_KEY=pk-lf-00000000-0000-0000-0000-000000000000 +LANGFUSE_INIT_PROJECT_SECRET_KEY=sk-lf-00000000-0000-0000-0000-000000000000 +LANGFUSE_INIT_USER_EMAIL=admin@case.local +LANGFUSE_INIT_USER_NAME=Case Admin +LANGFUSE_INIT_USER_PASSWORD=changeme123 + +# --- Client config (the `ca` app reads these; match the INIT keys above) --- +LANGFUSE_HOST=http://localhost:3000 +LANGFUSE_PUBLIC_KEY=pk-lf-00000000-0000-0000-0000-000000000000 +LANGFUSE_SECRET_KEY=sk-lf-00000000-0000-0000-0000-000000000000 diff --git a/.gitignore b/.gitignore index a91e09a..1558f3d 100644 --- a/.gitignore +++ b/.gitignore @@ -25,3 +25,6 @@ dist/ # bun artifacts *.bun-build .todos/ + +# Local secrets for podman-compose (use .env.example as the template) +.env diff --git a/MIGRATE_IMPLEMENTATION.md b/MIGRATE_IMPLEMENTATION.md index 899ce8f..29e1204 100644 --- a/MIGRATE_IMPLEMENTATION.md +++ b/MIGRATE_IMPLEMENTATION.md @@ -1,6 +1,6 @@ # Migration: Custom DAG + Event-Sourcing → LangGraph + Langfuse -**Status:** In progress — Phase 1.3 complete; **Phase 1 done** (see §0). Next: Phase 2.1 (Langfuse). +**Status:** In progress — **Phase 1 done** + **Phase 2.1 done** (Langfuse dispatch live-validated; see §0). Next: Phase 2.2 (⚠ BREAKING — delete the JSONL event log, cut over to Langfuse-only observability). **Author:** Case maintainers **Scope:** Replace Case's hand-rolled orchestration engine and granular event log with LangGraph (graph execution + checkpointing) and Langfuse (observability dispatch), without losing any existing feature. @@ -88,23 +88,38 @@ LangGraph is now **unconditional**. The legacy DAG executor/builder, the event-r 3. **TS narrowing workaround.** With the legacy in-scope failed-node loop gone, TS control-flow analysis narrows `outcome` to its `'completed'` initializer (it can't see the dispatch/`onPhaseFailed` closures mutate it). The final `if` reads `(outcome as string) === 'failed'` to keep the runtime failure branch. 4. **Carried open item (1.1 deviation 4):** skipped-phase `phase_end` events are **still not emitted**. `projectMetrics.skippedPhases` fidelity is therefore unchanged by this phase. If wanted, emit them from the engine when a profile bypasses a node — deferred (no current consumer). -### ⏭ Next: Phase 2.1 — Add Langfuse dispatch at the subscriber seam +### ✅ Phase 2.1 — Langfuse dispatch at the subscriber seam (additive, fire-and-forget) — **DONE** -> Self-contained handoff. Phase 1 is done: LangGraph + checkpointer own orchestration/resume; the event log is a **write-only** sink; td/markers are node-direct. Phase 2 swaps observability to Langfuse. 2.1 is **additive, fire-and-forget, reversible** — no orchestration change, no cutover. +Langfuse now receives a per-run trace fed from the single observability seam (`pi-adapter`), **additive** alongside the JSONL appender (dual until 2.2). No orchestration change; the control path never reads back (§7). Disabled (tracer `null`) when keys absent → Case runs exactly as before, JSONL-only. -**Do (RFC §4 2.1):** In `src/agent/adapters/pi-adapter.ts:68` (the existing `agent.subscribe(event)` seam — the single observability seam, §3), map pi events to Langfuse: `agent_start/end` → span (phase); `turn_start/turn_end` → **generation** (`message.usage` = tokens **and** pre-computed `cost`, confirmed at `pi-ai types.d.ts:144-157`); `tool_execution_start/end` → nested span; domain events → `event()`; verifier/reviewer rubrics → `score()`. Keep `onToolActivity`/`onAgentHeartbeat` feeding the TUI untouched (§1 constraint 3 — Langfuse can't drive a live local UI). **Langfuse failure must not affect the run** (§1 constraint 1, §7): wrap dispatch so a dropped/slow/unreachable Langfuse is a no-op for orchestration. Observability is **dual** (JSONL + Langfuse) after this — the log is deleted only in 2.2. +**Landed:** + +- **`src/tracing/langfuse.ts` (NEW).** `createLangfuseTracer(runId, task)` → `LangfuseTracer | null` (null when public/secret keys absent). One trace per run; `startAgentSpan(agent, phase)` opens a phase span; `AgentSpan` exposes `generation`/`toolStart`/`toolEnd`/`event`/`score`/`end`. `mapUsage` maps pi `usage` → Langfuse `usageDetails`/`costDetails` (snake_case; `total` summed by ingest). **Every method is self-defensive** (swallows its own error, logs, returns a `NOOP_SPAN` on span-open failure) — the §7 invariant that an unreachable/slow sink is a no-op rests here. `flushSafely` fire-and-forget; `shutdownSafely(timeoutMs=3000)` races shutdown against a timeout so a hung sink can't stall teardown. +- **`src/agent/adapters/pi-adapter.ts`.** At the existing `agent.subscribe` seam: `turn_end` → `span.generation(message)` (per-call tokens **and** pre-computed cost); `tool_execution_start/end` → nested `span.toolStart`/`toolEnd`; on completion `result.rubric` → `span.score`, then `span.end`; on throw `span.end(..., true)`. `onToolActivity`/`onHeartbeat` TUI feed left **untouched** (§1 constraint 3) — Langfuse calls sit beside them, not in front. +- **`src/pipeline.ts`.** Builds the tracer (`createLangfuseTracer(runId, { id: task.id })`), threads it via `config.langfuse`, and `await langfuse?.shutdownSafely()` at teardown (bounded; retrospective still reads local `runs.jsonl` only). +- **Tracer plumbing (the rest of the diff).** `src/types.ts` adds `langfuse?: LangfuseTracer | null` to **both** `PipelineConfig` and `SpawnAgentOptions`. Each phase entry (`src/phases/{scout,verify,review,close,retrospective}.ts`) passes `langfuse: config.langfuse` into its `spawn` options so every phase's agent emits to the per-run trace (+1 line each). `package.json` + `bun.lock` add the `langfuse` (^3.38) dependency. `.gitignore` adds `.env` (local secrets for the compose stack). +- **`.env.example` (NEW) + `podman-compose.yaml`.** Self-hosted stack template (headless init provisions org/project/keys); client reads `LANGFUSE_HOST` + public/secret keys. +- **`src/__tests__/langfuse-dispatch.spec.ts` (NEW, §9 NET-NEW).** The §7 risk-row oracle: keys absent → `null`; keys present + dead port (`127.0.0.1:1`) → the full adapter call sequence (span → generation → tool spans → event → score → end → flush/shutdown) **never throws**, tolerates malformed/empty inputs, and `shutdownSafely(200)` resolves bounded. In the default suite. +- **`test/e2e/` (NEW).** Live read-back proof (the only way to verify the wire). `readback.ts` (read-only client + `pollTrace`/`byName`/`ofType`, honors §7 by using a separate client); `bunfig.toml` disables the root preload so the tier drives the **real** `PiRuntimeAdapter` (root `mocks.ts` would stub the seam). Two gated tiers + `test:e2e`/`test:e2e:llm` scripts: + - **Tier 1 — `langfuse-mocked-agent.e2e.spec.ts`** (`LANGFUSE_E2E=1`, deterministic, no LLM): mock pi `Agent` emits a fixed event sequence through the real adapter + real tracer → live Langfuse, then reads the trace back and asserts phase span, nested tool span, a **generation with tokens AND cost**, and verifier rubric → scores. + - **Tier 2 — `langfuse-llm-smoke.e2e.spec.ts`** (`LANGFUSE_E2E_LLM=1`, billable, manual): real agent → real provider → live trace with a non-zero real per-call cost. Loose asserts (≥1 generation, cost>0). -**Deployment:** self-hosted via `podman-compose.yaml` (present, currently unused). Dispatch target is configurable — `LANGFUSE_HOST`/`LANGFUSE_BASE_URL` + public/secret keys, default to the compose service (§ Deployment). +**Validation:** typecheck ✅ · full suite **48 unit + 9 standalone, 0 fail** (process-isolated runner) ✅ · `langfuse-dispatch` no-op oracle ✅ · **Tier 1 e2e ran LIVE** against the running `podman-compose` Langfuse → **1 pass / 0 fail** (6.7s): read-back confirmed `phase:verify` span + `tool:bash` span + generation(tokens+cost) + `verifier:*` scores — the genuine §4 2.1 acceptance, on a real server. Tier 2 (real LLM) is gated/billable → not run (manual only). Branch `docs/migrate-langgraph-langfuse-rfc` — **not yet committed**. + +**Deviations / decisions made during implementation:** -*Acceptance (§4 2.1):* a run produces a complete Langfuse trace with per-call token + cost; with Langfuse unreachable, the run still completes and the TUI feed is intact. **NET-NEW test:** assert *run completes + TUI feed intact with Langfuse unreachable* (§9, §7 risk row). +1. **e2e specs live outside `src` → outside `tsc`.** `tsconfig.json` `include` is `["src"]` and excludes `src/__tests__`, so neither unit nor e2e specs are typechecked — consistent with the project stance that specs are validated by **running**, not by `tsc`. Tier 1 is validated by its live green run; widening `include` would force typechecking the deliberately-loose mock shapes (`any` pi events) and was left out of scope. +2. **`test/e2e/bunfig.toml` disables the root preload.** The root `bunfig` preloads `mocks.ts`, which stubs `spawnAgent` — that would short-circuit the very seam the e2e tier exists to validate. The tier must inherit no mocks. +3. **`generation` on `turn_end` only; domain `event()` exposed but not yet wired.** `agent_start/end` map to span open/close; pi `turn_start` carries no usage so only `turn_end` becomes a generation. `AgentSpan.event()` exists for §4's "domain events → `event()`" but the adapter still routes domain/tool events through the JSONL appender (dual observability) — no acceptance criterion rides on span-side `event()`, so it's deferred to avoid duplicate emission before the 2.2 cutover. **Gotchas for the next session:** -- **Run tests with `bun run test` (= `bun src/dev/run-tests.ts`, process-isolated, concurrency 8). This is the green, authoritative command.** A naive `bun test src/__tests__/` loads all specs into **one** process and Bun's process-global `mock.module()` leaks across files → **43 false failures** (was 38 pre-1.3; grew because 1.3 added `node-projection.spec` + converted `langgraph-parity.spec` + trimmed `events-appender.spec`, all of which register top-level mocks). Every spec passes in isolation; the isolated runner is **0 fail / 47 specs**. The 43 are leak victims (`createTask`, pipeline phase cases, …), not real regressions. *(If naive-`bun test` parity is ever wanted, add `mock.restore()` in an `afterAll` to the specs that `mock.module(...)` at top level — deferred; not blocking.)* +- **Run tests with `bun run test` (= `bun src/dev/run-tests.ts`, process-isolated, concurrency 8). This is the green, authoritative command.** A naive `bun test src/__tests__/` loads all specs into **one** process and Bun's process-global `mock.module()` leaks across files → **43 false failures** (was 38 pre-1.3; grew because 1.3 added `node-projection.spec` + converted `langgraph-parity.spec` + trimmed `events-appender.spec`, all of which register top-level mocks). Every spec passes in isolation; the isolated runner is **0 fail / 48 specs** (2.1 added `langfuse-dispatch.spec`). The 43 are leak victims (`createTask`, pipeline phase cases, …), not real regressions. *(If naive-`bun test` parity is ever wanted, add `mock.restore()` in an `afterAll` to the specs that `mock.module(...)` at top level — deferred; not blocking.)* - `better-sqlite3` does **not** load under Bun — the engine's checkpointer is the hand-rolled `BunSqliteSaver`. - The control path must **never read back from Langfuse** (§1 constraint 1, §7): the retrospective reads local `runs.jsonl` only. -- **Uncommitted:** all of Phase 1 (1.1 → 1.3) is on branch `docs/migrate-langgraph-langfuse-rfc`, **not yet committed**. 1.3 is staged as two logical commits (1: ⚠ BREAKING flip+delete+test-triage · 2: node-direct projections + `node-projection.spec`). Commit before starting 2.1 for a clean bisect. +- **Uncommitted:** all of Phase 1 (1.1 → 1.3) **and Phase 2.1** are on branch `docs/migrate-langgraph-langfuse-rfc`, **not yet committed**. Suggested commit boundaries: Phase 1.3 as two logical commits (1: ⚠ BREAKING flip+delete+test-triage · 2: node-direct projections + `node-projection.spec`); Phase 2.1 as one additive commit. **Full Phase 2.1 file set:** `src/tracing/langfuse.ts` (NEW), `src/agent/adapters/pi-adapter.ts`, `src/pipeline.ts`, `src/types.ts`, `src/phases/{scout,verify,review,close,retrospective}.ts`, `src/__tests__/langfuse-dispatch.spec.ts` (NEW), `test/e2e/` (NEW), `.env.example` (NEW), `.gitignore`, `package.json`, `bun.lock`. **⚠ Exclude `PROMPT.md`** (untracked, unrelated scratch — not part of the migration). Commit before starting 2.2 for a clean bisect. +- **e2e needs the live stack:** Tier 1 (`bun run test:e2e`, `LANGFUSE_E2E=1`) requires `podman-compose -f podman-compose.yaml up -d` and the seeded keys exported (the script runs `--cwd test/e2e`, so the root `.env` is **not** auto-loaded — export `LANGFUSE_HOST`/`LANGFUSE_PUBLIC_KEY`/`LANGFUSE_SECRET_KEY` inline). Default `bun run test` excludes `test/e2e` entirely. -**Not yet started:** Phase 2.1 (this) + Phase 2.2 (⚠ BREAKING: delete `src/events/{schema,appender,reducer}.ts` + `projectTaskJson`/`projectMarkers`/`projectMetrics`, re-point `ca watch` at the in-process callback stream per §5 decision 3, retire the DIE-at-2.2 event specs). The `podman-compose.yaml` Langfuse stack is present but unused until Phase 2. +**Not yet started:** Phase 2.2 (⚠ BREAKING: delete `src/events/{schema,appender,reducer}.ts` + `projectTaskJson`/`projectMarkers`/`projectMetrics`, re-point `ca watch` at the in-process callback stream per §5 decision 3, retire the DIE-at-2.2 event specs, and wire domain `event()` span-side once the JSONL sink is gone — see 2.1 deviation 3). The `podman-compose.yaml` Langfuse stack is now **in use** by Phase 2.1 (dispatch target + e2e read-back). --- diff --git a/bun.lock b/bun.lock index 90d0181..02fec40 100644 --- a/bun.lock +++ b/bun.lock @@ -12,6 +12,7 @@ "@mariozechner/pi-coding-agent": "^0.73.1", "@mariozechner/pi-tui": "^0.73.1", "@sinclair/typebox": "^0.34.49", + "langfuse": "^3.38.20", "pi-askuserquestion": "github:ghoseb/pi-askuserquestion", }, "devDependencies": { @@ -422,6 +423,10 @@ "koffi": ["koffi@2.15.2", "", {}, "sha512-r9tjJLVRSOhCRWdVyQlF3/Ugzeg13jlzS4czS82MAgLff4W+BcYOW7g8Y62t9O5JYjYOLAjAovAZDNlDfZNu+g=="], + "langfuse": ["langfuse@3.38.20", "", { "dependencies": { "langfuse-core": "^3.38.20" } }, "sha512-MAmBAASSzJtmK1O9HQegA1mFsQhT8Yf+OJRGvE7FXkyv3g/eiBE0glLD0Ohg3pkxhoPdggM5SejK7ue9ctlaMA=="], + + "langfuse-core": ["langfuse-core@3.38.20", "", { "dependencies": { "mustache": "^4.2.0" } }, "sha512-zBKVmQN/1oT5VWZUBYlWzvokIlkC/6mnpgr/2atMyTeAm+jR3ia7w2iJMjlrF5/oG8ukO1s8+LDRCzJpF1QeEA=="], + "langsmith": ["langsmith@0.7.10", "", { "dependencies": { "p-queue": "6.6.2" }, "peerDependencies": { "@opentelemetry/api": "*", "@opentelemetry/exporter-trace-otlp-proto": "*", "@opentelemetry/sdk-trace-base": "*", "openai": "*", "ws": ">=7" }, "optionalPeers": ["@opentelemetry/api", "@opentelemetry/exporter-trace-otlp-proto", "@opentelemetry/sdk-trace-base", "openai", "ws"] }, "sha512-3EjJx9zGMzqF60eT9JADHF+Hn/T5ayTgEVp4d3M5yvJIJi3q6seX0p5jT8ecBCWBi1kIvvssWrcDxfwgSier7Q=="], "long": ["long@5.3.2", "", {}, "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA=="], diff --git a/package.json b/package.json index b2d77a6..b588741 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,8 @@ "format": "oxfmt .", "format:check": "oxfmt --check .", "test": "bun src/dev/run-tests.ts", + "test:e2e": "bun test --cwd test/e2e langfuse-mocked-agent.e2e.spec.ts", + "test:e2e:llm": "bun test --cwd test/e2e langfuse-llm-smoke.e2e.spec.ts", "test:ast": "bun src/dev/test-ast-rules.ts", "lint:ast": "bun src/dev/lint-ast.ts target src/", "lint:ast:self": "bun src/dev/lint-ast.ts self src/", @@ -33,6 +35,7 @@ "@mariozechner/pi-coding-agent": "^0.73.1", "@mariozechner/pi-tui": "^0.73.1", "@sinclair/typebox": "^0.34.49", + "langfuse": "^3.38.20", "pi-askuserquestion": "github:ghoseb/pi-askuserquestion" }, "devDependencies": { diff --git a/src/__tests__/langfuse-dispatch.spec.ts b/src/__tests__/langfuse-dispatch.spec.ts new file mode 100644 index 0000000..a60f494 --- /dev/null +++ b/src/__tests__/langfuse-dispatch.spec.ts @@ -0,0 +1,122 @@ +import { describe, it, expect, beforeEach, afterEach } from 'bun:test'; +import { createLangfuseTracer } from '../tracing/langfuse.js'; +import type { Rubric } from '../types.js'; + +/** + * Phase 2.1 NET-NEW — the §7 risk-row oracle: **an unreachable/absent Langfuse is + * a no-op for the run.** Langfuse dispatch is fire-and-forget; the tracer is the + * wrapper the adapter trusts, so the guarantee that `pi-adapter` never throws into + * the control path (and never disturbs the onToolActivity/heartbeat TUI feed) rests + * entirely on every tracer method being self-defensive. This proves that contract: + * + * - keys absent → tracer is null → Case runs JSONL-only, unchanged. + * - keys present, sink unreachable → the full adapter call sequence + * (span → generation → tool spans → score → end → flush/shutdown) never throws. + * + * It deliberately points at a dead port so dispatch genuinely fails in the + * background; if any method propagated that failure, the run would break. + */ + +const KEYS = { + LANGFUSE_PUBLIC_KEY: 'pk-lf-test', + LANGFUSE_SECRET_KEY: 'sk-lf-test', + // Reserved, almost-certainly-closed port → every dispatch attempt fails. + LANGFUSE_HOST: 'http://127.0.0.1:1', +}; + +const SAVED: Record = {}; +const ENV_KEYS = ['LANGFUSE_PUBLIC_KEY', 'LANGFUSE_SECRET_KEY', 'LANGFUSE_HOST', 'LANGFUSE_BASE_URL']; + +beforeEach(() => { + for (const k of ENV_KEYS) { + SAVED[k] = process.env[k]; + delete process.env[k]; + } +}); + +afterEach(() => { + for (const k of ENV_KEYS) { + if (SAVED[k] === undefined) delete process.env[k]; + else process.env[k] = SAVED[k]; + } +}); + +const TASK = { id: 'repo-123-fix', title: 'Fix the thing' }; + +const VERIFIER_RUBRIC: Rubric = { + role: 'verifier', + categories: [ + { category: 'reproduced-scenario', verdict: 'pass', detail: 'ran the repro' }, + { category: 'edge-case-checked', verdict: 'fail', detail: 'missed null path' }, + ], +}; + +const PI_MESSAGE = { + model: 'claude-sonnet-4-20250514', + usage: { + input: 1200, + output: 340, + cacheRead: 800, + cacheWrite: 0, + totalTokens: 1540, + cost: { input: 0.0036, output: 0.0051, cacheRead: 0.0006, cacheWrite: 0, total: 0.0093 }, + }, +}; + +describe('langfuse dispatch — disabled when keys absent', () => { + it('returns null without public/secret keys (JSONL-only, unchanged behavior)', () => { + expect(createLangfuseTracer('run-1', TASK)).toBeNull(); + }); + + it('returns null when only one key is present', () => { + process.env.LANGFUSE_PUBLIC_KEY = 'pk-only'; + expect(createLangfuseTracer('run-2', TASK)).toBeNull(); + }); +}); + +describe('langfuse dispatch — unreachable sink is a no-op for the run', () => { + beforeEach(() => Object.assign(process.env, KEYS)); + + it('constructs a tracer when keys are present', () => { + const tracer = createLangfuseTracer('run-3', TASK); + expect(tracer).not.toBeNull(); + }); + + it('drives the full adapter event sequence without throwing', () => { + const tracer = createLangfuseTracer('run-4', TASK)!; + + // Exactly the call order pi-adapter.ts issues per spawn. + expect(() => { + const span = tracer.startAgentSpan('verifier', 'verify'); + span.generation(PI_MESSAGE); // turn_end + span.toolStart('t1', 'bash', { cmd: 'bun test' }); // tool_execution_start + span.toolEnd('t1', 'bash', { exitCode: 0 }, false); // tool_execution_end + span.event('scout_completed', { findings: 3 }); // domain event + span.score(VERIFIER_RUBRIC); // rubric → score() + span.end({ status: 'completed' }, false); // agent_end + }).not.toThrow(); + }); + + it('tolerates malformed / empty inputs (no usage, unknown tool end, NA verdicts)', () => { + const tracer = createLangfuseTracer('run-5', TASK)!; + expect(() => { + const span = tracer.startAgentSpan('scout'); + span.generation({}); // no model, no usage + span.toolEnd('never-started', 'grep', undefined, true); // end without start + span.score({ role: 'reviewer', categories: [{ category: 'pattern-fit', verdict: 'na', detail: '' }] }); + span.end(); + }).not.toThrow(); + }); + + it('flushSafely never throws against a dead sink', () => { + const tracer = createLangfuseTracer('run-6', TASK)!; + expect(() => tracer.flushSafely()).not.toThrow(); + }); + + it('shutdownSafely resolves (bounded) against a dead sink', async () => { + const tracer = createLangfuseTracer('run-7', TASK)!; + // Tight timeout: proves the race-against-timeout bound — a hung sink cannot + // stall run teardown. + await expect(tracer.shutdownSafely(200)).resolves.toBeUndefined(); + }); +}); diff --git a/src/agent/adapters/pi-adapter.ts b/src/agent/adapters/pi-adapter.ts index 5867c29..43d9b8e 100644 --- a/src/agent/adapters/pi-adapter.ts +++ b/src/agent/adapters/pi-adapter.ts @@ -68,14 +68,24 @@ export class PiRuntimeAdapter implements CaseAgentRuntime { let responseText = ''; const toolTimers = new Map(); + // Langfuse phase span (Phase 2.1). Optional + fire-and-forget: every method + // swallows its own errors, so an unreachable/slow sink never affects the run + // and never disturbs the onToolActivity/heartbeat TUI feed below. + const span = options.langfuse?.startAgentSpan(options.agentName, options.phase); + agent.subscribe((event: any) => { if (event.type === 'message_update' && event.assistantMessageEvent.type === 'text_delta') { responseText += event.assistantMessageEvent.delta; } + // turn_end carries the assistant message with per-call usage (tokens + cost). + if (event.type === 'turn_end') { + span?.generation(event.message); + } if (event.type === 'tool_execution_start') { if (options.onHeartbeat) options.onHeartbeat(Date.now() - start); toolTimers.set(event.toolCallId, Date.now()); const sanitizedArgs = sanitizeForTrace(event.args); + span?.toolStart(event.toolCallId, event.toolName, sanitizedArgs); // Renderer hook — wrap in try/catch so rendering bugs never kill the agent. if (options.onToolActivity) { try { @@ -110,6 +120,7 @@ export class PiRuntimeAdapter implements CaseAgentRuntime { const toolStart = toolTimers.get(event.toolCallId); toolTimers.delete(event.toolCallId); const durationMs = toolStart ? Date.now() - toolStart : 0; + span?.toolEnd(event.toolCallId, event.toolName, sanitizeForTrace(event.result), event.isError); if (options.onToolActivity) { try { options.onToolActivity({ @@ -155,6 +166,10 @@ export class PiRuntimeAdapter implements CaseAgentRuntime { const result = parseAgentResult(responseText); log.info('agent completed', { agent: options.agentName, durationMs, status: result.status }); + // Verifier/reviewer rubrics → Langfuse scores; then close the phase span. + if (result.rubric) span?.score(result.rubric); + span?.end({ status: result.status, summary: result.summary }, result.status === 'failed'); + return { raw: responseText, result, durationMs }; } catch (err) { clearTimeout(timer); @@ -164,6 +179,8 @@ export class PiRuntimeAdapter implements CaseAgentRuntime { log.error('agent spawn failed', { agent: options.agentName, durationMs, error: errorMsg }); + span?.end({ error: errorMsg }, true); + return { raw: '', result: { diff --git a/src/phases/close.ts b/src/phases/close.ts index ed21517..890b89e 100644 --- a/src/phases/close.ts +++ b/src/phases/close.ts @@ -55,6 +55,7 @@ export async function runClosePhase( onToolActivity: config.onToolActivity, traceWriter: config.traceWriter, eventAppender: config.eventAppender, + langfuse: config.langfuse, phase: 'close', }); diff --git a/src/phases/retrospective.ts b/src/phases/retrospective.ts index 269951e..9cddac4 100644 --- a/src/phases/retrospective.ts +++ b/src/phases/retrospective.ts @@ -103,6 +103,7 @@ export async function runRetrospectivePhase( onToolActivity: config.onToolActivity, traceWriter: config.traceWriter, eventAppender: config.eventAppender, + langfuse: config.langfuse, phase: 'retrospective', }); log.phase('retrospective', 'completed'); diff --git a/src/phases/review.ts b/src/phases/review.ts index ceac9cd..78add30 100644 --- a/src/phases/review.ts +++ b/src/phases/review.ts @@ -57,6 +57,7 @@ export async function runReviewPhase( onToolActivity: config.onToolActivity, traceWriter: config.traceWriter, eventAppender: config.eventAppender, + langfuse: config.langfuse, phase: 'review', }); diff --git a/src/phases/scout.ts b/src/phases/scout.ts index a42e4bd..946d75f 100644 --- a/src/phases/scout.ts +++ b/src/phases/scout.ts @@ -73,6 +73,7 @@ export async function runScoutPhase(config: PipelineConfig, store: TaskStore): P onToolActivity: config.onToolActivity, traceWriter: config.traceWriter, eventAppender: config.eventAppender, + langfuse: config.langfuse, phase: 'scout', }); diff --git a/src/phases/verify.ts b/src/phases/verify.ts index fd80094..dae9fa7 100644 --- a/src/phases/verify.ts +++ b/src/phases/verify.ts @@ -59,6 +59,7 @@ export async function runVerifyPhase( onToolActivity: config.onToolActivity, traceWriter: config.traceWriter, eventAppender: config.eventAppender, + langfuse: config.langfuse, phase: 'verify', }); diff --git a/src/pipeline.ts b/src/pipeline.ts index 4310a47..901bff6 100644 --- a/src/pipeline.ts +++ b/src/pipeline.ts @@ -14,6 +14,7 @@ import { createLogger } from './util/logger.js'; import { dispatchNode, type DispatchNodeRef } from './pipeline-dispatch.js'; import { executeLangGraph } from './langgraph/engine.js'; import { createSqliteCheckpointer } from './langgraph/checkpointer.js'; +import { createLangfuseTracer } from './tracing/langfuse.js'; const log = createLogger(); @@ -83,6 +84,11 @@ async function runPipelineBody( const appender = new EventAppender(config.dataDir, task.id, runId); config.eventAppender = appender; + // Langfuse dispatch (Phase 2.1) — additive, fire-and-forget. Null when keys + // are unset, in which case observability stays JSONL-only. Never blocks the run. + const langfuse = createLangfuseTracer(runId, { id: task.id }); + config.langfuse = langfuse; + const plan = generatePlan(task, config, runId); const { mkdir: mkdirPlan, writeFile: writePlan } = await import('node:fs/promises'); @@ -176,6 +182,10 @@ async function runPipelineBody( parentTaskId: task.contractPath, }); + // Flush the Langfuse trace. Bounded so a hung/unreachable sink can't stall run + // teardown; the retrospective already read local runs.jsonl, never Langfuse. + await langfuse?.shutdownSafely(); + log.info('pipeline finished', { outcome, failedAgent, diff --git a/src/tracing/langfuse.ts b/src/tracing/langfuse.ts new file mode 100644 index 0000000..6690c5d --- /dev/null +++ b/src/tracing/langfuse.ts @@ -0,0 +1,213 @@ +/** + * Langfuse dispatch (Phase 2.1). + * + * A per-run Langfuse trace (keyed by `runId`) fed from the single observability + * seam in `pi-adapter.ts`. Each `spawn` opens one span (the phase); generations, + * tool spans, and rubric scores nest under it. + * + * Hard invariants (RFC §1, §7): + * - Fire-and-forget. A dropped, slow, or unreachable Langfuse is a **no-op for + * orchestration** — every public method swallows its own errors and never + * throws into the control path. + * - The control path never reads back from Langfuse. This module is write-only. + * - Observability is dual until Phase 2.2: the JSONL appender keeps writing; this + * is additive. + * + * Disabled (returns `null` from {@link createLangfuseTracer}) when the public/secret + * keys are absent — Case then runs exactly as before, JSONL-only. + */ +import { Langfuse } from 'langfuse'; +import { createLogger } from '../util/logger.js'; +import type { Rubric } from '../types.js'; + +const log = createLogger(); + +/** pi `turn_end.message` shape we read (loosely — pi owns the full type). */ +interface PiAssistantMessage { + model?: string; + usage?: { + input?: number; + output?: number; + cacheRead?: number; + cacheWrite?: number; + totalTokens?: number; + cost?: { input?: number; output?: number; cacheRead?: number; cacheWrite?: number; total?: number }; + }; +} + +/** Per-spawn span handle. One per agent execution (= one phase node). */ +export interface AgentSpan { + /** `turn_end` → a generation observation carrying per-call tokens + cost. */ + generation(message: PiAssistantMessage): void; + /** `tool_execution_start` → open a nested span. */ + toolStart(toolCallId: string, toolName: string, args: unknown): void; + /** `tool_execution_end` → close the matching nested span. */ + toolEnd(toolCallId: string, toolName: string, result: unknown, isError: boolean): void; + /** Domain event → a point-in-time `event()` observation. */ + event(name: string, data?: unknown): void; + /** Verifier/reviewer rubric → one `score()` per category. */ + score(rubric: Rubric): void; + /** `agent_end` / spawn return → close the phase span. */ + end(output?: unknown, isError?: boolean): void; +} + +/** Run-scoped tracer. Created once per pipeline run, threaded via PipelineConfig. */ +export interface LangfuseTracer { + /** Open a phase span under the run trace. Always returns a usable (possibly no-op) handle. */ + startAgentSpan(agentName: string, phase?: string): AgentSpan; + /** Fire-and-forget flush — never awaited in the control path. */ + flushSafely(): void; + /** Bounded flush at run end: races shutdown against a timeout so a hung sink can't block. */ + shutdownSafely(timeoutMs?: number): Promise; +} + +const NOOP_SPAN: AgentSpan = { + generation() {}, + toolStart() {}, + toolEnd() {}, + event() {}, + score() {}, + end() {}, +}; + +/** Map pi `usage` → Langfuse usageDetails/costDetails (snake_case keys, `total` summed by ingest). */ +function mapUsage(usage: NonNullable): { + usageDetails: Record; + costDetails: Record; +} { + const usageDetails: Record = {}; + if (typeof usage.input === 'number') usageDetails.input = usage.input; + if (typeof usage.output === 'number') usageDetails.output = usage.output; + if (typeof usage.cacheRead === 'number') usageDetails.cache_read = usage.cacheRead; + if (typeof usage.cacheWrite === 'number') usageDetails.cache_write = usage.cacheWrite; + if (typeof usage.totalTokens === 'number') usageDetails.total = usage.totalTokens; + + const costDetails: Record = {}; + const cost = usage.cost; + if (cost) { + if (typeof cost.input === 'number') costDetails.input = cost.input; + if (typeof cost.output === 'number') costDetails.output = cost.output; + if (typeof cost.total === 'number') costDetails.total = cost.total; + } + return { usageDetails, costDetails }; +} + +export function createLangfuseTracer(runId: string, task: { id: string; title?: string }): LangfuseTracer | null { + const publicKey = process.env.LANGFUSE_PUBLIC_KEY; + const secretKey = process.env.LANGFUSE_SECRET_KEY; + // No keys → disabled. JSONL observability is unaffected. + if (!publicKey || !secretKey) return null; + + const baseUrl = process.env.LANGFUSE_HOST ?? process.env.LANGFUSE_BASE_URL ?? 'http://localhost:3000'; + + let client: Langfuse; + let trace: ReturnType; + try { + client = new Langfuse({ publicKey, secretKey, baseUrl }); + trace = client.trace({ + id: runId, + name: `case-run:${task.id}`, + metadata: { taskId: task.id, taskTitle: task.title, runId }, + }); + } catch (e) { + // Construction must never break a run. Degrade to disabled. + log.error('langfuse tracer init failed; observability degraded to JSONL-only', { + error: e instanceof Error ? e.message : String(e), + }); + return null; + } + + return { + startAgentSpan(agentName, phase) { + let span: ReturnType; + try { + span = trace.span({ + name: phase ? `phase:${phase}` : `agent:${agentName}`, + metadata: { agentName, phase }, + }); + } catch (e) { + log.error('langfuse span open failed', { error: e instanceof Error ? e.message : String(e) }); + return NOOP_SPAN; + } + + const toolSpans = new Map>(); + + return { + generation(message) { + try { + const usage = message.usage; + const gen = span.generation({ + name: 'turn', + model: message.model, + ...(usage ? mapUsage(usage) : {}), + }); + gen.end(); + } catch (e) { + log.error('langfuse generation failed', { error: e instanceof Error ? e.message : String(e) }); + } + }, + toolStart(toolCallId, toolName, args) { + try { + toolSpans.set(toolCallId, span.span({ name: `tool:${toolName}`, input: args })); + } catch (e) { + log.error('langfuse tool span open failed', { error: e instanceof Error ? e.message : String(e) }); + } + }, + toolEnd(toolCallId, toolName, result, isError) { + try { + const toolSpan = toolSpans.get(toolCallId); + toolSpans.delete(toolCallId); + if (toolSpan) toolSpan.end({ output: result, level: isError ? 'ERROR' : 'DEFAULT' }); + } catch (e) { + log.error('langfuse tool span close failed', { error: e instanceof Error ? e.message : String(e) }); + } + }, + event(name, data) { + try { + span.event({ name, input: data }); + } catch (e) { + log.error('langfuse event failed', { error: e instanceof Error ? e.message : String(e) }); + } + }, + score(rubric) { + try { + for (const cat of rubric.categories) { + span.score({ + name: `${rubric.role}:${cat.category}`, + value: cat.verdict === 'pass' ? 1 : cat.verdict === 'fail' ? 0 : 0.5, + comment: cat.detail, + }); + } + } catch (e) { + log.error('langfuse score failed', { error: e instanceof Error ? e.message : String(e) }); + } + }, + end(output, isError) { + try { + span.end({ output, level: isError ? 'ERROR' : 'DEFAULT' }); + } catch (e) { + log.error('langfuse span close failed', { error: e instanceof Error ? e.message : String(e) }); + } + }, + }; + }, + + flushSafely() { + try { + void client.flushAsync().catch((e: unknown) => { + log.error('langfuse flush failed', { error: e instanceof Error ? e.message : String(e) }); + }); + } catch (e) { + log.error('langfuse flush threw', { error: e instanceof Error ? e.message : String(e) }); + } + }, + + async shutdownSafely(timeoutMs = 3000) { + try { + await Promise.race([client.shutdownAsync(), new Promise((res) => setTimeout(res, timeoutMs))]); + } catch (e) { + log.error('langfuse shutdown failed', { error: e instanceof Error ? e.message : String(e) }); + } + }, + }; +} diff --git a/src/types.ts b/src/types.ts index 8a47941..66c562a 100644 --- a/src/types.ts +++ b/src/types.ts @@ -163,6 +163,8 @@ export interface PipelineConfig { traceWriter?: { write(event: any): void; flush(): Promise; path: string }; /** Event appender for unified event logging. */ eventAppender?: import('./events/appender.js').EventAppender; + /** Per-run Langfuse tracer (Phase 2.1). Absent → JSONL-only observability. */ + langfuse?: import('./tracing/langfuse.js').LangfuseTracer | null; /** Agent runtime for spawning agents. */ runtime?: import('./agent/runtime.js').CaseAgentRuntime; /** @@ -326,6 +328,8 @@ export interface SpawnAgentOptions { traceWriter?: { write(event: any): void; flush(): Promise; path: string }; /** Event appender for unified event logging. */ eventAppender?: import('./events/appender.js').EventAppender; + /** Per-run Langfuse tracer (Phase 2.1). Absent → JSONL-only observability. */ + langfuse?: import('./tracing/langfuse.js').LangfuseTracer | null; /** Current pipeline phase (used for trace events). */ phase?: PipelinePhase; } diff --git a/test/e2e/bunfig.toml b/test/e2e/bunfig.toml new file mode 100644 index 0000000..13d2ed8 --- /dev/null +++ b/test/e2e/bunfig.toml @@ -0,0 +1,4 @@ +[test] +# No preload — the e2e tier drives the REAL PiRuntimeAdapter against a live +# Langfuse instance. It must NOT inherit the root bunfig's mocks.ts (which stubs +# spawnAgent and would short-circuit the very seam we're validating). diff --git a/test/e2e/langfuse-llm-smoke.e2e.spec.ts b/test/e2e/langfuse-llm-smoke.e2e.spec.ts new file mode 100644 index 0000000..541e3cc --- /dev/null +++ b/test/e2e/langfuse-llm-smoke.e2e.spec.ts @@ -0,0 +1,63 @@ +import { describe, it, expect } from 'bun:test'; +import { PiRuntimeAdapter } from '../../src/agent/adapters/pi-adapter.js'; +import { createLangfuseTracer } from '../../src/tracing/langfuse.js'; +import { llmE2eEnabled, makeReadClient, pollTrace, ofType } from './readback.js'; +import type { SpawnAgentOptions } from '../../src/types.js'; + +/** + * Phase 2.1 E2E — Tier 2: real LLM, manual smoke. + * + * Spawns a REAL agent against the configured model and dispatches to a LIVE + * Langfuse, then reads back and asserts a generation with a non-zero **cost** — + * the one thing only a real provider call can produce (token counts + dollar + * cost are computed by pi from the actual API response). This is the true, + * unmocked end-to-end path. + * + * Non-deterministic and billable, so it is gated separately from Tier 1: + * runs only with LANGFUSE_E2E_LLM=1 (+ Langfuse keys + a working model auth). + * Run via `bun run test:e2e:llm`. Never part of the default suite or Tier 1. + * + * Assertions are intentionally loose (>=1 generation, cost>0) — the model may or + * may not call a tool, and token counts vary run to run. + */ + +const RUN_ID = `e2e-llm-${process.hrtime.bigint()}`; + +describe.skipIf(!llmE2eEnabled())('langfuse e2e — real LLM smoke', () => { + it('produces a live trace with a real per-call cost', async () => { + const tracer = createLangfuseTracer(RUN_ID, { id: 'e2e-llm-task' })!; + expect(tracer).not.toBeNull(); + + const adapter = new PiRuntimeAdapter(); + + // Minimal, cheap prompt: ask the model to emit a valid AGENT_RESULT and stop. + const options: SpawnAgentOptions = { + prompt: + 'Reply with EXACTLY this and nothing else:\n' + + '<<>>', + cwd: process.cwd(), + agentName: 'scout', // read-only toolset — safe in any cwd + packageRoot: process.cwd(), + dataDir: process.cwd(), + phase: 'scout', + timeout: 120_000, + langfuse: tracer, + }; + + const res = await adapter.spawn(options); + // Don't hard-fail on the model's status (it may editorialize); the trace is the point. + expect(res.durationMs).toBeGreaterThan(0); + + await tracer.shutdownSafely(15_000); + + const read = makeReadClient(); + const trace = await pollTrace(read, RUN_ID, { minObservations: 1, timeoutMs: 45_000 }); + + const generations = ofType(trace.observations, 'GENERATION'); + expect(generations.length).toBeGreaterThanOrEqual(1); + const totalCost = generations.reduce((sum, g) => sum + (g.costDetails?.total ?? 0), 0); + expect(totalCost).toBeGreaterThan(0); + + await read.shutdownAsync(); + }, 180_000); +}); diff --git a/test/e2e/langfuse-mocked-agent.e2e.spec.ts b/test/e2e/langfuse-mocked-agent.e2e.spec.ts new file mode 100644 index 0000000..bc8534c --- /dev/null +++ b/test/e2e/langfuse-mocked-agent.e2e.spec.ts @@ -0,0 +1,131 @@ +import { describe, it, expect, mock } from 'bun:test'; +import { createLangfuseTracer } from '../../src/tracing/langfuse.js'; +import { e2eEnabled, makeReadClient, pollTrace, byName, ofType } from './readback.js'; +import type { SpawnAgentOptions } from '../../src/types.js'; + +/** + * Phase 2.1 E2E — Tier 1: deterministic, no LLM. + * + * Drives the REAL `PiRuntimeAdapter.spawn` (the single observability seam) with a + * mocked pi `Agent` that emits a fixed event sequence, a REAL `createLangfuseTracer`, + * and a LIVE Langfuse — then reads the trace back and asserts the wire actually + * carried what the adapter dispatched. This is the genuine §4 2.1 acceptance + * ("a complete Langfuse trace with per-call token + cost"), minus LLM cost/flake. + * + * Covers what the unit spec cannot: the adapter's subscribe → tracer calls, the + * real HTTP ingest, the usage/cost mapping, and rubric → score(). + * + * Gated: runs only with LANGFUSE_E2E=1 + project keys (`bun run test:e2e`). The + * default suite skips this describe entirely, so offline CI stays green. + * + * Preconditions: `podman-compose -f podman-compose.yaml up -d` and a seeded + * project (LANGFUSE_INIT_PROJECT_PUBLIC_KEY/SECRET_KEY → LANGFUSE_PUBLIC_KEY/SECRET_KEY). + */ + +const RUN_ID = `e2e-mock-${process.env.LANGFUSE_E2E_RUN ?? '0'}-${process.hrtime.bigint()}`; + +// Fixed assistant message for turn_end → generation. Real-looking tokens + cost. +const TURN_MESSAGE = { + role: 'assistant', + model: 'claude-sonnet-4-6', + usage: { + input: 1200, + output: 340, + cacheRead: 800, + cacheWrite: 0, + totalTokens: 2340, + cost: { input: 0.0036, output: 0.0051, cacheRead: 0.0006, cacheWrite: 0, total: 0.0093 }, + }, +}; + +// The verifier's parsed result — includes a rubric so the adapter emits score()s. +const AGENT_RESULT = `<<>>`; + +/** Mock pi Agent: replays the exact event shapes pi-adapter subscribes to. */ +class MockAgent { + private listeners: Array<(e: any, s: AbortSignal) => unknown> = []; + constructor(public opts: unknown) {} + subscribe(cb: (e: any, s: AbortSignal) => unknown): () => void { + this.listeners.push(cb); + return () => {}; + } + async prompt(_input: string): Promise { + const signal = new AbortController().signal; + for (const cb of this.listeners) { + await cb({ type: 'tool_execution_start', toolCallId: 't1', toolName: 'bash', args: { cmd: 'bun test' } }, signal); + await cb({ type: 'tool_execution_end', toolCallId: 't1', toolName: 'bash', result: { exitCode: 0 }, isError: false }, signal); + await cb({ type: 'turn_end', message: TURN_MESSAGE, toolResults: [] }, signal); + await cb({ type: 'message_update', assistantMessageEvent: { type: 'text_delta', delta: AGENT_RESULT } }, signal); + } + } + abort(): void {} +} + +// Mock only the two boundaries the adapter would otherwise hit for real: +// - the pi Agent (no LLM / network) +// - the system-prompt loader (no package-asset disk read) +// ModelRegistry/tool creators stay REAL: registry.find('anthropic','claude-sonnet-4-6') +// resolves offline against static metadata; tool constructors are pure. +mock.module('@mariozechner/pi-agent-core', () => ({ Agent: MockAgent })); +mock.module('../../src/agent/prompt-loader.js', () => ({ loadSystemPrompt: async () => '' })); + +const { PiRuntimeAdapter } = await import('../../src/agent/adapters/pi-adapter.js'); + +describe.skipIf(!e2eEnabled())('langfuse e2e — mocked agent → live Langfuse', () => { + it('dispatches a complete trace (phase span, generation w/ tokens+cost, tool span, scores) and keeps the TUI feed intact', async () => { + const tracer = createLangfuseTracer(RUN_ID, { id: 'e2e-task' })!; + expect(tracer).not.toBeNull(); + + const toolActivity: Array<{ type: string; tool: string }> = []; + const adapter = new PiRuntimeAdapter(); + + const options: SpawnAgentOptions = { + prompt: 'verify the change', + cwd: process.cwd(), + agentName: 'verifier', + packageRoot: process.cwd(), + dataDir: process.cwd(), + provider: 'anthropic', + model: 'claude-sonnet-4-6', + phase: 'verify', + langfuse: tracer, + onToolActivity: (e) => toolActivity.push({ type: e.type, tool: e.tool }), + }; + + const res = await adapter.spawn(options); + + // The run itself succeeded and the live TUI feed fired — independent of Langfuse. + expect(res.result.status).toBe('completed'); + expect(toolActivity).toContainEqual({ type: 'start', tool: 'bash' }); + expect(toolActivity).toContainEqual({ type: 'end', tool: 'bash' }); + + // Force the batched dispatch out before reading back. + await tracer.shutdownSafely(10_000); + + const read = makeReadClient(); + const trace = await pollTrace(read, RUN_ID, { minObservations: 3, timeoutMs: 30_000 }); + + // Phase span. + expect(byName(trace.observations, 'phase:verify')).toBeDefined(); + + // Tool span (nested). + expect(byName(trace.observations, 'tool:bash')).toBeDefined(); + + // Generation with per-call tokens AND cost — the NEW capability (RFC §2). + const generations = ofType(trace.observations, 'GENERATION'); + expect(generations.length).toBeGreaterThanOrEqual(1); + const gen = generations[0]; + const totalTokens = gen.usageDetails?.total ?? gen.usageDetails?.input ?? 0; + expect(totalTokens).toBeGreaterThan(0); + expect(gen.costDetails?.total ?? 0).toBeGreaterThan(0); + + // Rubric → scores, one per category. + const scoreNames = trace.scores.map((s) => s.name); + expect(scoreNames).toContain('verifier:reproduced-scenario'); + expect(scoreNames).toContain('verifier:edge-case-checked'); + + await read.shutdownAsync(); + }, 60_000); +}); diff --git a/test/e2e/readback.ts b/test/e2e/readback.ts new file mode 100644 index 0000000..b794e85 --- /dev/null +++ b/test/e2e/readback.ts @@ -0,0 +1,105 @@ +/** + * Shared E2E read-back helpers (Phase 2.1). + * + * The control path never reads Langfuse (RFC §7) — but a *test* may, and that + * read-back is the only way to prove the dispatch wire actually lands: auth, + * baseUrl, the ingest schema, the usage/cost mapping, and scores, all verified + * against a real server. + * + * Ingestion is async + batched (SDK flush → ClickHouse write interval), so a + * trace is not queryable the instant we dispatch — {@link pollTrace} retries + * until the observations show up or a deadline passes. + */ +import { Langfuse } from 'langfuse'; + +export interface E2EConfig { + publicKey: string; + secretKey: string; + baseUrl: string; +} + +/** Read the project keys + host the same way the tracer does. */ +function readConfig(): E2EConfig | null { + const publicKey = process.env.LANGFUSE_PUBLIC_KEY; + const secretKey = process.env.LANGFUSE_SECRET_KEY; + if (!publicKey || !secretKey) return null; + const baseUrl = + process.env.LANGFUSE_HOST ?? process.env.LANGFUSE_BASE_URL ?? 'http://localhost:3000'; + return { publicKey, secretKey, baseUrl }; +} + +/** Tier 1 gate: opt-in flag + a reachable, keyed Langfuse. */ +export function e2eEnabled(): boolean { + return process.env.LANGFUSE_E2E === '1' && readConfig() !== null; +} + +/** Tier 2 gate: the heavier real-LLM smoke, behind its own flag. */ +export function llmE2eEnabled(): boolean { + return process.env.LANGFUSE_E2E_LLM === '1' && readConfig() !== null; +} + +/** A read-only client, independent of the tracer's write client (honors §7 separation). */ +export function makeReadClient(): Langfuse { + const cfg = readConfig(); + if (!cfg) throw new Error('Langfuse E2E keys absent — guard with e2eEnabled() before calling.'); + return new Langfuse({ publicKey: cfg.publicKey, secretKey: cfg.secretKey, baseUrl: cfg.baseUrl }); +} + +/** A single observation as returned by the public trace-details API (loosely typed). */ +export interface Observation { + type: 'SPAN' | 'GENERATION' | 'EVENT' | string; + name?: string | null; + model?: string | null; + usageDetails?: Record | null; + costDetails?: Record | null; + parentObservationId?: string | null; +} + +export interface TraceDetails { + id: string; + observations: Observation[]; + scores: Array<{ name?: string | null; value?: number | null; comment?: string | null }>; +} + +/** + * Poll the public trace API until at least `minObservations` are present. + * Throws on timeout so the assertion failure points at "ingest never landed". + */ +export async function pollTrace( + client: Langfuse, + traceId: string, + opts: { minObservations?: number; timeoutMs?: number; intervalMs?: number } = {}, +): Promise { + const minObservations = opts.minObservations ?? 1; + const timeoutMs = opts.timeoutMs ?? 30_000; + const intervalMs = opts.intervalMs ?? 1_500; + + const deadline = Date.now() + timeoutMs; + let last: TraceDetails | null = null; + let lastErr: unknown; + + while (Date.now() < deadline) { + try { + const trace = (await client.api.traceGet(traceId)) as unknown as TraceDetails; + last = trace; + if ((trace.observations?.length ?? 0) >= minObservations) return trace; + } catch (e) { + // 404 until the trace is first ingested — expected; keep polling. + lastErr = e; + } + await new Promise((r) => setTimeout(r, intervalMs)); + } + + const got = last?.observations?.length ?? 0; + throw new Error( + `pollTrace timed out after ${timeoutMs}ms for trace ${traceId}: ` + + `got ${got}/${minObservations} observations` + + (lastErr ? ` (last error: ${lastErr instanceof Error ? lastErr.message : String(lastErr)})` : ''), + ); +} + +export const byName = (obs: Observation[], name: string): Observation | undefined => + obs.find((o) => o.name === name); + +export const ofType = (obs: Observation[], type: string): Observation[] => + obs.filter((o) => o.type === type); From 7be77c3d9abb89452cab4cc3218aea4af49e2672 Mon Sep 17 00:00:00 2001 From: Em Jones Date: Mon, 22 Jun 2026 08:46:15 -0700 Subject: [PATCH 07/17] chore(migration): complete migration cleanup and state restructuring Remove legacy events layer (appender, reducer, schema, errors). Add run-state.ts and readback.ts, restructure tests, and finalize watch renderer/watcher for LangGraph state compatibility. --- MIGRATE_IMPLEMENTATION.md | 30 +- src/__tests__/checkpointer-resume.spec.ts | 74 ++--- src/__tests__/events-appender.spec.ts | 129 --------- src/__tests__/events-reducer.spec.ts | 249 ---------------- src/__tests__/events-validation.spec.ts | 293 ------------------- src/__tests__/run-state.spec.ts | 123 ++++++++ src/__tests__/watch-renderer.spec.ts | 232 ++++----------- src/__tests__/watch-watcher.spec.ts | 334 +++++++++------------- src/agent/adapters/pi-adapter.ts | 32 --- src/commands/watch.ts | 27 +- src/events/appender.ts | 57 ---- src/events/errors.ts | 82 ------ src/events/reducer.ts | 201 ------------- src/events/schema.ts | 106 ------- src/langgraph/engine.ts | 42 +-- src/phases/close.ts | 2 - src/phases/implement.ts | 4 - src/phases/retrospective.ts | 2 - src/phases/review.ts | 2 - src/phases/scout.ts | 2 - src/phases/verify.ts | 2 - src/pipeline-dispatch.ts | 17 +- src/pipeline.ts | 40 ++- src/state/run-state.ts | 124 ++++++++ src/state/transitions.ts | 26 +- src/tracing/langfuse.ts | 17 ++ src/tracing/readback.ts | 138 +++++++++ src/types.ts | 21 +- src/watch/renderer.ts | 79 +++-- src/watch/watcher.ts | 280 ++++++++++-------- test/e2e/readback.ts | 121 ++------ 31 files changed, 929 insertions(+), 1959 deletions(-) delete mode 100644 src/__tests__/events-appender.spec.ts delete mode 100644 src/__tests__/events-reducer.spec.ts delete mode 100644 src/__tests__/events-validation.spec.ts create mode 100644 src/__tests__/run-state.spec.ts delete mode 100644 src/events/appender.ts delete mode 100644 src/events/errors.ts delete mode 100644 src/events/reducer.ts delete mode 100644 src/events/schema.ts create mode 100644 src/state/run-state.ts create mode 100644 src/tracing/readback.ts diff --git a/MIGRATE_IMPLEMENTATION.md b/MIGRATE_IMPLEMENTATION.md index 29e1204..bb9e39d 100644 --- a/MIGRATE_IMPLEMENTATION.md +++ b/MIGRATE_IMPLEMENTATION.md @@ -1,6 +1,6 @@ # Migration: Custom DAG + Event-Sourcing → LangGraph + Langfuse -**Status:** In progress — **Phase 1 done** + **Phase 2.1 done** (Langfuse dispatch live-validated; see §0). Next: Phase 2.2 (⚠ BREAKING — delete the JSONL event log, cut over to Langfuse-only observability). +**Status:** **COMPLETE** — Phase 1 (1.1–1.3) + Phase 2 (2.1–2.2) all landed. The custom DAG and the granular event-sourcing log are gone; LangGraph + checkpointer own orchestration/resume; Langfuse is the sole observability sink. See §0. **Author:** Case maintainers **Scope:** Replace Case's hand-rolled orchestration engine and granular event log with LangGraph (graph execution + checkpointing) and Langfuse (observability dispatch), without losing any existing feature. @@ -119,7 +119,31 @@ Langfuse now receives a per-run trace fed from the single observability seam (`p - **Uncommitted:** all of Phase 1 (1.1 → 1.3) **and Phase 2.1** are on branch `docs/migrate-langgraph-langfuse-rfc`, **not yet committed**. Suggested commit boundaries: Phase 1.3 as two logical commits (1: ⚠ BREAKING flip+delete+test-triage · 2: node-direct projections + `node-projection.spec`); Phase 2.1 as one additive commit. **Full Phase 2.1 file set:** `src/tracing/langfuse.ts` (NEW), `src/agent/adapters/pi-adapter.ts`, `src/pipeline.ts`, `src/types.ts`, `src/phases/{scout,verify,review,close,retrospective}.ts`, `src/__tests__/langfuse-dispatch.spec.ts` (NEW), `test/e2e/` (NEW), `.env.example` (NEW), `.gitignore`, `package.json`, `bun.lock`. **⚠ Exclude `PROMPT.md`** (untracked, unrelated scratch — not part of the migration). Commit before starting 2.2 for a clean bisect. - **e2e needs the live stack:** Tier 1 (`bun run test:e2e`, `LANGFUSE_E2E=1`) requires `podman-compose -f podman-compose.yaml up -d` and the seeded keys exported (the script runs `--cwd test/e2e`, so the root `.env` is **not** auto-loaded — export `LANGFUSE_HOST`/`LANGFUSE_PUBLIC_KEY`/`LANGFUSE_SECRET_KEY` inline). Default `bun run test` excludes `test/e2e` entirely. -**Not yet started:** Phase 2.2 (⚠ BREAKING: delete `src/events/{schema,appender,reducer}.ts` + `projectTaskJson`/`projectMarkers`/`projectMetrics`, re-point `ca watch` at the in-process callback stream per §5 decision 3, retire the DIE-at-2.2 event specs, and wire domain `event()` span-side once the JSONL sink is gone — see 2.1 deviation 3). The `podman-compose.yaml` Langfuse stack is now **in use** by Phase 2.1 (dispatch target + e2e read-back). +### ✅ Phase 2.2 — ⚠ BREAKING: delete the granular event log, cut over to Langfuse-only observability — **DONE** + +The JSONL event log + its schema/appender/reducer are gone. Langfuse is now the **sole** trace sink; orchestration state lives in an in-memory container; `ca watch` reads the Langfuse trace. Full suite green (46 unit + 9 standalone, 0 fail). + +**Landed:** + +- **`src/state/run-state.ts` (NEW).** `RunState` — a JSONL-free in-memory container holding the **unchanged** `PipelineState` shape, with typed mutators (`startPhase`/`endPhase`/`setStatus`/`requestRevision`/`end`/`seedRevision`) ported from the reducer's per-case bodies. Replaces the `EventAppender` + `reduceEvents` pair: Phase 1.3 had the engine *drive* `PipelineState` via granular events and *read it back* via `appender.getState()`, so deleting the log meant **replacing the live state container**, not just removing a sink. Because the shape is identical, `projectTaskJson`/`projectMarkers`/`projectMetrics` (kept in `events/projections.ts`) are byte-identical by construction. +- **Deleted:** `src/events/{schema,appender,reducer,errors}.ts`. **Kept** `src/events/{types,projections,plan}.ts` (state shape, projections, plan generation — no event-log dependency). +- **`src/langgraph/engine.ts` + `src/pipeline.ts` + `src/pipeline-dispatch.ts`.** `appender` → `runState` throughout; `append({event})` calls became `runState.*` mutators. Orchestration-level domain events (`revision_requested` / `revision_budget_exhausted` / `fingerprint_match` / `scout_completed`) now land on the trace via a new **trace-level `LangfuseTracer.event()`** (closes 2.1 deviation 3 — they have no agent span). `config.eventAppender` → `config.runState` on `PipelineConfig`. +- **`src/agent/adapters/pi-adapter.ts`.** Deleted the dead `tool_start`/`tool_end` → `eventAppender`/`traceWriter` JSONL branches; `span.toolStart/toolEnd` (Langfuse, unconditional) + `onToolActivity` (TUI) already cover tools. `eventAppender`/`traceWriter` dropped from `SpawnAgentOptions` and the 6 phase pass-throughs. +- **`src/state/transitions.ts`.** Dropped the dead `determineEntryPhase(PipelineState)` overload (only the `TaskJson` form has a prod caller). +- **`ca watch` → Langfuse (RFC §5 decision 3, revised).** `src/tracing/readback.ts` (NEW, promoted from `test/e2e/readback.ts`; e2e re-exports it) is a read-only client honoring §7. `src/watch/watcher.ts` now **loads the run's trace observations then polls-with-cursor** for new ones (Langfuse has no push API — same as the dashboard), yielding normalized `WatchRecord`s; `renderer.ts` renders them; `commands/watch.ts` errors clearly when keys are absent (`--run ` pins a run). + +**Test triage (§9):** DIE (deleted) — `events-appender.spec`, `events-reducer.spec`, `events-validation.spec`. PORT — `events-reducer.spec` behavior → **`run-state.spec` (NEW)** (state-build oracle over `RunState`). Re-pointed — `checkpointer-resume.spec` (dropped the `reduceEvents` oracle for the directly-known crash-point expectation; `appender` stub → `runState` stub). Rewritten — `watch-watcher.spec` + `watch-renderer.spec` (Langfuse `WatchRecord` API, fake read client). Unchanged — `events-projections.spec`, `node-projection.spec` (projections + state shape survive). + +**Validation:** typecheck ✅ · `oxlint` 0 errors (2 pre-existing warnings on `interview/session.ts:40`) ✅ · `oxfmt` (my files) ✅ · full suite **46 unit + 9 standalone, 0 fail** (process-isolated runner) ✅. + +**Deviations / decisions made during implementation:** + +1. **State container replaces appender/reducer (not a pure deletion).** The plan called `projectTaskJson`/`projectMarkers` "orphaned" — stale relative to post-1.3 code, where `projectNodeState` uses them at runtime. The faithful 2.2 keeps the projections + `PipelineState` shape and swaps only the *driver* (events → `RunState` mutators). `reduceEvents`/`loadEventsFromFile`/`validateTransition`/the event schema are gone; the transition logic survives as plain methods. +2. **`ca watch` re-pointed to Langfuse, not an "in-process callback stream" (§5 decision 3 revised).** That decision predated the realization that `ca watch` is a *separate process* — there is no shared in-process stream cross-process. Per user direction, watch now loads + polls the Langfuse trace (full fidelity: tool spans, generations w/ tokens+cost, scores), reusing the 2.1 read-back client. Trade-off accepted: watch now **requires Langfuse keys + reachability** (no offline tail) and sees events at ingest latency (seconds). Reading Langfuse from a *human tool* does not violate §7 (that bars the *control path*). +3. **Domain `event()` is trace-level, not span-level (closes 2.1 deviation 3).** Orchestration events fire between phases (no agent span), so they attach to the run trace via `LangfuseTracer.event()`; per-call generations/tool spans stay span-nested as before. No more dual emission — the JSONL sink it would have duplicated is gone. +4. **`scout_completed`/`status_changed` are no longer state mutations.** They only bumped `lastSequence` in the reducer (observability-only); `scout_completed` is now a trace event, `status_changed` is folded into `RunState.setStatus`. Net td/metrics state unchanged. + +**End state:** the migration is complete. `runs.jsonl`, working memory, marker files, and td are the durable local truth (unchanged); LangGraph + the SQLite checkpointer own orchestration + resume; Langfuse holds the audit trace and drives `ca watch`. Breaking surface of 2.2 = any external consumer of `run-*.jsonl` and `ca watch`'s old JSONL source. --- @@ -313,7 +337,7 @@ Delete `src/events/{schema,appender,reducer}.ts` and the now-orphaned `projectTa 1. **Resume mechanism — DECIDED: LangGraph SQLite checkpointer.** Not td-embedded graph state (td stays a coarse human-facing projection — it lacks per-cycle keys, `revisionCycles`, the fingerprint set, and full `AgentResult` bodies), and not a hand-rolled snapshot. The checkpointer owns engine state; td keeps mirroring coarse status for humans. Co-location with td's SQLite must be verified (§6). 2. **Human override mechanism — DECIDED: LangGraph `interrupt`.** Native human-in-the-loop; composes with checkpointed resume. (Alt considered: custom retry/abort prompt wrapped around graph steps.) -3. **`ca watch` future — DECIDED: re-point at the in-process callback stream.** Keeps the offline local-first terminal tail; small adapter. (Alt considered: replace with the remote Langfuse trace UI — loses offline tail.) +3. **`ca watch` future — DECIDED: re-point at the in-process callback stream. → REVISED at 2.2: load + poll the Langfuse trace.** The callback-stream plan assumed a shared in-process channel, but `ca watch` is a *separate process* — nothing in-process is shared cross-process. 2.2 instead has watch load the run's Langfuse observations then poll-with-cursor (full fidelity, reuses the 2.1 read-back client). Trade-off: watch now requires Langfuse (no offline tail) + ingest latency; reading Langfuse from a human tool does not breach §7. See §0 Phase 2.2 deviation 2. (Alts considered: in-process callback tee — impossible cross-process; a minimal activity-log file — rejected, resurrects the JSONL we deleted.) 4. **Revision budget mechanism — DECIDED: custom counter channel + edge guard.** Explicit, matches today's `maxRevisionCycles`. LangGraph `recursionLimit` retained only as a runaway backstop. (Alt considered: `recursionLimit` alone — too blunt.) --- diff --git a/src/__tests__/checkpointer-resume.spec.ts b/src/__tests__/checkpointer-resume.spec.ts index e91f9de..26f7f6f 100644 --- a/src/__tests__/checkpointer-resume.spec.ts +++ b/src/__tests__/checkpointer-resume.spec.ts @@ -1,13 +1,11 @@ import { describe, it, expect } from 'bun:test'; import { MemorySaver } from '@langchain/langgraph-checkpoint'; import { executeLangGraph, type DispatchFn } from '../langgraph/engine.js'; -import { reduceEvents } from '../events/reducer.js'; import type { AgentResult, RevisionRequest } from '../types.js'; -import type { PipelineEvent } from '../events/types.js'; /** - * Phase 1.2 acceptance — checkpointer resume parity (the new oracle that will - * replace `events-reducer.spec` after the 1.3 cutover). + * Checkpointer resume parity (Phase 1.2 acceptance; Phase 2.2 re-pointed off the + * deleted `reduceEvents` oracle). * * A run is killed mid-`implement_1` (the implementer throws on the revision * cycle, escaping `invoke` exactly as a process crash would). A *second* @@ -15,9 +13,9 @@ import type { PipelineEvent } from '../events/types.js'; * restored run: * 1. re-enters at `implement` (not `scout`) — it did not restart from the top, * 2. carries the restored pending revision into that implement, and - * 3. that restored revision matches what `reduceEvents` derives from the - * pre-crash event stream — i.e. the checkpointer snapshot and the legacy - * event-replay oracle agree on (revisionCycles, pendingRevision). + * 3. that restored revision is the verifier failure from cycle 0 → cycle 1 + * (the crash point the dispatch script injects) — i.e. the checkpointer + * snapshot preserves (revisionCycles, pendingRevision) across the crash. */ const completed: AgentResult = { @@ -48,12 +46,13 @@ const verifierFail: AgentResult = { }, }; -/** A recording appender: collects what the engine emits, stamped like the real one. */ -function recordingAppender(events: PipelineEvent[]) { - let seq = 1; +/** + * A stub run-state: the engine only needs a valid `getState()` for the + * node-direct projection (empty phases/markers → no marker files, a single + * no-op td write) plus the mutators it calls, which are no-ops here. + */ +function stubRunState() { return { - // Minimal-but-valid PipelineState shape for the node-direct projection - // (empty phases/markers → no marker files, a single no-op td write). getState: () => ({ status: 'active', taskId: 'task-1', @@ -62,9 +61,12 @@ function recordingAppender(events: PipelineEvent[]) { markers: new Set(), pendingRevision: null, }), - append: async (e: Record) => { - events.push({ ...e, ts: new Date(0).toISOString(), sequence: seq++ } as unknown as PipelineEvent); - }, + startPhase() {}, + endPhase() {}, + setStatus() {}, + requestRevision() {}, + end() {}, + seedRevision() {}, }; } @@ -83,11 +85,11 @@ const noopNotifier = { askUser: async (_p: string, options: string[]) => options[options.length - 1], }; -function baseArgs(appender: unknown, dispatch: DispatchFn, checkpointer: MemorySaver) { +function baseArgs(runState: unknown, dispatch: DispatchFn, checkpointer: MemorySaver) { return { profile: 'standard' as const, maxRevisionCycles: 2, - appender: appender as never, + runState: runState as never, store: noopStore as never, caseRoot: '/tmp/case-resume-spec-unused', notifier: noopNotifier as never, @@ -99,11 +101,10 @@ function baseArgs(appender: unknown, dispatch: DispatchFn, checkpointer: MemoryS } describe('checkpointer resume parity', () => { - it('resumes mid-implement_1 with the restored pending revision (matches reduceEvents)', async () => { + it('resumes mid-implement_1 with the checkpointer-restored pending revision', async () => { const checkpointer = new MemorySaver(); // --- Run 1: crash on the second implement (the revision cycle). ---------- - const crashEvents: PipelineEvent[] = []; let implementCalls = 0; const crashDispatch: DispatchFn = async (node) => { switch (node.phase) { @@ -120,27 +121,9 @@ describe('checkpointer resume parity', () => { } }; - await expect( - executeLangGraph(baseArgs(recordingAppender(crashEvents), crashDispatch, checkpointer)), - ).rejects.toThrow('simulated crash mid-implement_1'); - - // Legacy oracle: replay the pre-crash event stream the way resume used to. - const oracleStream: PipelineEvent[] = [ - { - event: 'pipeline_start', - runId: 'r1', - taskId: 'task-1', - profile: 'standard', - plan: {}, - ts: new Date(0).toISOString(), - sequence: 0, - } as unknown as PipelineEvent, - ...crashEvents, - ]; - const oracle = reduceEvents(oracleStream); - expect(oracle.revisionCycles).toBe(1); - expect(oracle.pendingRevision?.source).toBe('verifier'); - expect(oracle.pendingRevision?.cycle).toBe(1); + await expect(executeLangGraph(baseArgs(stubRunState(), crashDispatch, checkpointer))).rejects.toThrow( + 'simulated crash mid-implement_1', + ); // --- Run 2: resume over the same checkpointer + thread. ------------------ const resumeCalls: { phase: string; revision: RevisionRequest | null }[] = []; @@ -149,27 +132,24 @@ describe('checkpointer resume parity', () => { return completed; // implement clears, verify passes, review/close/retro proceed }; - await executeLangGraph(baseArgs(recordingAppender([]), resumeDispatch, checkpointer)); + await executeLangGraph(baseArgs(stubRunState(), resumeDispatch, checkpointer)); // It resumed at implement (no scout re-run) and ran the cycle to the end. expect(resumeCalls.map((c) => c.phase)).toEqual(['implement', 'verify', 'review', 'close', 'retrospective']); - // The restored implement carried the pending revision … + // The restored implement carried the pending revision from the pre-crash + // verify failure (cycle 0 → cycle 1) — the checkpointer preserved it. const firstRevision = resumeCalls[0]?.revision; expect(firstRevision).not.toBeNull(); expect(firstRevision?.source).toBe('verifier'); expect(firstRevision?.cycle).toBe(1); - - // … and it agrees with the legacy event-replay oracle. - expect(firstRevision?.source).toBe(oracle.pendingRevision?.source); - expect(firstRevision?.cycle).toBe(oracle.pendingRevision?.cycle); }); it('a clean run leaves no resumable checkpoint (thread dropped on completion)', async () => { const checkpointer = new MemorySaver(); const cleanDispatch: DispatchFn = async () => completed; - await executeLangGraph(baseArgs(recordingAppender([]), cleanDispatch, checkpointer)); + await executeLangGraph(baseArgs(stubRunState(), cleanDispatch, checkpointer)); const tuple = await checkpointer.getTuple({ configurable: { thread_id: 'task-1', checkpoint_ns: '' } }); expect(tuple).toBeUndefined(); diff --git a/src/__tests__/events-appender.spec.ts b/src/__tests__/events-appender.spec.ts deleted file mode 100644 index c003636..0000000 --- a/src/__tests__/events-appender.spec.ts +++ /dev/null @@ -1,129 +0,0 @@ -import { describe, test, expect, afterAll, beforeEach } from 'bun:test'; -import { readFile, mkdir, rm } from 'node:fs/promises'; -import { resolve } from 'node:path'; -import { EventAppender } from '../events/appender.js'; -import { LifecycleValidationError } from '../events/errors.js'; -import type { PlanArtifact } from '../events/plan.js'; - -// Phase 1.3: the appender is now a write-only JSONL sink + state container. -// td-mirror / marker projection moved to node-direct writes — see -// node-projection.spec for that coverage. - -const PLAN: PlanArtifact = { - runId: 'run-1', - taskId: 'task-1', - profile: 'standard', - phases: [], - revisionBudget: 2, - modelConfig: {}, - generatedAt: '2026-01-01T00:00:00Z', -}; - -const tmpDir = resolve(process.env.TMPDIR ?? '/tmp', `case-appender-test-${Date.now()}`); - -beforeEach(async () => { - await mkdir(tmpDir, { recursive: true }); -}); - -afterAll(async () => { - await rm(tmpDir, { recursive: true, force: true }); -}); - -describe('EventAppender', () => { - test('appends valid event sequence to NDJSON file', async () => { - const appender = new EventAppender(tmpDir, 'task-1', 'run-1'); - - await appender.append({ event: 'pipeline_start', taskId: 'task-1', profile: 'standard', plan: PLAN }); - await appender.append({ event: 'phase_start', phase: 'implement', agent: 'implementer' }); - await appender.append({ - event: 'phase_end', - phase: 'implement', - agent: 'implementer', - outcome: 'completed', - durationMs: 1000, - }); - - const content = await readFile(appender.path, 'utf-8'); - const lines = content.trim().split('\n'); - expect(lines).toHaveLength(3); - - const events = lines.map((l) => JSON.parse(l)); - expect(events[0].event).toBe('pipeline_start'); - expect(events[1].event).toBe('phase_start'); - expect(events[2].event).toBe('phase_end'); - }); - - test('assigns monotonically increasing sequence numbers', async () => { - const appender = new EventAppender(tmpDir, 'task-1', 'run-2'); - - await appender.append({ event: 'pipeline_start', taskId: 'task-1', profile: 'standard', plan: PLAN }); - await appender.append({ event: 'phase_start', phase: 'implement', agent: 'implementer' }); - - const content = await readFile(appender.path, 'utf-8'); - const events = content - .trim() - .split('\n') - .map((l) => JSON.parse(l)); - - expect(events[0].sequence).toBe(1); - expect(events[1].sequence).toBe(2); - }); - - test('assigns consistent runId across all events', async () => { - const appender = new EventAppender(tmpDir, 'task-1', 'run-3'); - - await appender.append({ event: 'pipeline_start', taskId: 'task-1', profile: 'standard', plan: PLAN }); - await appender.append({ event: 'phase_start', phase: 'implement', agent: 'implementer' }); - - const content = await readFile(appender.path, 'utf-8'); - const events = content - .trim() - .split('\n') - .map((l) => JSON.parse(l)); - - expect(events[0].runId).toBe('run-3'); - expect(events[1].runId).toBe('run-3'); - }); - - test('allows concurrent phase starts (pipeline executor)', async () => { - const appender = new EventAppender(tmpDir, 'task-1', 'run-4'); - - await appender.append({ event: 'pipeline_start', taskId: 'task-1', profile: 'standard', plan: PLAN }); - await appender.append({ event: 'phase_start', phase: 'implement', agent: 'implementer' }); - - // Pipeline executor may start multiple phases concurrently - await expect( - appender.append({ event: 'phase_start', phase: 'verify', agent: 'verifier' }), - ).resolves.toBeUndefined(); - }); - - test('rejects events after pipeline end', async () => { - const appender = new EventAppender(tmpDir, 'task-1', 'run-4b'); - - await appender.append({ event: 'pipeline_start', taskId: 'task-1', profile: 'standard', plan: PLAN }); - await appender.append({ event: 'pipeline_end', outcome: 'completed', durationMs: 100 }); - - await expect(appender.append({ event: 'phase_start', phase: 'implement', agent: 'implementer' })).rejects.toThrow( - LifecycleValidationError, - ); - }); - - test('updates in-memory state after each append', async () => { - const appender = new EventAppender(tmpDir, 'task-1', 'run-5'); - - await appender.append({ event: 'pipeline_start', taskId: 'task-1', profile: 'standard', plan: PLAN }); - - const state = appender.getState(); - expect(state.runId).toBe('run-5'); - expect(state.outcome).toBe('running'); - - await appender.append({ event: 'phase_start', phase: 'implement', agent: 'implementer' }); - expect(appender.getState().currentPhase).toBe('implement_0'); - }); - - test('throws when getState called before any events', () => { - const appender = new EventAppender(tmpDir, 'task-1', 'run-7'); - - expect(() => appender.getState()).toThrow('No events appended yet'); - }); -}); diff --git a/src/__tests__/events-reducer.spec.ts b/src/__tests__/events-reducer.spec.ts deleted file mode 100644 index 1b244a7..0000000 --- a/src/__tests__/events-reducer.spec.ts +++ /dev/null @@ -1,249 +0,0 @@ -import { describe, test, expect, afterAll } from 'bun:test'; -import { writeFile, mkdir, rm } from 'node:fs/promises'; -import { resolve } from 'node:path'; -import { reduceEvents, loadEventsFromFile } from '../events/reducer.js'; -import type { PipelineEvent } from '../events/schema.js'; -import type { PlanArtifact } from '../events/plan.js'; - -const PLAN: PlanArtifact = { - runId: 'run-1', - taskId: 'task-1', - profile: 'standard', - phases: [ - { phase: 'implement', agent: 'implementer', evidenceGates: ['commit'] }, - { phase: 'verify', agent: 'verifier', evidenceGates: ['tested'] }, - { phase: 'review', agent: 'reviewer', evidenceGates: ['reviewed'] }, - { phase: 'close', agent: 'closer', evidenceGates: ['pr-opened'] }, - { phase: 'retrospective', agent: 'retrospective', evidenceGates: [] }, - ], - revisionBudget: 2, - modelConfig: {}, - generatedAt: '2026-01-01T00:00:00Z', -}; - -function makeEvent(seq: number, partial: Partial & { event: string }): PipelineEvent { - return { - ts: `2026-01-01T00:00:${String(seq).padStart(2, '0')}Z`, - sequence: seq, - runId: 'run-1', - ...partial, - } as PipelineEvent; -} - -const tmpDir = resolve(process.env.TMPDIR ?? '/tmp', `case-reducer-test-${Date.now()}`); - -afterAll(async () => { - await rm(tmpDir, { recursive: true, force: true }); -}); - -describe('reduceEvents', () => { - test('happy path: full pipeline lifecycle', () => { - const events: PipelineEvent[] = [ - makeEvent(1, { event: 'pipeline_start', taskId: 'task-1', profile: 'standard', plan: PLAN }), - makeEvent(2, { event: 'phase_start', phase: 'implement', agent: 'implementer' }), - makeEvent(3, { - event: 'phase_end', - phase: 'implement', - agent: 'implementer', - outcome: 'completed', - durationMs: 1000, - }), - makeEvent(4, { event: 'phase_start', phase: 'verify', agent: 'verifier' }), - makeEvent(5, { event: 'phase_end', phase: 'verify', agent: 'verifier', outcome: 'completed', durationMs: 500 }), - makeEvent(6, { event: 'phase_start', phase: 'review', agent: 'reviewer' }), - makeEvent(7, { event: 'phase_end', phase: 'review', agent: 'reviewer', outcome: 'completed', durationMs: 800 }), - makeEvent(8, { event: 'phase_start', phase: 'close', agent: 'closer' }), - makeEvent(9, { event: 'phase_end', phase: 'close', agent: 'closer', outcome: 'completed', durationMs: 200 }), - makeEvent(10, { event: 'phase_start', phase: 'retrospective', agent: 'retrospective' }), - makeEvent(11, { - event: 'phase_end', - phase: 'retrospective', - agent: 'retrospective', - outcome: 'completed', - durationMs: 300, - }), - makeEvent(12, { event: 'pipeline_end', outcome: 'completed', durationMs: 5000 }), - ]; - - const state = reduceEvents(events); - - expect(state.runId).toBe('run-1'); - expect(state.taskId).toBe('task-1'); - expect(state.outcome).toBe('completed'); - expect(state.phases.size).toBe(5); - expect(state.currentPhase).toBeNull(); - expect(state.lastSequence).toBe(12); - expect(state.totalDurationMs).toBe(5000); - - const impl = state.phases.get('implement_0'); - expect(impl?.status).toBe('completed'); - expect(impl?.durationMs).toBe(1000); - }); - - test('crash after implement — verify is pending', () => { - const events: PipelineEvent[] = [ - makeEvent(1, { event: 'pipeline_start', taskId: 'task-1', profile: 'standard', plan: PLAN }), - makeEvent(2, { event: 'phase_start', phase: 'implement', agent: 'implementer' }), - makeEvent(3, { - event: 'phase_end', - phase: 'implement', - agent: 'implementer', - outcome: 'completed', - durationMs: 1000, - }), - ]; - - const state = reduceEvents(events); - - expect(state.outcome).toBe('running'); - expect(state.currentPhase).toBeNull(); - expect(state.phases.get('implement_0')?.status).toBe('completed'); - expect(state.lastSequence).toBe(3); - }); - - test('revision cycle increments revisionCycles', () => { - const events: PipelineEvent[] = [ - makeEvent(1, { event: 'pipeline_start', taskId: 'task-1', profile: 'standard', plan: PLAN }), - makeEvent(2, { event: 'phase_start', phase: 'implement', agent: 'implementer' }), - makeEvent(3, { - event: 'phase_end', - phase: 'implement', - agent: 'implementer', - outcome: 'completed', - durationMs: 1000, - }), - makeEvent(4, { event: 'phase_start', phase: 'verify', agent: 'verifier' }), - makeEvent(5, { event: 'phase_end', phase: 'verify', agent: 'verifier', outcome: 'completed', durationMs: 500 }), - makeEvent(6, { event: 'revision_requested', source: 'verifier', cycle: 1, failedCategories: [] }), - ]; - - const state = reduceEvents(events); - - expect(state.revisionCycles).toBe(1); - expect(state.pendingRevision).not.toBeNull(); - expect(state.pendingRevision?.source).toBe('verifier'); - expect(state.lastSequence).toBe(6); - }); - - test('status_changed updates status', () => { - const events: PipelineEvent[] = [ - makeEvent(1, { event: 'pipeline_start', taskId: 'task-1', profile: 'standard', plan: PLAN }), - makeEvent(2, { event: 'status_changed', from: 'active', to: 'implementing' }), - ]; - - const state = reduceEvents(events); - expect(state.status).toBe('implementing'); - }); - - test('marker_written adds to markers set', () => { - const events: PipelineEvent[] = [ - makeEvent(1, { event: 'pipeline_start', taskId: 'task-1', profile: 'standard', plan: PLAN }), - makeEvent(2, { event: 'marker_written', marker: 'tested', path: '.case/task-1/tested' }), - ]; - - const state = reduceEvents(events); - expect(state.markers.has('tested')).toBe(true); - }); - - test('pipeline_end with failure records failedAgent', () => { - const events: PipelineEvent[] = [ - makeEvent(1, { event: 'pipeline_start', taskId: 'task-1', profile: 'standard', plan: PLAN }), - makeEvent(2, { event: 'pipeline_end', outcome: 'failed', failedAgent: 'verifier', durationMs: 3000 }), - ]; - - const state = reduceEvents(events); - expect(state.outcome).toBe('failed'); - expect(state.failedAgent).toBe('verifier'); - }); - - test('tool events update lastSequence without changing state', () => { - const events: PipelineEvent[] = [ - makeEvent(1, { event: 'pipeline_start', taskId: 'task-1', profile: 'standard', plan: PLAN }), - makeEvent(2, { - event: 'tool_start', - phase: 'implement', - agent: 'implementer', - toolCallId: 'tc-1', - tool: 'bash', - args: 'ls', - }), - makeEvent(3, { - event: 'tool_end', - phase: 'implement', - agent: 'implementer', - toolCallId: 'tc-1', - tool: 'bash', - durationMs: 50, - isError: false, - result: 'ok', - }), - ]; - - const state = reduceEvents(events); - expect(state.lastSequence).toBe(3); - expect(state.phases.size).toBe(0); - }); - - test('throws on empty event array', () => { - expect(() => reduceEvents([])).toThrow('No events to reduce'); - }); - - test('lastSequence matches highest sequence in input', () => { - const events: PipelineEvent[] = [ - makeEvent(1, { event: 'pipeline_start', taskId: 'task-1', profile: 'standard', plan: PLAN }), - makeEvent(5, { event: 'phase_start', phase: 'implement', agent: 'implementer' }), - makeEvent(10, { - event: 'phase_end', - phase: 'implement', - agent: 'implementer', - outcome: 'completed', - durationMs: 100, - }), - ]; - - const state = reduceEvents(events); - expect(state.lastSequence).toBe(10); - }); -}); - -describe('loadEventsFromFile', () => { - test('loads valid NDJSON events', async () => { - await mkdir(tmpDir, { recursive: true }); - const filePath = resolve(tmpDir, 'events.jsonl'); - - const events = [ - makeEvent(1, { event: 'pipeline_start', taskId: 'task-1', profile: 'standard', plan: PLAN }), - makeEvent(2, { event: 'phase_start', phase: 'implement', agent: 'implementer' }), - ]; - - await writeFile(filePath, events.map((e) => JSON.stringify(e)).join('\n') + '\n'); - - const loaded = await loadEventsFromFile(filePath); - expect(loaded).toHaveLength(2); - expect(loaded[0].event).toBe('pipeline_start'); - expect(loaded[1].event).toBe('phase_start'); - }); - - test('skips corrupted trailing line', async () => { - await mkdir(tmpDir, { recursive: true }); - const filePath = resolve(tmpDir, 'events-corrupt.jsonl'); - - const validEvent = makeEvent(1, { event: 'pipeline_start', taskId: 'task-1', profile: 'standard', plan: PLAN }); - await writeFile(filePath, JSON.stringify(validEvent) + '\n' + '{"broken json'); - - const loaded = await loadEventsFromFile(filePath); - expect(loaded).toHaveLength(1); - expect(loaded[0].event).toBe('pipeline_start'); - }); - - test('skips empty lines', async () => { - await mkdir(tmpDir, { recursive: true }); - const filePath = resolve(tmpDir, 'events-empty-lines.jsonl'); - - const event = makeEvent(1, { event: 'pipeline_start', taskId: 'task-1', profile: 'standard', plan: PLAN }); - await writeFile(filePath, '\n' + JSON.stringify(event) + '\n\n'); - - const loaded = await loadEventsFromFile(filePath); - expect(loaded).toHaveLength(1); - }); -}); diff --git a/src/__tests__/events-validation.spec.ts b/src/__tests__/events-validation.spec.ts deleted file mode 100644 index 8be07ed..0000000 --- a/src/__tests__/events-validation.spec.ts +++ /dev/null @@ -1,293 +0,0 @@ -import { describe, test, expect } from 'bun:test'; -import { LifecycleValidationError, validateTransition } from '../events/errors.js'; -import type { PipelineEvent } from '../events/schema.js'; -import type { PipelineState } from '../events/types.js'; -import type { PlanArtifact } from '../events/plan.js'; - -const PLAN: PlanArtifact = { - runId: 'run-1', - taskId: 'task-1', - profile: 'standard', - phases: [], - revisionBudget: 2, - modelConfig: {}, - generatedAt: '2026-01-01T00:00:00Z', -}; - -function makeState(overrides: Partial = {}): PipelineState { - return { - runId: 'run-1', - taskId: 'task-1', - profile: 'standard', - plan: PLAN, - status: 'implementing', - phases: new Map(), - currentPhase: null, - runningPhases: new Set(), - revisionCycles: 0, - pendingRevision: null, - markers: new Set(), - outcome: 'running', - startedAt: '2026-01-01T00:00:00Z', - lastSequence: 0, - ...overrides, - }; -} - -function makeEvent(partial: Partial & { event: string }): PipelineEvent { - return { - ts: '2026-01-01T00:00:01Z', - sequence: 1, - runId: 'run-1', - ...partial, - } as PipelineEvent; -} - -describe('validateTransition', () => { - describe('pipeline_start', () => { - test('allows pipeline_start with null state', () => { - expect(() => - validateTransition( - makeEvent({ event: 'pipeline_start', taskId: 'task-1', profile: 'standard', plan: PLAN }), - null, - ), - ).not.toThrow(); - }); - - test('rejects pipeline_start when pipeline already started', () => { - expect(() => - validateTransition( - makeEvent({ event: 'pipeline_start', taskId: 'task-1', profile: 'standard', plan: PLAN }), - makeState(), - ), - ).toThrow(LifecycleValidationError); - }); - - test('error includes "Pipeline already started" reason', () => { - try { - validateTransition( - makeEvent({ event: 'pipeline_start', taskId: 'task-1', profile: 'standard', plan: PLAN }), - makeState(), - ); - } catch (e) { - expect(e).toBeInstanceOf(LifecycleValidationError); - expect((e as LifecycleValidationError).reason).toBe('Pipeline already started'); - } - }); - }); - - describe('phase_start', () => { - test('allows phase_start when no phase is running', () => { - expect(() => - validateTransition(makeEvent({ event: 'phase_start', phase: 'implement', agent: 'implementer' }), makeState()), - ).not.toThrow(); - }); - - test('allows concurrent phase_start when another phase is running (pipeline executor)', () => { - expect(() => - validateTransition( - makeEvent({ event: 'phase_start', phase: 'verify', agent: 'verifier' }), - makeState({ currentPhase: 'implement_0', runningPhases: new Set(['implement_0']) }), - ), - ).not.toThrow(); - }); - - test('rejects phase_start when pipeline not started', () => { - expect(() => - validateTransition(makeEvent({ event: 'phase_start', phase: 'implement', agent: 'implementer' }), null), - ).toThrow(LifecycleValidationError); - }); - }); - - describe('phase_end', () => { - test('allows phase_end when matching phase is running', () => { - const phases = new Map([ - [ - 'implement_0', - { - phase: 'implement' as const, - agent: 'implementer' as const, - status: 'running' as const, - startedAt: '2026-01-01T00:00:00Z', - }, - ], - ]); - expect(() => - validateTransition( - makeEvent({ - event: 'phase_end', - phase: 'implement', - agent: 'implementer', - outcome: 'completed', - durationMs: 100, - }), - makeState({ currentPhase: 'implement_0', runningPhases: new Set(['implement_0']), phases }), - ), - ).not.toThrow(); - }); - - test('rejects phase_end when no phase is running', () => { - expect(() => - validateTransition( - makeEvent({ - event: 'phase_end', - phase: 'implement', - agent: 'implementer', - outcome: 'completed', - durationMs: 100, - }), - makeState(), - ), - ).toThrow(LifecycleValidationError); - }); - - test('allows phase_end for a different running phase (concurrent execution)', () => { - const phases = new Map([ - [ - 'verify_0', - { - phase: 'verify' as const, - agent: 'verifier' as const, - status: 'running' as const, - startedAt: '2026-01-01T00:00:00Z', - }, - ], - [ - 'implement_0', - { - phase: 'implement' as const, - agent: 'implementer' as const, - status: 'running' as const, - startedAt: '2026-01-01T00:00:00Z', - }, - ], - ]); - expect(() => - validateTransition( - makeEvent({ - event: 'phase_end', - phase: 'implement', - agent: 'implementer', - outcome: 'completed', - durationMs: 100, - }), - makeState({ currentPhase: 'verify_0', runningPhases: new Set(['verify_0', 'implement_0']), phases }), - ), - ).not.toThrow(); - }); - }); - - describe('revision_requested', () => { - test('allows revision_requested when evaluator has completed', () => { - const phases = new Map([ - ['implement_0', { phase: 'implement' as const, agent: 'implementer' as const, status: 'completed' as const }], - ['verify_0', { phase: 'verify' as const, agent: 'verifier' as const, status: 'completed' as const }], - ]); - expect(() => - validateTransition( - makeEvent({ event: 'revision_requested', source: 'verifier', cycle: 1, failedCategories: [] }), - makeState({ phases }), - ), - ).not.toThrow(); - }); - - test('rejects revision_requested without evaluator output', () => { - const phases = new Map([ - ['implement_0', { phase: 'implement' as const, agent: 'implementer' as const, status: 'completed' as const }], - ]); - expect(() => - validateTransition( - makeEvent({ event: 'revision_requested', source: 'verifier', cycle: 1, failedCategories: [] }), - makeState({ phases }), - ), - ).toThrow(LifecycleValidationError); - }); - }); - - describe('pipeline_end', () => { - test('allows pipeline_end when pipeline is running', () => { - expect(() => - validateTransition(makeEvent({ event: 'pipeline_end', outcome: 'completed', durationMs: 5000 }), makeState()), - ).not.toThrow(); - }); - - test('rejects pipeline_end when pipeline not started', () => { - expect(() => - validateTransition(makeEvent({ event: 'pipeline_end', outcome: 'completed', durationMs: 5000 }), null), - ).toThrow(LifecycleValidationError); - }); - }); - - describe('events after pipeline_end', () => { - test('rejects any event after pipeline has ended', () => { - const terminalState = makeState({ outcome: 'completed' }); - expect(() => - validateTransition( - makeEvent({ event: 'phase_start', phase: 'implement', agent: 'implementer' }), - terminalState, - ), - ).toThrow(LifecycleValidationError); - }); - - test('error includes "Cannot append events after pipeline end"', () => { - const terminalState = makeState({ outcome: 'completed' }); - try { - validateTransition( - makeEvent({ event: 'phase_start', phase: 'implement', agent: 'implementer' }), - terminalState, - ); - } catch (e) { - expect((e as LifecycleValidationError).reason).toBe('Cannot append events after pipeline end'); - } - }); - }); - - describe('tool events', () => { - test('allows tool_start when pipeline is running', () => { - expect(() => - validateTransition( - makeEvent({ - event: 'tool_start', - phase: 'implement', - agent: 'implementer', - toolCallId: 'tc-1', - tool: 'bash', - args: 'ls', - }), - makeState(), - ), - ).not.toThrow(); - }); - - test('rejects tool_start when pipeline not started', () => { - expect(() => - validateTransition( - makeEvent({ - event: 'tool_start', - phase: 'implement', - agent: 'implementer', - toolCallId: 'tc-1', - tool: 'bash', - args: 'ls', - }), - null, - ), - ).toThrow(LifecycleValidationError); - }); - }); - - describe('error shape', () => { - test('LifecycleValidationError has correct name', () => { - try { - validateTransition( - makeEvent({ event: 'pipeline_start', taskId: 'task-1', profile: 'standard', plan: PLAN }), - makeState(), - ); - } catch (e) { - expect((e as LifecycleValidationError).name).toBe('LifecycleValidationError'); - expect((e as LifecycleValidationError).event).toBeDefined(); - expect((e as LifecycleValidationError).currentState).toBeDefined(); - } - }); - }); -}); diff --git a/src/__tests__/run-state.spec.ts b/src/__tests__/run-state.spec.ts new file mode 100644 index 0000000..c46f31a --- /dev/null +++ b/src/__tests__/run-state.spec.ts @@ -0,0 +1,123 @@ +import { describe, test, expect } from 'bun:test'; +import { RunState } from '../state/run-state.js'; +import type { PlanArtifact } from '../events/plan.js'; + +/** + * Phase 2.2 — `RunState` is the state-build oracle that `events-reducer.spec` + * used to be. The granular event log + `reduceEvents` are gone; the transition + * logic that builds `PipelineState` now lives in `RunState`'s typed mutators. + * These assertions are the ported reducer-happy-path / revision / failure cases, + * driven by method calls instead of events. (Timestamps come from the wall clock + * now, so duration is asserted via the value passed to `endPhase`, not derived.) + */ + +const PLAN: PlanArtifact = { + runId: 'run-1', + taskId: 'task-1', + profile: 'standard', + phases: [], + revisionBudget: 2, + modelConfig: {}, + generatedAt: '2026-01-01T00:00:00Z', +}; + +function fresh(): RunState { + return new RunState({ runId: 'run-1', taskId: 'task-1', profile: 'standard', plan: PLAN }); +} + +describe('RunState', () => { + test('initial state', () => { + const s = fresh().getState(); + expect(s.runId).toBe('run-1'); + expect(s.taskId).toBe('task-1'); + expect(s.status).toBe('active'); + expect(s.outcome).toBe('running'); + expect(s.phases.size).toBe(0); + expect(s.revisionCycles).toBe(0); + }); + + test('happy path: full pipeline lifecycle', () => { + const rs = fresh(); + for (const [phase, agent, dur] of [ + ['implement', 'implementer', 1000], + ['verify', 'verifier', 500], + ['review', 'reviewer', 800], + ['close', 'closer', 200], + ['retrospective', 'retrospective', 300], + ] as const) { + rs.startPhase(phase, agent); + rs.endPhase(phase, agent, 'completed', dur); + } + rs.end('completed', undefined, 5000); + + const s = rs.getState(); + expect(s.outcome).toBe('completed'); + expect(s.phases.size).toBe(5); + expect(s.currentPhase).toBeNull(); + expect(s.totalDurationMs).toBe(5000); + + const impl = s.phases.get('implement_0'); + expect(impl?.status).toBe('completed'); + expect(impl?.durationMs).toBe(1000); + }); + + test('crash after implement — outcome still running, implement completed', () => { + const rs = fresh(); + rs.startPhase('implement', 'implementer'); + rs.endPhase('implement', 'implementer', 'completed', 1000); + + const s = rs.getState(); + expect(s.outcome).toBe('running'); + expect(s.currentPhase).toBeNull(); + expect(s.phases.get('implement_0')?.status).toBe('completed'); + }); + + test('requestRevision increments revisionCycles + sets pendingRevision', () => { + const rs = fresh(); + rs.startPhase('implement', 'implementer'); + rs.endPhase('implement', 'implementer', 'completed', 1000); + rs.startPhase('verify', 'verifier'); + rs.endPhase('verify', 'verifier', 'completed', 500); + rs.requestRevision('verifier', 1, []); + + const s = rs.getState(); + expect(s.revisionCycles).toBe(1); + expect(s.pendingRevision?.source).toBe('verifier'); + expect(s.pendingRevision?.cycle).toBe(1); + }); + + test('cyclic phase keys by revision cycle', () => { + const rs = fresh(); + rs.startPhase('implement', 'implementer'); + rs.endPhase('implement', 'implementer', 'completed', 100); + rs.requestRevision('verifier', 1, []); + rs.startPhase('implement', 'implementer'); // cycle 1 + + const s = rs.getState(); + expect(s.phases.has('implement_0')).toBe(true); + expect(s.phases.has('implement_1')).toBe(true); + }); + + test('setStatus updates status', () => { + const rs = fresh(); + rs.setStatus('implementing'); + expect(rs.getState().status).toBe('implementing'); + }); + + test('end with failure records failedAgent', () => { + const rs = fresh(); + rs.end('failed', 'verifier', 3000); + const s = rs.getState(); + expect(s.outcome).toBe('failed'); + expect(s.failedAgent).toBe('verifier'); + expect(s.totalDurationMs).toBe(3000); + }); + + test('seedRevision seeds cycle count + pending revision for a resumed run', () => { + const rs = fresh(); + rs.seedRevision({ source: 'reviewer', failedCategories: [], summary: '', suggestedFocus: [], cycle: 2 }); + const s = rs.getState(); + expect(s.revisionCycles).toBe(2); + expect(s.pendingRevision?.source).toBe('reviewer'); + }); +}); diff --git a/src/__tests__/watch-renderer.spec.ts b/src/__tests__/watch-renderer.spec.ts index c66c4b2..01295de 100644 --- a/src/__tests__/watch-renderer.spec.ts +++ b/src/__tests__/watch-renderer.spec.ts @@ -1,6 +1,6 @@ import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; import { renderWatchEvent } from '../watch/renderer.js'; -import type { PipelineEvent } from '../events/schema.js'; +import type { WatchRecord } from '../watch/watcher.js'; // Lock color OFF so we can assert on exact plain-text shapes. let savedNoColor: string | undefined; @@ -20,154 +20,68 @@ afterEach(() => { else process.env.FORCE_COLOR = savedForceColor; }); -function makeEvent( - event: T, - partial: Partial>, -): PipelineEvent { - return { - ts: '2026-05-18T00:00:00Z', - sequence: 1, - runId: 'run-abcdef0123456789', - event, - ...partial, - } as PipelineEvent; -} - describe('renderWatchEvent — no color', () => { - test('phase_start uses formatPhaseHeader output (60 chars wide)', () => { - const out = renderWatchEvent(makeEvent('phase_start', { phase: 'implement', agent: 'implementer' } as any)); - expect(out.startsWith('▶ implement (implementer)')).toBe(true); - expect(out.length).toBe(60); - expect(out.includes('─')).toBe(true); - }); - - test('phase_end completed uses ✓ and padded duration', () => { - const out = renderWatchEvent( - makeEvent('phase_end', { - phase: 'verify', - agent: 'verifier', - outcome: 'completed', - durationMs: 42_000, - } as any), - ); - expect(out.startsWith('✓ verify completed')).toBe(true); - expect(out.endsWith('42s')).toBe(true); + test('trace_start shows trace name + short id', () => { + const out = renderWatchEvent({ kind: 'trace_start', traceId: 'abcdef0123456789', traceName: 'case-run:task-1' }); + expect(out).toBe('▶ watching case-run:task-1 (trace abcdef01)'); }); - test('phase_end failed uses ✗', () => { - const out = renderWatchEvent( - makeEvent('phase_end', { - phase: 'review', - agent: 'reviewer', - outcome: 'failed', - durationMs: 18_000, - } as any), - ); - expect(out.startsWith('✗ review failed')).toBe(true); - expect(out.endsWith('18s')).toBe(true); + test('phase span_start', () => { + expect(renderWatchEvent({ kind: 'span_start', span: 'phase', name: 'implement' })).toBe('▶ implement'); }); - test('phase_end skipped uses ⊘', () => { - const out = renderWatchEvent( - makeEvent('phase_end', { - phase: 'verify', - agent: 'verifier', - outcome: 'skipped', - durationMs: 0, - } as any), - ); - expect(out).toBe('⊘ verify skipped'); + test('tool span_start is indented', () => { + expect(renderWatchEvent({ kind: 'span_start', span: 'tool', name: 'bash' })).toBe(' ⚙ bash'); }); - test('tool_start renders as indented tool line', () => { - const out = renderWatchEvent( - makeEvent('tool_start', { - phase: 'implement', - agent: 'implementer', - toolCallId: 't1', - tool: 'Read', - args: 'src/foo.ts', - } as any), - ); - expect(out).toBe(' ↳ Read src/foo.ts'); + test('phase span_end completed uses ✓ + duration', () => { + const out = renderWatchEvent({ + kind: 'span_end', + span: 'phase', + name: 'verify', + durationMs: 42_000, + isError: false, + }); + expect(out).toBe('✓ verify (42s)'); }); - test('tool_end renders with duration', () => { - const out = renderWatchEvent( - makeEvent('tool_end', { - phase: 'implement', - agent: 'implementer', - toolCallId: 't1', - tool: 'Read', - durationMs: 2_000, - isError: false, - result: 'ok', - } as any), - ); - expect(out.startsWith(' ↳ Read')).toBe(true); - expect(out.endsWith('2s')).toBe(true); + test('phase span_end error uses ✗', () => { + const out = renderWatchEvent({ + kind: 'span_end', + span: 'phase', + name: 'review', + durationMs: 18_000, + isError: true, + }); + expect(out).toBe('✗ review (18s)'); }); - test('tool_end with error appends ERROR marker', () => { - const out = renderWatchEvent( - makeEvent('tool_end', { - phase: 'implement', - agent: 'implementer', - toolCallId: 't1', - tool: 'Bash', - durationMs: 1_000, - isError: true, - result: 'failed', - } as any), - ); + test('tool span_end with error appends ERROR', () => { + const out = renderWatchEvent({ kind: 'span_end', span: 'tool', name: 'bash', durationMs: 1_000, isError: true }); expect(out.includes('ERROR')).toBe(true); }); - test('revision_requested', () => { - const out = renderWatchEvent( - makeEvent('revision_requested', { source: 'verifier', cycle: 1, failedCategories: [] } as any), - ); - expect(out).toBe('↻ revision requested by verifier (cycle 1)'); - }); - - test('revision_budget_exhausted', () => { - const out = renderWatchEvent(makeEvent('revision_budget_exhausted', { cycles: 2 } as any)); - expect(out).toBe('⚠ revision budget exhausted (2 cycles)'); - }); - - test('status_changed shows new status', () => { - const out = renderWatchEvent(makeEvent('status_changed', { from: 'implementing', to: 'evaluating' } as any)); - expect(out).toBe('→ evaluating'); + test('generation shows tokens + cost', () => { + const out = renderWatchEvent({ kind: 'generation', model: 'claude', tokens: 1234, cost: 0.0021 }); + expect(out).toBe(' ↳ turn claude (1234 tok, $0.0021)'); }); - test('pipeline_start shows profile + short runId', () => { - const out = renderWatchEvent( - makeEvent('pipeline_start', { - taskId: 'task-1', - profile: 'standard', - plan: {} as any, - runId: 'abcdef0123456789xyz', - } as any), - ); - expect(out.startsWith('▶ pipeline started (standard profile, run ')).toBe(true); - expect(out.includes('abcdef01')).toBe(true); + test('event renders the domain name', () => { + expect(renderWatchEvent({ kind: 'event', name: 'revision_requested' })).toBe('↻ revision_requested'); }); - test('pipeline_end completed', () => { - const out = renderWatchEvent(makeEvent('pipeline_end', { outcome: 'completed', durationMs: 222_000 } as any)); - expect(out).toBe('✓ pipeline complete (3m 42s)'); + test('score renders name + value + comment', () => { + const out = renderWatchEvent({ + kind: 'score', + name: 'verifier:edge-case', + value: 0, + comment: 'missing null check', + }); + expect(out).toBe('★ verifier:edge-case: 0 — missing null check'); }); - test('pipeline_end failed', () => { - const out = renderWatchEvent( - makeEvent('pipeline_end', { outcome: 'failed', failedAgent: 'reviewer', durationMs: 135_000 } as any), - ); - expect(out).toBe('✗ pipeline failed at reviewer (2m 15s)'); - }); - - test('marker_written', () => { - const out = renderWatchEvent(makeEvent('marker_written', { marker: '.case-tested', path: '/tmp/x' } as any)); - expect(out).toBe('📎 marker: .case-tested'); + test('run_complete', () => { + expect(renderWatchEvent({ kind: 'run_complete' })).toBe('✓ run complete'); }); }); @@ -177,59 +91,27 @@ describe('renderWatchEvent — with color (FORCE_COLOR)', () => { process.env.FORCE_COLOR = '1'; }); - test('phase_end completed icon is green', () => { - const out = renderWatchEvent( - makeEvent('phase_end', { - phase: 'verify', - agent: 'verifier', - outcome: 'completed', - durationMs: 5_000, - } as any), - ); - expect(out.startsWith('\x1b[32m✓\x1b[0m')).toBe(true); - }); - - test('phase_end failed icon is red', () => { - const out = renderWatchEvent( - makeEvent('phase_end', { - phase: 'verify', - agent: 'verifier', - outcome: 'failed', - durationMs: 5_000, - } as any), - ); - expect(out.startsWith('\x1b[31m✗\x1b[0m')).toBe(true); - }); - - test('pipeline_end completed is green', () => { - const out = renderWatchEvent(makeEvent('pipeline_end', { outcome: 'completed', durationMs: 5_000 } as any)); + test('phase span_end completed icon is green', () => { + const out = renderWatchEvent({ + kind: 'span_end', + span: 'phase', + name: 'verify', + durationMs: 5_000, + isError: false, + }); expect(out.startsWith('\x1b[32m')).toBe(true); }); - test('pipeline_end failed is red', () => { - const out = renderWatchEvent( - makeEvent('pipeline_end', { outcome: 'failed', failedAgent: 'reviewer', durationMs: 5_000 } as any), - ); + test('phase span_end error is red', () => { + const out = renderWatchEvent({ kind: 'span_end', span: 'phase', name: 'verify', durationMs: 5_000, isError: true }); expect(out.startsWith('\x1b[31m')).toBe(true); }); - test('revision_requested is yellow', () => { - const out = renderWatchEvent( - makeEvent('revision_requested', { source: 'verifier', cycle: 1, failedCategories: [] } as any), - ); - expect(out.startsWith('\x1b[33m')).toBe(true); + test('event is yellow', () => { + expect(renderWatchEvent({ kind: 'event', name: 'fingerprint_match' }).startsWith('\x1b[33m')).toBe(true); }); - test('tool_start is dim', () => { - const out = renderWatchEvent( - makeEvent('tool_start', { - phase: 'implement', - agent: 'implementer', - toolCallId: 't1', - tool: 'Read', - args: 'src/foo.ts', - } as any), - ); - expect(out.startsWith('\x1b[2m')).toBe(true); + test('tool span_start is dim', () => { + expect(renderWatchEvent({ kind: 'span_start', span: 'tool', name: 'bash' }).startsWith('\x1b[2m')).toBe(true); }); }); diff --git a/src/__tests__/watch-watcher.spec.ts b/src/__tests__/watch-watcher.spec.ts index ee4455e..a0e7086 100644 --- a/src/__tests__/watch-watcher.spec.ts +++ b/src/__tests__/watch-watcher.spec.ts @@ -1,224 +1,148 @@ -import { describe, test, expect, afterAll } from 'bun:test'; -import { appendFile, mkdir, rm, writeFile } from 'node:fs/promises'; -import { resolve } from 'node:path'; -import { watchEventLog } from '../watch/watcher.js'; -import type { PipelineEvent } from '../events/schema.js'; - -const tmpDir = resolve(process.env.TMPDIR ?? '/tmp', `case-watch-test-${Date.now()}`); +import { describe, test, expect } from 'bun:test'; +import type { Langfuse } from 'langfuse'; +import { watchTrace, type WatchRecord, type WatchOptions } from '../watch/watcher.js'; +import type { Observation, TraceDetails } from '../tracing/readback.js'; + +/** + * Phase 2.2 — `ca watch` reads the run's Langfuse trace (load + poll-with-cursor) + * instead of tailing a JSONL log. These drive the generator over a fake read + * client returning canned trace snapshots and assert the emitted WatchRecords. + */ + +function obs(o: Partial & { id: string; type: string }): Observation { + return { startTime: '2026-01-01T00:00:00.000Z', ...o } as Observation; +} -afterAll(async () => { - await rm(tmpDir, { recursive: true, force: true }); -}); +/** A fake read client: traceList resolves the id; traceGet walks the snapshot list. */ +function fakeClient(snapshots: TraceDetails[], opts: { noTrace?: boolean } = {}): Langfuse { + let i = 0; + return { + api: { + traceList: async () => ({ data: opts.noTrace ? [] : [{ id: 'r1' }] }), + traceGet: async () => snapshots[Math.min(i++, snapshots.length - 1)], + }, + } as unknown as Langfuse; +} -function makeEvent(partial: Partial & { event: string }): string { - const base = { - ts: new Date().toISOString(), - sequence: 1, - runId: 'run-1', - }; - return JSON.stringify({ ...base, ...partial }); +async function collect(options: WatchOptions): Promise { + const out: WatchRecord[] = []; + for await (const r of watchTrace({ pollIntervalMs: 1, maxIdleMs: 40, ...options })) out.push(r); + return out; } -describe('watchEventLog', () => { - test('replays existing events and stops on pipeline_end', async () => { - const taskSlug = 'test-replay'; - const eventDir = resolve(tmpDir, '.case', taskSlug, 'events'); - await mkdir(eventDir, { recursive: true }); +const trace = (observations: Observation[], scores: TraceDetails['scores'] = []): TraceDetails => ({ + id: 'r1', + observations, + scores, +}); - const logPath = resolve(eventDir, 'run-test.jsonl'); - const events = [ - makeEvent({ event: 'pipeline_start', sequence: 1, taskId: 'task-1', profile: 'standard', plan: {} as any }), - makeEvent({ event: 'phase_start', sequence: 2, phase: 'implement', agent: 'implementer' }), - makeEvent({ - event: 'phase_end', - sequence: 3, - phase: 'implement', - agent: 'implementer', - outcome: 'completed', - durationMs: 5000, +describe('watchTrace', () => { + test('loads observations and completes when the retrospective span ends', async () => { + const snapshot = trace([ + obs({ + id: 'a', + type: 'SPAN', + name: 'phase:implement', + startTime: '2026-01-01T00:00:01Z', + endTime: '2026-01-01T00:00:02Z', }), - makeEvent({ event: 'pipeline_end', sequence: 4, outcome: 'completed', durationMs: 10000 }), - ]; - await writeFile(logPath, events.join('\n') + '\n'); - - const collected: PipelineEvent[] = []; - for await (const event of watchEventLog({ - taskSlug, - caseRoot: tmpDir, - runId: 'test', - format: 'structured', - })) { - collected.push(event); - } - - expect(collected).toHaveLength(4); - expect(collected[0].event).toBe('pipeline_start'); - expect(collected[3].event).toBe('pipeline_end'); - }); - - test('structured mode includes tool events (milestone set expanded)', async () => { - const taskSlug = 'test-filter'; - const eventDir = resolve(tmpDir, '.case', taskSlug, 'events'); - await mkdir(eventDir, { recursive: true }); - - const logPath = resolve(eventDir, 'run-filter.jsonl'); - const events = [ - makeEvent({ event: 'pipeline_start', sequence: 1, taskId: 'task-1', profile: 'standard', plan: {} as any }), - makeEvent({ - event: 'tool_start', - sequence: 2, - phase: 'implement', - agent: 'implementer', - toolCallId: 't1', - tool: 'Read', - args: '{}', + obs({ + id: 'b', + type: 'SPAN', + name: 'tool:bash', + startTime: '2026-01-01T00:00:01.5Z', + endTime: '2026-01-01T00:00:01.8Z', }), - makeEvent({ - event: 'tool_end', - sequence: 3, - phase: 'implement', - agent: 'implementer', - toolCallId: 't1', - tool: 'Read', - durationMs: 50, - isError: false, - result: 'ok', + obs({ + id: 'c', + type: 'SPAN', + name: 'phase:retrospective', + startTime: '2026-01-01T00:00:03Z', + endTime: '2026-01-01T00:00:04Z', }), - makeEvent({ event: 'pipeline_end', sequence: 4, outcome: 'completed', durationMs: 10000 }), - ]; - await writeFile(logPath, events.join('\n') + '\n'); - - const collected: PipelineEvent[] = []; - for await (const event of watchEventLog({ - taskSlug, - caseRoot: tmpDir, - runId: 'filter', - format: 'structured', - })) { - collected.push(event); - } - - // Tool events are now shown by default — pipeline_start + tool_start + tool_end + pipeline_end. - expect(collected).toHaveLength(4); - expect(collected.map((e) => e.event)).toEqual(['pipeline_start', 'tool_start', 'tool_end', 'pipeline_end']); + ]); + + const records = await collect({ taskSlug: 'task-1', client: fakeClient([snapshot]) }); + const kinds = records.map((r) => r.kind); + + expect(records[0]).toEqual({ kind: 'trace_start', traceId: 'r1', traceName: 'case-run:task-1' }); + expect(kinds).toContain('span_start'); + // span_starts emitted in start-time order + const starts = records.filter((r): r is Extract => r.kind === 'span_start'); + expect(starts.map((s) => s.name)).toEqual(['implement', 'bash', 'retrospective']); + // completes and stops + expect(records.at(-1)).toEqual({ kind: 'run_complete' }); }); - test('raw mode yields all events', async () => { - const taskSlug = 'test-raw'; - const eventDir = resolve(tmpDir, '.case', taskSlug, 'events'); - await mkdir(eventDir, { recursive: true }); - - const logPath = resolve(eventDir, 'run-raw.jsonl'); - const events = [ - makeEvent({ event: 'pipeline_start', sequence: 1, taskId: 'task-1', profile: 'standard', plan: {} as any }), - makeEvent({ - event: 'tool_start', - sequence: 2, - phase: 'implement', - agent: 'implementer', - toolCallId: 't1', - tool: 'Read', - args: '{}', + test('pinned runId skips trace resolution and tails that trace', async () => { + const snapshot = trace([ + obs({ + id: 'a', + type: 'SPAN', + name: 'phase:retrospective', + startTime: '2026-01-01T00:00:03Z', + endTime: '2026-01-01T00:00:04Z', }), - makeEvent({ event: 'pipeline_end', sequence: 3, outcome: 'completed', durationMs: 10000 }), - ]; - await writeFile(logPath, events.join('\n') + '\n'); - - const collected: PipelineEvent[] = []; - for await (const event of watchEventLog({ - taskSlug, - caseRoot: tmpDir, - runId: 'raw', - format: 'raw', - })) { - collected.push(event); - } - - expect(collected).toHaveLength(3); + ]); + const records = await collect({ taskSlug: 'task-1', runId: 'pinned-run', client: fakeClient([snapshot]) }); + expect(records[0]).toEqual({ kind: 'trace_start', traceId: 'pinned-run', traceName: 'case-run:task-1' }); + expect(records.at(-1)).toEqual({ kind: 'run_complete' }); }); - test('skips partial trailing line (no final newline)', async () => { - const taskSlug = 'test-partial'; - const eventDir = resolve(tmpDir, '.case', taskSlug, 'events'); - await mkdir(eventDir, { recursive: true }); - - const logPath = resolve(eventDir, 'run-partial.jsonl'); - const complete = makeEvent({ - event: 'pipeline_start', - sequence: 1, - taskId: 'task-1', - profile: 'standard', - plan: {} as any, - }); - const partial = '{"event":"pipeline_end","sequence":2'; // intentionally truncated - await writeFile(logPath, complete + '\n' + partial); - - // Append the rest after a delay to simulate live writing - setTimeout(async () => { - const rest = `,"runId":"run-1","ts":"2026-01-01","outcome":"completed","durationMs":100}\n`; - await appendFile(logPath, rest); - }, 300); - - const collected: PipelineEvent[] = []; - for await (const event of watchEventLog({ - taskSlug, - caseRoot: tmpDir, - runId: 'partial', - format: 'raw', - pollIntervalMs: 100, - })) { - collected.push(event); - } - - expect(collected).toHaveLength(2); - expect(collected[1].event).toBe('pipeline_end'); + test('emits rubric scores', async () => { + const snapshot = trace( + [ + obs({ + id: 'a', + type: 'SPAN', + name: 'phase:retrospective', + startTime: '2026-01-01T00:00:03Z', + endTime: '2026-01-01T00:00:04Z', + }), + ], + [{ name: 'verifier:edge-case', value: 0, comment: 'missing null check' }], + ); + const records = await collect({ taskSlug: 'task-1', client: fakeClient([snapshot]) }); + const score = records.find((r) => r.kind === 'score'); + expect(score).toEqual({ kind: 'score', name: 'verifier:edge-case', value: 0, comment: 'missing null check' }); }); - test('incremental read yields new events as they are appended', async () => { - const taskSlug = 'test-incremental'; - const eventDir = resolve(tmpDir, '.case', taskSlug, 'events'); - await mkdir(eventDir, { recursive: true }); - - const logPath = resolve(eventDir, 'run-incr.jsonl'); - const initial = makeEvent({ - event: 'pipeline_start', - sequence: 1, - taskId: 'task-1', - profile: 'standard', - plan: {} as any, - }); - await writeFile(logPath, initial + '\n'); - - // Append more events after a delay - setTimeout(async () => { - await appendFile( - logPath, - makeEvent({ event: 'phase_start', sequence: 2, phase: 'implement', agent: 'implementer' }) + '\n', - ); - }, 200); - setTimeout(async () => { - await appendFile( - logPath, - makeEvent({ event: 'pipeline_end', sequence: 3, outcome: 'completed', durationMs: 5000 }) + '\n', - ); - }, 400); + test('raw format surfaces generations; structured hides them', async () => { + const make = () => + trace([ + obs({ id: 'g', type: 'GENERATION', name: 'turn', usageDetails: { total: 100 }, costDetails: { total: 0.01 } }), + obs({ + id: 'r', + type: 'SPAN', + name: 'phase:retrospective', + startTime: '2026-01-01T00:00:03Z', + endTime: '2026-01-01T00:00:04Z', + }), + ]); + const raw = await collect({ taskSlug: 'task-1', format: 'raw', client: fakeClient([make()]) }); + expect(raw.some((r) => r.kind === 'generation')).toBe(true); + + const structured = await collect({ taskSlug: 'task-1', format: 'structured', client: fakeClient([make()]) }); + expect(structured.some((r) => r.kind === 'generation')).toBe(false); + }); - const collected: PipelineEvent[] = []; - for await (const event of watchEventLog({ - taskSlug, - caseRoot: tmpDir, - runId: 'incr', - format: 'structured', - pollIntervalMs: 100, - })) { - collected.push(event); - } + test('returns when no trace ever appears', async () => { + const records = await collect({ taskSlug: 'task-1', client: fakeClient([], { noTrace: true }), timeoutMs: 50 }); + expect(records).toEqual([]); + }); - expect(collected).toHaveLength(3); - expect(collected[0].event).toBe('pipeline_start'); - expect(collected[1].event).toBe('phase_start'); - expect(collected[2].event).toBe('pipeline_end'); + test('returns on idle when the run goes quiet without a retrospective', async () => { + const snapshot = trace([ + obs({ + id: 'a', + type: 'SPAN', + name: 'phase:implement', + startTime: '2026-01-01T00:00:01Z', + endTime: '2026-01-01T00:00:02Z', + }), + ]); + const records = await collect({ taskSlug: 'task-1', client: fakeClient([snapshot]) }); + expect(records.some((r) => r.kind === 'span_start')).toBe(true); + expect(records.some((r) => r.kind === 'run_complete')).toBe(false); }); }); - -// Renderer-specific tests live in `watch-renderer.spec.ts`. diff --git a/src/agent/adapters/pi-adapter.ts b/src/agent/adapters/pi-adapter.ts index 43d9b8e..51ac6a5 100644 --- a/src/agent/adapters/pi-adapter.ts +++ b/src/agent/adapters/pi-adapter.ts @@ -100,21 +100,6 @@ export class PiRuntimeAdapter implements CaseAgentRuntime { }); } } - if (options.phase) { - const toolEvent = { - event: 'tool_start' as const, - phase: options.phase, - agent: options.agentName, - toolCallId: event.toolCallId, - tool: event.toolName, - args: sanitizedArgs, - }; - if (options.eventAppender) { - void options.eventAppender.append(toolEvent); - } else if (options.traceWriter) { - options.traceWriter.write({ ts: new Date().toISOString(), ...toolEvent }); - } - } } if (event.type === 'tool_execution_end') { const toolStart = toolTimers.get(event.toolCallId); @@ -135,23 +120,6 @@ export class PiRuntimeAdapter implements CaseAgentRuntime { }); } } - if (options.phase) { - const toolEvent = { - event: 'tool_end' as const, - phase: options.phase, - agent: options.agentName, - toolCallId: event.toolCallId, - tool: event.toolName, - durationMs, - isError: event.isError, - result: sanitizeForTrace(event.result), - }; - if (options.eventAppender) { - void options.eventAppender.append(toolEvent); - } else if (options.traceWriter) { - options.traceWriter.write({ ts: new Date().toISOString(), ...toolEvent }); - } - } } }); diff --git a/src/commands/watch.ts b/src/commands/watch.ts index b5b6e78..290be96 100644 --- a/src/commands/watch.ts +++ b/src/commands/watch.ts @@ -1,8 +1,6 @@ import { parseArgs } from 'node:util'; -import { resolvePackageRoot } from '../paths.js'; -import { detectRepo } from '../entry/repo-detector.js'; -export const description = 'Live-tail a task event log'; +export const description = 'Live-tail a task run from its Langfuse trace'; export async function handler(argv: string[]): Promise { const { values, positionals } = parseArgs({ @@ -10,6 +8,7 @@ export async function handler(argv: string[]): Promise { options: { raw: { type: 'boolean' }, 'no-color': { type: 'boolean' }, + run: { type: 'string' }, }, allowPositionals: true, strict: false, @@ -27,19 +26,21 @@ export async function handler(argv: string[]): Promise { process.env.NO_COLOR = '1'; } - const caseRoot = resolvePackageRoot(); - let stateRoot = process.cwd(); - try { - stateRoot = (await detectRepo(caseRoot)).path; - } catch { - // Allow explicit use from a repo-like directory or tests that pass a temp root. - } - const { watchEventLog } = await import('../watch/watcher.js'); + const { watchTrace, WatchKeysMissingError } = await import('../watch/watcher.js'); const { renderWatchEvent } = await import('../watch/renderer.js'); const format = values.raw ? ('raw' as const) : ('structured' as const); + const runId = typeof values.run === 'string' ? values.run : undefined; - for await (const event of watchEventLog({ taskSlug, caseRoot: stateRoot, format })) { - process.stdout.write(renderWatchEvent(event) + '\n'); + try { + for await (const record of watchTrace({ taskSlug, runId, format })) { + process.stdout.write(renderWatchEvent(record) + '\n'); + } + } catch (err) { + if (err instanceof WatchKeysMissingError) { + process.stderr.write(`Error: ${err.message}\n`); + return 1; + } + throw err; } return 0; diff --git a/src/events/appender.ts b/src/events/appender.ts deleted file mode 100644 index b195c80..0000000 --- a/src/events/appender.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { appendFile, mkdir } from 'node:fs/promises'; -import { resolve } from 'node:path'; -import type { PipelineEvent, PipelineEventInput } from './schema.js'; -import type { PipelineState } from './types.js'; -import { validateTransition } from './errors.js'; -import { applyEvent } from './reducer.js'; - -/** - * Write-only JSONL event sink + in-memory `PipelineState` container. Phase 1.3 - * relocated the td-mirror + marker projections out of `append()` to node-direct - * writes in the LangGraph engine (see `langgraph/projection.ts`); the raw log - * stays as the observability sink until it is deleted in Phase 2.2. `getState()` - * still backs metrics and the retrospective snapshot. - */ -export class EventAppender { - private readonly filePath: string; - private readonly runId: string; - private state: PipelineState | null = null; - private sequence = 0; - private dirReady: Promise | null = null; - - constructor(caseRoot: string, taskSlug: string, runId: string) { - this.runId = runId; - const eventDir = resolve(caseRoot, '.case', taskSlug, 'events'); - this.filePath = resolve(eventDir, `run-${runId}.jsonl`); - this.dirReady = mkdir(eventDir, { recursive: true }).then(() => {}); - } - - async append(partial: PipelineEventInput): Promise { - const event = { - ...partial, - ts: new Date().toISOString(), - sequence: ++this.sequence, - runId: this.runId, - } as PipelineEvent; - - validateTransition(event, this.state); - - if (this.dirReady) { - await this.dirReady; - this.dirReady = null; - } - - await appendFile(this.filePath, JSON.stringify(event) + '\n'); - - this.state = applyEvent(this.state, event); - } - - getState(): PipelineState { - if (!this.state) throw new Error('No events appended yet'); - return this.state; - } - - get path(): string { - return this.filePath; - } -} diff --git a/src/events/errors.ts b/src/events/errors.ts deleted file mode 100644 index 49e4e7c..0000000 --- a/src/events/errors.ts +++ /dev/null @@ -1,82 +0,0 @@ -import type { PipelineEvent } from './schema.js'; -import type { PipelineState } from './types.js'; - -export class LifecycleValidationError extends Error { - override readonly name = 'LifecycleValidationError'; - - constructor( - public readonly event: PipelineEvent, - public readonly currentState: PipelineState | null, - public readonly reason: string, - ) { - super(`Invalid lifecycle transition: ${reason}`); - } -} - -export function validateTransition(event: PipelineEvent, state: PipelineState | null): void | never { - switch (event.event) { - case 'pipeline_start': { - if (state !== null) { - throw new LifecycleValidationError(event, state, 'Pipeline already started'); - } - return; - } - - case 'phase_start': { - assertRunning(event, state); - // Allow concurrent phases (e.g., verify + review run in parallel) - return; - } - - case 'phase_end': { - assertRunning(event, state); - // Allow phase_end for skipped phases that were never started - if (event.outcome === 'skipped') return; - // Verify at least one phase is running - if (state!.runningPhases.size === 0 && state!.currentPhase === null) { - throw new LifecycleValidationError(event, state, 'Cannot end phase when no phases are running'); - } - return; - } - - case 'revision_requested': { - assertRunning(event, state); - const hasEvaluator = Array.from(state!.phases.values()).some( - (p) => (p.phase === 'verify' || p.phase === 'review') && p.status === 'completed', - ); - if (!hasEvaluator) { - throw new LifecycleValidationError(event, state, 'Cannot request revision without evaluator output'); - } - return; - } - - case 'pipeline_end': { - assertRunning(event, state); - return; - } - - case 'tool_start': - case 'tool_end': - case 'revision_budget_exhausted': - case 'fingerprint_match': - case 'scout_completed': - case 'status_changed': - case 'marker_written': { - assertRunning(event, state); - return; - } - - default: { - assertRunning(event, state); - } - } -} - -function assertRunning(event: PipelineEvent, state: PipelineState | null): asserts state is PipelineState { - if (state === null) { - throw new LifecycleValidationError(event, state, 'Pipeline not started'); - } - if (state.outcome !== 'running') { - throw new LifecycleValidationError(event, state, 'Cannot append events after pipeline end'); - } -} diff --git a/src/events/reducer.ts b/src/events/reducer.ts deleted file mode 100644 index a461d56..0000000 --- a/src/events/reducer.ts +++ /dev/null @@ -1,201 +0,0 @@ -import { readFile } from 'node:fs/promises'; -import type { PipelineEvent } from './schema.js'; -import type { PipelineState } from './types.js'; - -export function reduceEvents(events: PipelineEvent[]): PipelineState { - let state: PipelineState | null = null; - - for (const event of events) { - state = applyEvent(state, event); - } - - if (state === null) { - throw new Error('No events to reduce — expected at least a pipeline_start event'); - } - - return state; -} - -export function applyEvent(state: PipelineState | null, event: PipelineEvent): PipelineState { - switch (event.event) { - case 'pipeline_start': { - return { - runId: event.runId, - taskId: event.taskId, - profile: event.profile, - plan: event.plan, - status: 'active', - phases: new Map(), - currentPhase: null, - runningPhases: new Set(), - revisionCycles: 0, - pendingRevision: null, - markers: new Set(), - outcome: 'running', - startedAt: event.ts, - lastSequence: event.sequence, - }; - } - - case 'phase_start': { - const s = ensureState(state, event); - const key = isTerminalPhase(event.phase) ? event.phase : `${event.phase}_${s.revisionCycles}`; - const updated = cloneState(s); - updated.phases.set(key, { - phase: event.phase, - agent: event.agent, - status: 'running', - startedAt: event.ts, - }); - updated.currentPhase = key; - updated.runningPhases.add(key); - updated.lastSequence = event.sequence; - return updated; - } - - case 'phase_end': { - const s = ensureState(state, event); - const updated = cloneState(s); - const key = isTerminalPhase(event.phase) ? event.phase : `${event.phase}_${s.revisionCycles}`; - // Find the matching phase — try the key first, fall back to currentPhase - const phaseState = - updated.phases.get(key) ?? (updated.currentPhase ? updated.phases.get(updated.currentPhase) : undefined); - if (phaseState) { - phaseState.status = - event.outcome === 'completed' ? 'completed' : event.outcome === 'skipped' ? 'skipped' : 'failed'; - phaseState.completedAt = event.ts; - phaseState.durationMs = event.durationMs; - if (event.result) phaseState.result = event.result; - } - updated.runningPhases.delete(key); - // currentPhase = last remaining running phase, or null - if (updated.runningPhases.size > 0) { - updated.currentPhase = [...updated.runningPhases][updated.runningPhases.size - 1]; - } else { - updated.currentPhase = null; - } - updated.lastSequence = event.sequence; - return updated; - } - - case 'revision_requested': { - const s = ensureState(state, event); - const updated = cloneState(s); - updated.revisionCycles = event.cycle; - updated.pendingRevision = { - source: event.source, - failedCategories: event.failedCategories, - summary: '', - suggestedFocus: [], - cycle: event.cycle, - }; - updated.lastSequence = event.sequence; - return updated; - } - - case 'revision_budget_exhausted': { - const s = ensureState(state, event); - const updated = cloneState(s); - updated.lastSequence = event.sequence; - return updated; - } - - case 'fingerprint_match': { - const s = ensureState(state, event); - const updated = cloneState(s); - updated.lastSequence = event.sequence; - return updated; - } - - case 'scout_completed': { - const s = ensureState(state, event); - const updated = cloneState(s); - updated.lastSequence = event.sequence; - return updated; - } - - case 'status_changed': { - const s = ensureState(state, event); - const updated = cloneState(s); - updated.status = event.to; - updated.lastSequence = event.sequence; - return updated; - } - - case 'marker_written': { - const s = ensureState(state, event); - const updated = cloneState(s); - updated.markers.add(event.marker); - updated.lastSequence = event.sequence; - return updated; - } - - case 'pipeline_end': { - const s = ensureState(state, event); - const updated = cloneState(s); - updated.outcome = event.outcome; - updated.completedAt = event.ts; - updated.totalDurationMs = event.durationMs; - if (event.failedAgent) updated.failedAgent = event.failedAgent; - updated.lastSequence = event.sequence; - return updated; - } - - case 'tool_start': - case 'tool_end': { - const s = ensureState(state, event); - const updated = cloneState(s); - updated.lastSequence = event.sequence; - return updated; - } - - default: { - if (state) { - const updated = cloneState(state); - updated.lastSequence = (event as PipelineEvent).sequence; - return updated; - } - return state!; - } - } -} - -const TERMINAL_PHASES = new Set(['close', 'retrospective']); - -function isTerminalPhase(phase: string): boolean { - return TERMINAL_PHASES.has(phase); -} - -function ensureState(state: PipelineState | null, event: PipelineEvent): PipelineState { - if (!state) - throw new Error( - `Event "${event.event}" (sequence ${event.sequence}) received before pipeline_start — event log may be missing or its first line may be corrupt`, - ); - return state; -} - -function cloneState(state: PipelineState): PipelineState { - return { - ...state, - phases: new Map(state.phases), - markers: new Set(state.markers), - runningPhases: new Set(state.runningPhases), - }; -} - -export async function loadEventsFromFile(filePath: string): Promise { - const content = await readFile(filePath, 'utf-8'); - const events: PipelineEvent[] = []; - - for (const line of content.split('\n')) { - const trimmed = line.trim(); - if (!trimmed) continue; - try { - events.push(JSON.parse(trimmed) as PipelineEvent); - } catch { - // Skip unparseable trailing lines (crash tolerance) - } - } - - return events; -} diff --git a/src/events/schema.ts b/src/events/schema.ts deleted file mode 100644 index 8144b52..0000000 --- a/src/events/schema.ts +++ /dev/null @@ -1,106 +0,0 @@ -import type { AgentName, AgentResult, PipelinePhase, PipelineProfile, RubricCategory, TaskStatus } from '../types.js'; -import type { PlanArtifact } from './plan.js'; - -export interface EventMeta { - ts: string; - sequence: number; - runId: string; -} - -export type PipelineEvent = - | (EventMeta & { - event: 'pipeline_start'; - taskId: string; - profile: PipelineProfile; - plan: PlanArtifact; - }) - | (EventMeta & { - event: 'phase_start'; - phase: PipelinePhase; - agent: AgentName | 'retrospective'; - }) - | (EventMeta & { - event: 'phase_end'; - phase: PipelinePhase; - agent: AgentName | 'retrospective'; - outcome: 'completed' | 'failed' | 'skipped'; - durationMs: number; - result?: AgentResult; - }) - | (EventMeta & { - event: 'tool_start'; - phase: PipelinePhase; - agent: AgentName | 'retrospective'; - toolCallId: string; - tool: string; - args: string; - }) - | (EventMeta & { - event: 'tool_end'; - phase: PipelinePhase; - agent: AgentName | 'retrospective'; - toolCallId: string; - tool: string; - durationMs: number; - isError: boolean; - result: string; - }) - | (EventMeta & { - event: 'revision_requested'; - source: 'verifier' | 'reviewer'; - cycle: number; - failedCategories: RubricCategory[]; - }) - | (EventMeta & { - event: 'revision_budget_exhausted'; - cycles: number; - }) - | (EventMeta & { - event: 'fingerprint_match'; - /** Cycle whose fingerprint matched the previous cycle (1-indexed — the cycle being aborted). */ - cycle: number; - /** Truncated SHA-256 fingerprint (16 hex chars). */ - fingerprint: string; - /** Previous cycle that produced the same fingerprint. */ - previousCycle: number; - }) - | (EventMeta & { - event: 'scout_completed'; - /** - * Whether the scout returned validated findings (`true`) or a partial / - * unparseable result that the implementer will run without (`false`). - * The full structured findings live on the scout node's `phase_end` - * result; this event is a lightweight audit signal. - */ - hasFindings: boolean; - /** Count of files the scout flagged as relevant — 0 when `hasFindings` is false. */ - relevantFileCount: number; - /** Count of patterns the scout flagged for the implementer to follow. */ - patternCount: number; - /** Wall-clock duration of the scout dispatch, in ms. */ - durationMs: number; - }) - | (EventMeta & { - event: 'status_changed'; - from: TaskStatus; - to: TaskStatus; - }) - | (EventMeta & { - event: 'marker_written'; - marker: string; - path: string; - }) - | (EventMeta & { - event: 'pipeline_end'; - outcome: 'completed' | 'failed'; - failedAgent?: AgentName; - durationMs: number; - }); - -export type PipelineEventType = PipelineEvent['event']; - -export type PipelineEventInput = PipelineEvent extends infer E - ? E extends PipelineEvent - ? Omit - : never - : never; diff --git a/src/langgraph/engine.ts b/src/langgraph/engine.ts index cf2d37f..d76e46e 100644 --- a/src/langgraph/engine.ts +++ b/src/langgraph/engine.ts @@ -3,7 +3,8 @@ import type { BaseCheckpointSaver } from '@langchain/langgraph-checkpoint'; import type { AgentName, AgentResult, PipelinePhase, PipelineProfile, RevisionRequest, TaskStatus } from '../types.js'; import { PROFILE_PHASES } from '../types.js'; import type { Notifier } from '../notify.js'; -import type { EventAppender } from '../events/appender.js'; +import type { RunState } from '../state/run-state.js'; +import type { LangfuseTracer } from '../tracing/langfuse.js'; import type { TaskStore } from '../state/task-store.js'; import type { DispatchNodeRef } from '../pipeline-dispatch.js'; import { projectNodeState } from './projection.js'; @@ -19,7 +20,14 @@ export type DispatchFn = (node: DispatchNodeRef, revision?: RevisionRequest) => export interface LangGraphEngineArgs { profile: PipelineProfile; maxRevisionCycles: number; - appender: EventAppender; + /** In-memory run-state (Phase 2.2) — drives the node-direct projection + metrics. */ + runState: RunState; + /** + * Per-run Langfuse tracer (Phase 2.2). Orchestration-level domain events + * (`revision_requested` / `revision_budget_exhausted` / `fingerprint_match`) land + * on the trace here. Null/absent → no trace sink; the run is unaffected. + */ + langfuse?: LangfuseTracer | null; /** Task-grain store — receives the node-direct td mirror (RFC §1.3 step 2). */ store: TaskStore; /** Repo data dir; marker files are written under `/.case//`. */ @@ -95,17 +103,17 @@ function fingerprintFor(request: RevisionRequest): string | undefined { * stay correct. */ export async function executeLangGraph(args: LangGraphEngineArgs): Promise { - const { appender, store, caseRoot, notifier, dispatch, onPhaseFailed, maxRevisionCycles } = args; + const { runState, store, caseRoot, notifier, dispatch, onPhaseFailed, maxRevisionCycles, langfuse } = args; const phases = PROFILE_PHASES[args.profile]; const hasScout = phases.includes('scout'); const hasVerify = phases.includes('verify'); - let currentStatus: TaskStatus = appender.getState().status; + let currentStatus: TaskStatus = runState.getState().status; - async function emitStatus(phase: PipelinePhase, state: CaseGraphStateType): Promise { + function emitStatus(phase: PipelinePhase, state: CaseGraphStateType): void { const next = phaseStatus(phase, state); if (!next || next === currentStatus) return; - await appender.append({ event: 'status_changed', from: currentStatus, to: next }); + runState.setStatus(next); currentStatus = next; } @@ -117,12 +125,12 @@ export async function executeLangGraph(args: LangGraphEngineArgs): Promise revision?: RevisionRequest, ): Promise { const startedAt = new Date().toISOString(); - await appender.append({ event: 'phase_start', phase, agent }); + runState.startPhase(phase, agent); notifier.phaseStart(phase, agent); - await emitStatus(phase, state); + emitStatus(phase, state); // Node-direct td mirror at phase start: surfaces the running phase + its new // status to td/humans before the (possibly long) dispatch (RFC §1.3 step 2). - await projectNodeState(appender.getState(), store, caseRoot); + await projectNodeState(runState.getState(), store, caseRoot); notifier.startHeartbeat(); let result: AgentResult; @@ -134,11 +142,11 @@ export async function executeLangGraph(args: LangGraphEngineArgs): Promise const elapsed = Date.now() - Date.parse(startedAt); const outcome = result.status === 'completed' ? 'completed' : 'failed'; - await appender.append({ event: 'phase_end', phase, agent, outcome, durationMs: elapsed, result }); + runState.endPhase(phase, agent, outcome, elapsed, result); // Node-direct td mirror + evidence markers on completion: agent status flips // to completed/failed and a passed verify/review drops its tested/reviewed // marker file in the same tick. - await projectNodeState(appender.getState(), store, caseRoot); + await projectNodeState(runState.getState(), store, caseRoot); notifier.phaseEnd(phase, agent, elapsed, outcome); if (outcome === 'failed' && agent !== 'retrospective') onPhaseFailed(agent); return result; @@ -208,7 +216,7 @@ export async function executeLangGraph(args: LangGraphEngineArgs): Promise // Revision budget: implement nodes exist for cycles 0..maxRevisionCycles, so // a next cycle is available iff c + 1 <= maxRevisionCycles. if (c + 1 > maxRevisionCycles) { - await appender.append({ event: 'revision_budget_exhausted', cycles: c + 1 }); + langfuse?.event('revision_budget_exhausted', { cycles: c + 1 }); notifier.send( `Revision budget exhausted after cycle ${c}. ${source} found issues but no revision cycles remain. Proceeding with warnings.`, ); @@ -219,8 +227,8 @@ export async function executeLangGraph(args: LangGraphEngineArgs): Promise // is unlikely to clear with another pass. const previousFingerprint = c - 1 >= 0 ? state.fingerprints[c - 1] : undefined; if (fingerprint && previousFingerprint && fingerprintsMatch(fingerprint, previousFingerprint)) { - await appender.append({ event: 'fingerprint_match', cycle: c + 1, fingerprint, previousCycle: c - 1 }); - await appender.append({ event: 'revision_budget_exhausted', cycles: c + 1 }); + langfuse?.event('fingerprint_match', { cycle: c + 1, fingerprint, previousCycle: c - 1 }); + langfuse?.event('revision_budget_exhausted', { cycles: c + 1 }); notifier.send( `Revision budget exhausted: fingerprint match (cycle ${c} matched cycle ${c - 1}, ${fingerprint}). Aborting revision cycle ${c + 1} and proceeding with warnings.`, ); @@ -229,8 +237,10 @@ export async function executeLangGraph(args: LangGraphEngineArgs): Promise const merged = mergeRevisionRequests([request]); if (fingerprint) merged.fingerprint = fingerprint; - await appender.append({ - event: 'revision_requested', + // Update run-state (drives metrics + the td pendingRevision projection) and + // surface the domain event on the trace. + runState.requestRevision(merged.source, c + 1, merged.failedCategories); + langfuse?.event('revision_requested', { source: merged.source, cycle: c + 1, failedCategories: merged.failedCategories, diff --git a/src/phases/close.ts b/src/phases/close.ts index 890b89e..d78848e 100644 --- a/src/phases/close.ts +++ b/src/phases/close.ts @@ -53,8 +53,6 @@ export async function runClosePhase( dataDir: config.dataDir, onHeartbeat: config.onAgentHeartbeat, onToolActivity: config.onToolActivity, - traceWriter: config.traceWriter, - eventAppender: config.eventAppender, langfuse: config.langfuse, phase: 'close', }); diff --git a/src/phases/implement.ts b/src/phases/implement.ts index bccda80..e27ec65 100644 --- a/src/phases/implement.ts +++ b/src/phases/implement.ts @@ -54,8 +54,6 @@ export async function runImplementPhase( dataDir: config.dataDir, onHeartbeat: config.onAgentHeartbeat, onToolActivity: config.onToolActivity, - traceWriter: config.traceWriter, - eventAppender: config.eventAppender, phase: 'implement', }); @@ -155,8 +153,6 @@ async function attemptRetry( dataDir: config.dataDir, onHeartbeat: config.onAgentHeartbeat, onToolActivity: config.onToolActivity, - traceWriter: config.traceWriter, - eventAppender: config.eventAppender, phase: 'implement', }); diff --git a/src/phases/retrospective.ts b/src/phases/retrospective.ts index 9cddac4..f8a7fd4 100644 --- a/src/phases/retrospective.ts +++ b/src/phases/retrospective.ts @@ -101,8 +101,6 @@ export async function runRetrospectivePhase( dataDir: config.dataDir, onHeartbeat: config.onAgentHeartbeat, onToolActivity: config.onToolActivity, - traceWriter: config.traceWriter, - eventAppender: config.eventAppender, langfuse: config.langfuse, phase: 'retrospective', }); diff --git a/src/phases/review.ts b/src/phases/review.ts index 78add30..5ce37fb 100644 --- a/src/phases/review.ts +++ b/src/phases/review.ts @@ -55,8 +55,6 @@ export async function runReviewPhase( dataDir: config.dataDir, onHeartbeat: config.onAgentHeartbeat, onToolActivity: config.onToolActivity, - traceWriter: config.traceWriter, - eventAppender: config.eventAppender, langfuse: config.langfuse, phase: 'review', }); diff --git a/src/phases/scout.ts b/src/phases/scout.ts index 946d75f..5dc5608 100644 --- a/src/phases/scout.ts +++ b/src/phases/scout.ts @@ -71,8 +71,6 @@ export async function runScoutPhase(config: PipelineConfig, store: TaskStore): P timeout: timeoutMs, onHeartbeat: config.onAgentHeartbeat, onToolActivity: config.onToolActivity, - traceWriter: config.traceWriter, - eventAppender: config.eventAppender, langfuse: config.langfuse, phase: 'scout', }); diff --git a/src/phases/verify.ts b/src/phases/verify.ts index dae9fa7..9389ea6 100644 --- a/src/phases/verify.ts +++ b/src/phases/verify.ts @@ -57,8 +57,6 @@ export async function runVerifyPhase( dataDir: config.dataDir, onHeartbeat: config.onAgentHeartbeat, onToolActivity: config.onToolActivity, - traceWriter: config.traceWriter, - eventAppender: config.eventAppender, langfuse: config.langfuse, phase: 'verify', }); diff --git a/src/pipeline-dispatch.ts b/src/pipeline-dispatch.ts index 5c7c9f8..58c816d 100644 --- a/src/pipeline-dispatch.ts +++ b/src/pipeline-dispatch.ts @@ -72,14 +72,13 @@ export async function dispatchNode( const output = await runScoutPhase(config, store); consultMatrix(output.outcome); callbacks.setScoutFindings(output.findings); - // Emit a lightweight audit event so cross-run analytics can track - // scout coverage without reading the phase_end payload. - if (config.eventAppender) { + // Emit a lightweight audit event on the trace so cross-run analytics can + // track scout coverage without reading the phase span payload. + { const elapsedMs = output.result.summary.startsWith('[dry-run]') ? 0 : Date.now() - Date.parse(node.startedAt ?? new Date().toISOString()); - await config.eventAppender.append({ - event: 'scout_completed', + config.langfuse?.event('scout_completed', { hasFindings: output.findings !== null, relevantFileCount: output.findings?.relevantFiles.length ?? 0, patternCount: output.findings?.patterns.length ?? 0, @@ -178,12 +177,12 @@ export async function dispatchNode( } case 'retrospective': { - const appenderState = config.eventAppender!.getState(); + const runStateSnapshot = config.runState!.getState(); const metricsSnapshot: MetricsSnapshot = { - revisionCycles: appenderState.revisionCycles, + revisionCycles: runStateSnapshot.revisionCycles, humanOverrides: 0, - profile: appenderState.profile, - evaluatorEffectiveness: projectMetrics(appenderState).evaluatorEffectiveness, + profile: runStateSnapshot.profile, + evaluatorEffectiveness: projectMetrics(runStateSnapshot).evaluatorEffectiveness, }; await runRetrospectivePhase(config, store, previousResults, callbacks.outcome(), undefined, metricsSnapshot); return { diff --git a/src/pipeline.ts b/src/pipeline.ts index 901bff6..d747151 100644 --- a/src/pipeline.ts +++ b/src/pipeline.ts @@ -6,7 +6,7 @@ import { createTuiRenderer, type TuiRenderer } from './render/tui-renderer.js'; import type { Notifier } from './notify.js'; import { writeRunMetrics } from './metrics/writer.js'; import { getCurrentPromptVersions, findPriorRunId } from './versioning/prompt-tracker.js'; -import { EventAppender } from './events/appender.js'; +import { RunState } from './state/run-state.js'; import { generatePlan } from './events/plan.js'; import { projectMetrics } from './events/projections.js'; import { PiRuntimeAdapter } from './agent/adapters/pi-adapter.js'; @@ -80,17 +80,19 @@ async function runPipelineBody( const runId = crypto.randomUUID(); config.runtime ??= new PiRuntimeAdapter(); - // Event log is mutable runtime state — lives under /.case//events/. - const appender = new EventAppender(config.dataDir, task.id, runId); - config.eventAppender = appender; - - // Langfuse dispatch (Phase 2.1) — additive, fire-and-forget. Null when keys - // are unset, in which case observability stays JSONL-only. Never blocks the run. + // Langfuse dispatch (Phase 2.1+) — fire-and-forget per-run trace, now the sole + // observability sink (the granular JSONL log was deleted in 2.2). Null when keys + // are unset → no trace; the run is unaffected. Never blocks the control path. const langfuse = createLangfuseTracer(runId, { id: task.id }); config.langfuse = langfuse; const plan = generatePlan(task, config, runId); + // In-memory run-state (Phase 2.2) — replaces the EventAppender. Drives the + // node-direct td/marker projection, run metrics, and the retrospective snapshot. + const runState = new RunState({ runId, taskId: task.id, profile, plan }); + config.runState = runState; + const { mkdir: mkdirPlan, writeFile: writePlan } = await import('node:fs/promises'); const { resolve: resolvePlan } = await import('node:path'); // Plan + event log live under /.case// — mutable runtime state. @@ -136,26 +138,23 @@ async function runPipelineBody( // task id, so an interrupted run of the same task resumes from its last // superstep; the engine drops the thread on normal completion. td seeds the // first run's pending revision (resume-at-implement); the checkpoint is - // authoritative once a run has begun. Resume is checkpointer-only — the legacy - // event-replay path was removed in Phase 1.3. - await appender.append({ event: 'pipeline_start', taskId: task.id, profile, plan }); + // authoritative once a run has begun. Resume is checkpointer-only. // A td-persisted pending revision seeds the cumulative revision-cycle count so // metrics + the retrospective snapshot see the pre-crash cycles even though no - // new `revision_requested` event fires on this resumed run. The graph state is - // seeded separately via `initialPendingRevision` (the engine routes to - // implement and carries the revision into the cycle counters). + // new revision is requested on this resumed run. The graph state is seeded + // separately via `initialPendingRevision` (the engine routes to implement and + // carries the revision into the cycle counters). if (task.pendingRevision) { - const seedState = appender.getState(); - seedState.revisionCycles = task.pendingRevision.cycle ?? 1; - seedState.pendingRevision = task.pendingRevision; + runState.seedRevision(task.pendingRevision); } const checkpointer = createSqliteCheckpointer(config.repoPath); await executeLangGraph({ profile, maxRevisionCycles, - appender, + runState, + langfuse, store, caseRoot: config.dataDir, notifier, @@ -169,11 +168,11 @@ async function runPipelineBody( threadId: task.id, }); - const totalDurationMs = Date.now() - Date.parse(appender.getState().startedAt); + const totalDurationMs = Date.now() - Date.parse(runState.getState().startedAt); - await appender.append({ event: 'pipeline_end', outcome, failedAgent, durationMs: totalDurationMs }); + runState.end(outcome, failedAgent, totalDurationMs); - const runMetrics = projectMetrics(appender.getState()); + const runMetrics = projectMetrics(runState.getState()); runMetrics.promptVersions = promptVersions; runMetrics.humanOverrides = humanOverrides; const priorRunId = await findPriorRunId(config.repoPath, task.id); @@ -191,7 +190,6 @@ async function runPipelineBody( failedAgent, runId, totalDurationMs: runMetrics.totalDurationMs, - eventLog: appender.path, }); // `outcome` is mutated only via the dispatch/onPhaseFailed closures, which diff --git a/src/state/run-state.ts b/src/state/run-state.ts new file mode 100644 index 0000000..ce48720 --- /dev/null +++ b/src/state/run-state.ts @@ -0,0 +1,124 @@ +import type { AgentName, PipelinePhase, PipelineProfile, RubricCategory } from '../types.js'; +import type { PlanArtifact } from '../events/plan.js'; +import type { PhaseState, PipelineState } from '../events/types.js'; + +/** + * In-memory run-state container (Phase 2.2). + * + * Replaces the `EventAppender` + `reduceEvents` pair. Phase 1.3 had the LangGraph + * engine *drive* `PipelineState` by appending granular events and *read it back* + * via `appender.getState()` for the node-direct td/marker projection, the run + * metrics, and the retrospective snapshot. 2.2 deletes the JSONL event log and its + * schema/reducer, so the transition logic that built `PipelineState` lives here as + * plain typed mutators instead — no event envelope, no file I/O, no replay. + * + * The {@link PipelineState} shape is unchanged, so every downstream projection + * (`projectTaskJson` / `projectMarkers` / `projectMetrics`) is identical by + * construction. Observability moved to Langfuse (the per-run trace); this object + * is process-local orchestration/projection state, rebuilt fresh each run. + * + * Single-owner, mutate-in-place: the reducer cloned state for replay immutability, + * but there is exactly one writer (the engine) and the readers take a live + * snapshot via {@link getState}. `projectNodeState` still mutates `markers` + * directly to dedupe marker writes — unchanged from 1.3. + */ +export class RunState { + private readonly state: PipelineState; + private sequence = 0; + + constructor(args: { runId: string; taskId: string; profile: PipelineProfile; plan: PlanArtifact }) { + this.state = { + runId: args.runId, + taskId: args.taskId, + profile: args.profile, + plan: args.plan, + status: 'active', + phases: new Map(), + currentPhase: null, + runningPhases: new Set(), + revisionCycles: 0, + pendingRevision: null, + markers: new Set(), + outcome: 'running', + startedAt: new Date().toISOString(), + lastSequence: 0, + }; + } + + /** Phase key: terminal phases are singletons; cyclic phases key by revision cycle. */ + private phaseKey(phase: PipelinePhase): string { + return isTerminalPhase(phase) ? phase : `${phase}_${this.state.revisionCycles}`; + } + + startPhase(phase: PipelinePhase, agent: AgentName | 'retrospective'): void { + const key = this.phaseKey(phase); + this.state.phases.set(key, { phase, agent, status: 'running', startedAt: new Date().toISOString() }); + this.state.currentPhase = key; + this.state.runningPhases.add(key); + this.state.lastSequence = ++this.sequence; + } + + endPhase( + phase: PipelinePhase, + _agent: AgentName | 'retrospective', + outcome: 'completed' | 'failed' | 'skipped', + durationMs: number, + result?: PhaseState['result'], + ): void { + const key = this.phaseKey(phase); + const phaseState = + this.state.phases.get(key) ?? + (this.state.currentPhase ? this.state.phases.get(this.state.currentPhase) : undefined); + if (phaseState) { + phaseState.status = outcome === 'completed' ? 'completed' : outcome === 'skipped' ? 'skipped' : 'failed'; + phaseState.completedAt = new Date().toISOString(); + phaseState.durationMs = durationMs; + if (result) phaseState.result = result; + } + this.state.runningPhases.delete(key); + this.state.currentPhase = + this.state.runningPhases.size > 0 ? [...this.state.runningPhases][this.state.runningPhases.size - 1] : null; + this.state.lastSequence = ++this.sequence; + } + + setStatus(to: PipelineState['status']): void { + this.state.status = to; + this.state.lastSequence = ++this.sequence; + } + + requestRevision(source: 'verifier' | 'reviewer', cycle: number, failedCategories: RubricCategory[]): void { + this.state.revisionCycles = cycle; + this.state.pendingRevision = { source, failedCategories, summary: '', suggestedFocus: [], cycle }; + this.state.lastSequence = ++this.sequence; + } + + end(outcome: 'completed' | 'failed', failedAgent: AgentName | undefined, durationMs: number): void { + this.state.outcome = outcome; + this.state.completedAt = new Date().toISOString(); + this.state.totalDurationMs = durationMs; + if (failedAgent) this.state.failedAgent = failedAgent; + this.state.lastSequence = ++this.sequence; + } + + /** + * Seed the cumulative revision-cycle count + pending revision from a td-persisted + * resume (replaces the 1.3 in-place mutation of `getState()` in pipeline.ts). Used + * only on a resumed run so metrics + the retrospective snapshot see the pre-crash + * cycles even though no new revision is requested this run. + */ + seedRevision(revision: import('../types.js').RevisionRequest): void { + this.state.revisionCycles = revision.cycle ?? 1; + this.state.pendingRevision = revision; + } + + /** Live snapshot — the engine is the single writer; readers must not mutate (except the marker dedupe in projectNodeState). */ + getState(): PipelineState { + return this.state; + } +} + +const TERMINAL_PHASES = new Set(['close', 'retrospective']); + +function isTerminalPhase(phase: string): boolean { + return TERMINAL_PHASES.has(phase); +} diff --git a/src/state/transitions.ts b/src/state/transitions.ts index 115e9c5..7a8efed 100644 --- a/src/state/transitions.ts +++ b/src/state/transitions.ts @@ -1,6 +1,5 @@ import type { PipelinePhase, PipelineProfile, TaskJson } from '../types.js'; import { PHASE_ORDER, PROFILE_PHASES } from '../types.js'; -import type { PipelineState } from '../events/types.js'; /** * Determine which pipeline phase to enter based on current task state and profile. @@ -8,29 +7,8 @@ import type { PipelineState } from '../events/types.js'; * * If the raw entry phase is skipped by the profile, advances to the next allowed phase. */ -export function determineEntryPhase(task: TaskJson, profile?: PipelineProfile): PipelinePhase; -export function determineEntryPhase(state: PipelineState): PipelinePhase; -export function determineEntryPhase(taskOrState: TaskJson | PipelineState, profile?: PipelineProfile): PipelinePhase { - if ('runId' in taskOrState) { - return determineEntryPhaseFromState(taskOrState); - } - return determineEntryPhaseFromTask(taskOrState, profile); -} - -function determineEntryPhaseFromState(state: PipelineState): PipelinePhase { - if (state.pendingRevision) return 'implement'; - if (state.outcome === 'completed') return 'complete'; - - const completedPhases = new Set(); - for (const [, phase] of state.phases) { - if (phase.status === 'completed') completedPhases.add(phase.phase); - } - - if (!completedPhases.has('implement')) return 'implement'; - if (!completedPhases.has('verify')) return 'verify'; - if (!completedPhases.has('review')) return 'review'; - if (!completedPhases.has('close')) return 'close'; - return 'retrospective'; +export function determineEntryPhase(task: TaskJson, profile?: PipelineProfile): PipelinePhase { + return determineEntryPhaseFromTask(task, profile); } function determineEntryPhaseFromTask(task: TaskJson, profile?: PipelineProfile): PipelinePhase { diff --git a/src/tracing/langfuse.ts b/src/tracing/langfuse.ts index 6690c5d..655d90b 100644 --- a/src/tracing/langfuse.ts +++ b/src/tracing/langfuse.ts @@ -55,6 +55,15 @@ export interface AgentSpan { export interface LangfuseTracer { /** Open a phase span under the run trace. Always returns a usable (possibly no-op) handle. */ startAgentSpan(agentName: string, phase?: string): AgentSpan; + /** + * Trace-level domain event (Phase 2.2). Orchestration-level events that have no + * agent span — `revision_requested`, `revision_budget_exhausted`, + * `fingerprint_match`, `scout_completed` — land on the run trace directly. These + * used to be granular JSONL events; with the log gone they become trace events + * so `ca watch` and the Langfuse UI still surface the revision/fingerprint story. + * Self-defensive: never throws into the control path. + */ + event(name: string, data?: unknown): void; /** Fire-and-forget flush — never awaited in the control path. */ flushSafely(): void; /** Bounded flush at run end: races shutdown against a timeout so a hung sink can't block. */ @@ -192,6 +201,14 @@ export function createLangfuseTracer(runId: string, task: { id: string; title?: }; }, + event(name, data) { + try { + trace.event({ name, input: data }); + } catch (e) { + log.error('langfuse trace event failed', { error: e instanceof Error ? e.message : String(e) }); + } + }, + flushSafely() { try { void client.flushAsync().catch((e: unknown) => { diff --git a/src/tracing/readback.ts b/src/tracing/readback.ts new file mode 100644 index 0000000..875cbe7 --- /dev/null +++ b/src/tracing/readback.ts @@ -0,0 +1,138 @@ +/** + * Langfuse read-back client + helpers. + * + * The control path never reads Langfuse (RFC §1, §7) — but **human tools** may. + * Two consumers share this module: + * - the e2e tier (Phase 2.1), which reads a trace back to prove the dispatch wire; + * - `ca watch` (Phase 2.2), which loads a run's observations then polls for new + * ones to drive a live terminal tail (Langfuse has no push API, so "subscribe" + * is poll-with-cursor — exactly what the dashboard does). + * + * Always a **separate read-only client** from the tracer's write client, keeping the + * §7 control-path/observability separation intact. Ingestion is async + batched + * (SDK flush → ClickHouse write interval), so a trace/observation is not queryable + * the instant it is dispatched — callers poll with a deadline. + */ +import { Langfuse } from 'langfuse'; + +export interface ReadConfig { + publicKey: string; + secretKey: string; + baseUrl: string; +} + +/** Read the project keys + host the same way the tracer does. Null when keys absent. */ +export function readConfig(): ReadConfig | null { + const publicKey = process.env.LANGFUSE_PUBLIC_KEY; + const secretKey = process.env.LANGFUSE_SECRET_KEY; + if (!publicKey || !secretKey) return null; + const baseUrl = process.env.LANGFUSE_HOST ?? process.env.LANGFUSE_BASE_URL ?? 'http://localhost:3000'; + return { publicKey, secretKey, baseUrl }; +} + +/** Tier 1 e2e gate: opt-in flag + a reachable, keyed Langfuse. */ +export function e2eEnabled(): boolean { + return process.env.LANGFUSE_E2E === '1' && readConfig() !== null; +} + +/** Tier 2 e2e gate: the heavier real-LLM smoke, behind its own flag. */ +export function llmE2eEnabled(): boolean { + return process.env.LANGFUSE_E2E_LLM === '1' && readConfig() !== null; +} + +/** A read-only client, independent of any write client (honors §7 separation). */ +export function makeReadClient(): Langfuse { + const cfg = readConfig(); + if (!cfg) throw new Error('Langfuse keys absent — guard with readConfig()/e2eEnabled() before calling.'); + return new Langfuse({ publicKey: cfg.publicKey, secretKey: cfg.secretKey, baseUrl: cfg.baseUrl }); +} + +/** A single observation as returned by the public trace-details API (loosely typed). */ +export interface Observation { + id: string; + type: 'SPAN' | 'GENERATION' | 'EVENT' | string; + name?: string | null; + model?: string | null; + startTime?: string | null; + endTime?: string | null; + level?: string | null; + parentObservationId?: string | null; + usageDetails?: Record | null; + costDetails?: Record | null; + input?: unknown; + output?: unknown; +} + +export interface TraceScore { + name?: string | null; + value?: number | null; + comment?: string | null; +} + +export interface TraceDetails { + id: string; + observations: Observation[]; + scores: TraceScore[]; +} + +/** + * Resolve the most recent trace id for a trace name (e.g. `case-run:`). + * Returns null when no trace exists yet (run hasn't dispatched, or keys-absent run + * produced no trace). `ca watch` polls this until a trace appears. + */ +export async function listLatestTraceIdByName(client: Langfuse, name: string): Promise { + try { + const res = (await client.api.traceList({ name, orderBy: 'timestamp.desc', limit: 1 })) as unknown as { + data?: Array<{ id: string }>; + }; + return res.data?.[0]?.id ?? null; + } catch { + return null; + } +} + +/** Fetch a trace's full observation + score set. Throws on transport error (caller decides retry). */ +export async function getTraceDetails(client: Langfuse, traceId: string): Promise { + return (await client.api.traceGet(traceId)) as unknown as TraceDetails; +} + +/** + * Poll the public trace API until at least `minObservations` are present. + * Throws on timeout so the assertion failure points at "ingest never landed". + */ +export async function pollTrace( + client: Langfuse, + traceId: string, + opts: { minObservations?: number; timeoutMs?: number; intervalMs?: number } = {}, +): Promise { + const minObservations = opts.minObservations ?? 1; + const timeoutMs = opts.timeoutMs ?? 30_000; + const intervalMs = opts.intervalMs ?? 1_500; + + const deadline = Date.now() + timeoutMs; + let last: TraceDetails | null = null; + let lastErr: unknown; + + while (Date.now() < deadline) { + try { + const trace = await getTraceDetails(client, traceId); + last = trace; + if ((trace.observations?.length ?? 0) >= minObservations) return trace; + } catch (e) { + // 404 until the trace is first ingested — expected; keep polling. + lastErr = e; + } + await new Promise((r) => setTimeout(r, intervalMs)); + } + + const got = last?.observations?.length ?? 0; + throw new Error( + `pollTrace timed out after ${timeoutMs}ms for trace ${traceId}: ` + + `got ${got}/${minObservations} observations` + + (lastErr ? ` (last error: ${lastErr instanceof Error ? lastErr.message : String(lastErr)})` : ''), + ); +} + +export const byName = (obs: Observation[], name: string): Observation | undefined => obs.find((o) => o.name === name); + +export const ofType = (obs: Observation[], type: string): Observation[] => obs.filter((o) => o.type === type); diff --git a/src/types.ts b/src/types.ts index 66c562a..246683a 100644 --- a/src/types.ts +++ b/src/types.ts @@ -159,11 +159,13 @@ export interface PipelineConfig { onToolActivity?: (event: import('./render/types.js').ToolActivityEvent) => void; /** Optional pre-built notifier override (tests / custom renderers). Defaults to StructuredLogRenderer. */ notifier?: import('./notify.js').Notifier; - /** Per-run trace writer for tool-level observability (deprecated — use eventAppender). */ - traceWriter?: { write(event: any): void; flush(): Promise; path: string }; - /** Event appender for unified event logging. */ - eventAppender?: import('./events/appender.js').EventAppender; - /** Per-run Langfuse tracer (Phase 2.1). Absent → JSONL-only observability. */ + /** + * In-memory run-state container (Phase 2.2). Drives the node-direct td/marker + * projection, run metrics, and the retrospective snapshot — replaces the deleted + * `eventAppender`. Set by the pipeline; read by the engine + dispatch. + */ + runState?: import('./state/run-state.js').RunState; + /** Per-run Langfuse tracer (Phase 2.1). Absent → no trace sink (observability disabled). */ langfuse?: import('./tracing/langfuse.js').LangfuseTracer | null; /** Agent runtime for spawning agents. */ runtime?: import('./agent/runtime.js').CaseAgentRuntime; @@ -324,11 +326,7 @@ export interface SpawnAgentOptions { onHeartbeat?: (elapsedMs: number) => void; /** Called on every tool start/end so renderers can show live activity. */ onToolActivity?: (event: import('./render/types.js').ToolActivityEvent) => void; - /** Trace writer for per-run observability (deprecated — use eventAppender). */ - traceWriter?: { write(event: any): void; flush(): Promise; path: string }; - /** Event appender for unified event logging. */ - eventAppender?: import('./events/appender.js').EventAppender; - /** Per-run Langfuse tracer (Phase 2.1). Absent → JSONL-only observability. */ + /** Per-run Langfuse tracer (Phase 2.1). Absent → no trace sink (observability disabled). */ langfuse?: import('./tracing/langfuse.js').LangfuseTracer | null; /** Current pipeline phase (used for trace events). */ phase?: PipelinePhase; @@ -551,7 +549,6 @@ export interface InterviewFindings { ciProvider: string; } -// Event system re-exports -export type { PipelineEvent } from './events/schema.js'; +// Run-state re-exports export type { PipelineState } from './events/types.js'; export type { PlanArtifact } from './events/plan.js'; diff --git a/src/watch/renderer.ts b/src/watch/renderer.ts index 78aecb8..b43f74f 100644 --- a/src/watch/renderer.ts +++ b/src/watch/renderer.ts @@ -1,61 +1,48 @@ -import type { PipelineEvent } from '../events/schema.js'; -import { formatDuration, formatPhaseEnd, formatPhaseHeader, formatToolLine } from '../render/format.js'; +import type { WatchRecord } from './watcher.js'; +import { formatDuration } from '../render/format.js'; import { cyan, dim, green, red, yellow } from '../render/color.js'; /** - * Render a single PipelineEvent for `ca watch`. Uses the same formatting - * primitives as the inline structured log, with colors applied (respecting - * NO_COLOR / FORCE_COLOR / TTY detection in `render/color.ts`). + * Render a single `ca watch` record (a Langfuse observation, Phase 2.2) for the + * terminal tail. Uses the same color primitives as the inline structured log. */ -export function renderWatchEvent(event: PipelineEvent): string { - switch (event.event) { - case 'pipeline_start': - return cyan(`▶ pipeline started (${event.profile} profile, run ${event.runId.slice(0, 8)})`); - - case 'phase_start': - return formatPhaseHeader(event.phase, event.agent); - - case 'phase_end': { - if (event.outcome === 'skipped') { - return dim(`⊘ ${event.phase} skipped`); +export function renderWatchEvent(record: WatchRecord): string { + switch (record.kind) { + case 'trace_start': + return cyan(`▶ watching ${record.traceName} (trace ${record.traceId.slice(0, 8)})`); + + case 'span_start': + if (record.span === 'phase') return cyan(`▶ ${record.name}`); + if (record.span === 'tool') return dim(` ⚙ ${record.name}`); + return dim(` · ${record.name}`); + + case 'span_end': { + const dur = formatDuration(record.durationMs); + if (record.span === 'phase') { + return record.isError ? red(`✗ ${record.name} (${dur})`) : green(`✓ ${record.name} (${dur})`); } - const status = event.outcome === 'completed' ? 'completed' : 'failed'; - const raw = formatPhaseEnd(event.phase, event.agent, event.durationMs, status); - const icon = status === 'completed' ? green(raw[0]!) : red(raw[0]!); - return `${icon}${raw.slice(1)}`; + const line = dim(` ⚙ ${record.name} (${dur})`); + return record.isError ? `${line}${red(' ERROR')}` : line; } - case 'tool_start': - return dim(formatToolLine(event.tool, event.args)); - - case 'tool_end': { - const line = dim(formatToolLine(event.tool, '', event.durationMs)); - return event.isError ? `${line}${red(' ERROR')}` : line; + case 'generation': { + const parts: string[] = []; + if (record.tokens !== undefined) parts.push(`${record.tokens} tok`); + if (record.cost !== undefined) parts.push(`$${record.cost.toFixed(4)}`); + const meta = parts.length > 0 ? ` (${parts.join(', ')})` : ''; + return dim(` ↳ turn${record.model ? ` ${record.model}` : ''}${meta}`); } - case 'revision_requested': - return yellow(`↻ revision requested by ${event.source} (cycle ${event.cycle})`); - - case 'revision_budget_exhausted': - return yellow(`⚠ revision budget exhausted (${event.cycles} cycles)`); - - case 'fingerprint_match': - return yellow( - `⚠ fingerprint match: aborting revision cycle ${event.cycle} (same failure as cycle ${event.previousCycle}, ${event.fingerprint})`, - ); - - case 'status_changed': - return dim(`→ ${event.to}`); + case 'event': + return yellow(`↻ ${record.name}`); - case 'pipeline_end': - return event.outcome === 'completed' - ? green(`✓ pipeline complete (${formatDuration(event.durationMs)})`) - : red(`✗ pipeline failed at ${event.failedAgent ?? 'unknown'} (${formatDuration(event.durationMs)})`); + case 'score': + return dim(`★ ${record.name}: ${record.value}${record.comment ? ` — ${record.comment}` : ''}`); - case 'marker_written': - return dim(`📎 marker: ${event.marker}`); + case 'run_complete': + return green('✓ run complete'); default: - return dim(`? ${(event as { event: string }).event}`); + return dim(`? ${(record as { kind: string }).kind}`); } } diff --git a/src/watch/watcher.ts b/src/watch/watcher.ts index fca622b..0a3677d 100644 --- a/src/watch/watcher.ts +++ b/src/watch/watcher.ts @@ -1,157 +1,191 @@ -import { open, readdir, stat } from 'node:fs/promises'; -import { resolve } from 'node:path'; -import type { PipelineEvent } from '../events/schema.js'; +import type { Langfuse } from 'langfuse'; +import { + getTraceDetails, + listLatestTraceIdByName, + makeReadClient, + readConfig, + type Observation, +} from '../tracing/readback.js'; + +/** + * `ca watch` data source (Phase 2.2). + * + * The granular JSONL event log was deleted, so the live tail now reads the run's + * Langfuse trace: load the observations that already landed, then poll-with-cursor + * for new ones (Langfuse has no push API — this is what the dashboard does). A + * human tool reading Langfuse does **not** violate §7 (that bars the *control + * path*). Requires Langfuse keys + reachability; ingest is async so events surface + * seconds after they happen. + */ export interface WatchOptions { + /** Task id; the trace is named `case-run:`. */ taskSlug: string; - caseRoot: string; + /** Pin a specific run (trace id === runId). Default: latest trace for the task. */ runId?: string; format?: 'structured' | 'raw'; pollIntervalMs?: number; + /** Give up if no new observation arrives for this long (run likely crashed without a retrospective). */ + maxIdleMs?: number; + /** Overall ceiling before the tail returns regardless. */ + timeoutMs?: number; + /** Injected read client (tests). Defaults to a real read-only client. */ + client?: Langfuse; } -const MILESTONE_EVENTS = new Set([ - 'phase_start', - 'phase_end', - 'revision_requested', - 'revision_budget_exhausted', - 'fingerprint_match', - 'status_changed', - 'pipeline_start', - 'pipeline_end', - 'tool_start', - 'tool_end', -]); - -export async function* watchEventLog(options: WatchOptions): AsyncGenerator { - const { taskSlug, caseRoot, format = 'structured', pollIntervalMs = 250 } = options; - - const filePath = await resolveEventLogPath(caseRoot, taskSlug, options.runId); - - // Wait for file to appear with exponential backoff - let waited = 0; - const maxWait = 10000; - let delay = 100; - while (waited < maxWait) { - try { - await stat(filePath); - break; - } catch { - await sleep(delay); - waited += delay; - delay = Math.min(delay * 2, 2000); - } - } - - let offset = 0; - let remainder = ''; - - // Initial read: replay existing events - const initial = await readFromOffset(filePath, offset); - if (initial) { - const { lines, leftover } = parseLines(initial.data, remainder); - offset = initial.bytesRead + offset; - remainder = leftover; - - for (const line of lines) { - const event = parseLine(line); - if (event && shouldYield(event, format)) yield event; - if (event?.event === 'pipeline_end') return; - } - offset = initial.bytesRead; +export type WatchRecord = + | { kind: 'trace_start'; traceId: string; traceName: string } + | { kind: 'span_start'; span: 'phase' | 'tool' | 'other'; name: string } + | { kind: 'span_end'; span: 'phase' | 'tool' | 'other'; name: string; durationMs: number; isError: boolean } + | { kind: 'generation'; model?: string; tokens?: number; cost?: number } + | { kind: 'event'; name: string; data?: unknown } + | { kind: 'score'; name: string; value: number; comment?: string } + | { kind: 'run_complete' }; + +export class WatchKeysMissingError extends Error { + override readonly name = 'WatchKeysMissingError'; + constructor() { + super( + 'ca watch requires Langfuse — set LANGFUSE_HOST, LANGFUSE_PUBLIC_KEY and LANGFUSE_SECRET_KEY ' + + '(the granular JSONL event log was removed in the LangGraph + Langfuse migration).', + ); } +} - // Tail loop - while (true) { - await sleep(pollIntervalMs); +function spanKind(name: string | null | undefined): { span: 'phase' | 'tool' | 'other'; label: string } { + if (name?.startsWith('phase:')) return { span: 'phase', label: name.slice('phase:'.length) }; + if (name?.startsWith('tool:')) return { span: 'tool', label: name.slice('tool:'.length) }; + return { span: 'other', label: name ?? 'span' }; +} - const chunk = await readFromOffset(filePath, offset); - if (!chunk || chunk.data.length === 0) continue; +function durationMs(o: Observation): number { + if (!o.startTime || !o.endTime) return 0; + return Math.max(0, Date.parse(o.endTime) - Date.parse(o.startTime)); +} - const { lines, leftover } = parseLines(chunk.data, remainder); - offset += chunk.bytesRead; - remainder = leftover; +function generationTokens(o: Observation): number | undefined { + const u = o.usageDetails; + if (!u) return undefined; + if (typeof u.total === 'number') return u.total; + const sum = (u.input ?? 0) + (u.output ?? 0); + return sum > 0 ? sum : undefined; +} - for (const line of lines) { - const event = parseLine(line); - if (event && shouldYield(event, format)) yield event; - if (event?.event === 'pipeline_end') return; +/** Translate an observation into watch records (start now; end emitted later when it gains an endTime). */ +function startRecord(o: Observation, format: 'structured' | 'raw'): WatchRecord | null { + switch (o.type) { + case 'SPAN': { + const { span, label } = spanKind(o.name); + return { kind: 'span_start', span, name: label }; } + case 'GENERATION': + // turn-level generations are noisy; structured tail hides them, raw shows them. + if (format !== 'raw') return null; + return { + kind: 'generation', + model: o.model ?? undefined, + tokens: generationTokens(o), + cost: o.costDetails?.total, + }; + case 'EVENT': + return { kind: 'event', name: o.name ?? 'event', data: o.input }; + default: + return null; } } -async function resolveEventLogPath(caseRoot: string, taskSlug: string, runId?: string): Promise { - const eventDir = resolve(caseRoot, '.case', taskSlug, 'events'); - - if (runId) { - return resolve(eventDir, `run-${runId}.jsonl`); +/** + * Tail a run's Langfuse trace. Yields records as observations land, ending when the + * retrospective phase span closes (the last phase) or on idle/overall timeout. + */ +export async function* watchTrace(options: WatchOptions): AsyncGenerator { + const format = options.format ?? 'structured'; + const pollIntervalMs = options.pollIntervalMs ?? 1500; + const maxIdleMs = options.maxIdleMs ?? 60_000; + const timeoutMs = options.timeoutMs ?? 30 * 60_000; + + if (!options.client && readConfig() === null) throw new WatchKeysMissingError(); + const client = options.client ?? makeReadClient(); + + const traceName = `case-run:${options.taskSlug}`; + + // Resolve the trace id (pinned run, or the latest trace for the task). Poll until + // it appears — the run may not have dispatched its first observation yet. + let traceId = options.runId ?? null; + const appearDeadline = Date.now() + Math.min(timeoutMs, 30_000); + while (!traceId && Date.now() < appearDeadline) { + traceId = await listLatestTraceIdByName(client, traceName); + if (!traceId) await sleep(pollIntervalMs); } - - // Find latest .jsonl by mtime - try { - const files = await readdir(eventDir); - const jsonlFiles = files.filter((f) => f.endsWith('.jsonl')); - if (jsonlFiles.length === 0) { - return resolve(eventDir, 'run-latest.jsonl'); + if (!traceId) return; // nothing to watch + yield { kind: 'trace_start', traceId, traceName }; + + const seen = new Set(); + const ended = new Set(); + const seenScores = new Set(); + const overallDeadline = Date.now() + timeoutMs; + let lastActivity = Date.now(); + + while (Date.now() < overallDeadline) { + let observations: Observation[] = []; + let scores: { name?: string | null; value?: number | null; comment?: string | null }[] = []; + try { + const trace = await getTraceDetails(client, traceId); + observations = trace.observations ?? []; + scores = trace.scores ?? []; + } catch { + // Transient read error — keep polling. + await sleep(pollIntervalMs); + continue; } - let latest = jsonlFiles[0]; - let latestMtime = 0; - for (const file of jsonlFiles) { - const s = await stat(resolve(eventDir, file)); - if (s.mtimeMs > latestMtime) { - latestMtime = s.mtimeMs; - latest = file; + let activity = false; + + // New observations, in start order. + const fresh = observations + .filter((o) => !seen.has(o.id)) + .sort((a, b) => Date.parse(a.startTime ?? '') - Date.parse(b.startTime ?? '')); + for (const o of fresh) { + seen.add(o.id); + const rec = startRecord(o, format); + if (rec) { + yield rec; + activity = true; } } - return resolve(eventDir, latest); - } catch { - return resolve(eventDir, 'run-latest.jsonl'); - } -} -async function readFromOffset(filePath: string, offset: number): Promise<{ data: string; bytesRead: number } | null> { - try { - const fh = await open(filePath, 'r'); - try { - const fileStat = await fh.stat(); - if (fileStat.size <= offset) return null; - - const buf = Buffer.alloc(fileStat.size - offset); - const { bytesRead } = await fh.read(buf, 0, buf.length, offset); - return { data: buf.toString('utf-8', 0, bytesRead), bytesRead }; - } finally { - await fh.close(); + // Spans that have since closed → emit completion (and detect run end). + let retrospectiveEnded = false; + for (const o of observations) { + if (o.type !== 'SPAN' || !o.endTime || ended.has(o.id)) continue; + ended.add(o.id); + const { span, label } = spanKind(o.name); + yield { kind: 'span_end', span, name: label, durationMs: durationMs(o), isError: o.level === 'ERROR' }; + activity = true; + if (span === 'phase' && label === 'retrospective') retrospectiveEnded = true; } - } catch { - return null; - } -} -function parseLines(data: string, remainder: string): { lines: string[]; leftover: string } { - const combined = remainder + data; - const parts = combined.split('\n'); + // New scores (verifier/reviewer rubric categories). + for (const s of scores) { + const key = `${s.name}=${s.value}`; + if (seenScores.has(key)) continue; + seenScores.add(key); + yield { kind: 'score', name: s.name ?? 'score', value: s.value ?? 0, comment: s.comment ?? undefined }; + activity = true; + } - // Last element is either empty (data ended with \n) or an incomplete line - const leftover = parts.pop() ?? ''; - const lines = parts.filter((l) => l.trim().length > 0); + if (retrospectiveEnded) { + yield { kind: 'run_complete' }; + return; + } - return { lines, leftover }; -} + if (activity) lastActivity = Date.now(); + else if (Date.now() - lastActivity > maxIdleMs) return; // run went quiet (likely crashed without retrospective) -function parseLine(line: string): PipelineEvent | null { - try { - return JSON.parse(line) as PipelineEvent; - } catch { - return null; + await sleep(pollIntervalMs); } } -function shouldYield(event: PipelineEvent, format: 'structured' | 'raw'): boolean { - if (format === 'raw') return true; - return MILESTONE_EVENTS.has(event.event); -} - function sleep(ms: number): Promise { return new Promise((r) => setTimeout(r, ms)); } diff --git a/test/e2e/readback.ts b/test/e2e/readback.ts index b794e85..6c1857e 100644 --- a/test/e2e/readback.ts +++ b/test/e2e/readback.ts @@ -1,105 +1,22 @@ /** - * Shared E2E read-back helpers (Phase 2.1). + * E2E read-back helpers — re-exported from the shared source module. * - * The control path never reads Langfuse (RFC §7) — but a *test* may, and that - * read-back is the only way to prove the dispatch wire actually lands: auth, - * baseUrl, the ingest schema, the usage/cost mapping, and scores, all verified - * against a real server. - * - * Ingestion is async + batched (SDK flush → ClickHouse write interval), so a - * trace is not queryable the instant we dispatch — {@link pollTrace} retries - * until the observations show up or a deadline passes. - */ -import { Langfuse } from 'langfuse'; - -export interface E2EConfig { - publicKey: string; - secretKey: string; - baseUrl: string; -} - -/** Read the project keys + host the same way the tracer does. */ -function readConfig(): E2EConfig | null { - const publicKey = process.env.LANGFUSE_PUBLIC_KEY; - const secretKey = process.env.LANGFUSE_SECRET_KEY; - if (!publicKey || !secretKey) return null; - const baseUrl = - process.env.LANGFUSE_HOST ?? process.env.LANGFUSE_BASE_URL ?? 'http://localhost:3000'; - return { publicKey, secretKey, baseUrl }; -} - -/** Tier 1 gate: opt-in flag + a reachable, keyed Langfuse. */ -export function e2eEnabled(): boolean { - return process.env.LANGFUSE_E2E === '1' && readConfig() !== null; -} - -/** Tier 2 gate: the heavier real-LLM smoke, behind its own flag. */ -export function llmE2eEnabled(): boolean { - return process.env.LANGFUSE_E2E_LLM === '1' && readConfig() !== null; -} - -/** A read-only client, independent of the tracer's write client (honors §7 separation). */ -export function makeReadClient(): Langfuse { - const cfg = readConfig(); - if (!cfg) throw new Error('Langfuse E2E keys absent — guard with e2eEnabled() before calling.'); - return new Langfuse({ publicKey: cfg.publicKey, secretKey: cfg.secretKey, baseUrl: cfg.baseUrl }); -} - -/** A single observation as returned by the public trace-details API (loosely typed). */ -export interface Observation { - type: 'SPAN' | 'GENERATION' | 'EVENT' | string; - name?: string | null; - model?: string | null; - usageDetails?: Record | null; - costDetails?: Record | null; - parentObservationId?: string | null; -} - -export interface TraceDetails { - id: string; - observations: Observation[]; - scores: Array<{ name?: string | null; value?: number | null; comment?: string | null }>; -} - -/** - * Poll the public trace API until at least `minObservations` are present. - * Throws on timeout so the assertion failure points at "ingest never landed". + * Phase 2.1 introduced these here; Phase 2.2 promoted them to + * `src/tracing/readback.ts` so `ca watch` shares the same read-only client. + * Kept as a thin re-export so the e2e specs' import path is unchanged. */ -export async function pollTrace( - client: Langfuse, - traceId: string, - opts: { minObservations?: number; timeoutMs?: number; intervalMs?: number } = {}, -): Promise { - const minObservations = opts.minObservations ?? 1; - const timeoutMs = opts.timeoutMs ?? 30_000; - const intervalMs = opts.intervalMs ?? 1_500; - - const deadline = Date.now() + timeoutMs; - let last: TraceDetails | null = null; - let lastErr: unknown; - - while (Date.now() < deadline) { - try { - const trace = (await client.api.traceGet(traceId)) as unknown as TraceDetails; - last = trace; - if ((trace.observations?.length ?? 0) >= minObservations) return trace; - } catch (e) { - // 404 until the trace is first ingested — expected; keep polling. - lastErr = e; - } - await new Promise((r) => setTimeout(r, intervalMs)); - } - - const got = last?.observations?.length ?? 0; - throw new Error( - `pollTrace timed out after ${timeoutMs}ms for trace ${traceId}: ` + - `got ${got}/${minObservations} observations` + - (lastErr ? ` (last error: ${lastErr instanceof Error ? lastErr.message : String(lastErr)})` : ''), - ); -} - -export const byName = (obs: Observation[], name: string): Observation | undefined => - obs.find((o) => o.name === name); - -export const ofType = (obs: Observation[], type: string): Observation[] => - obs.filter((o) => o.type === type); +export { + readConfig, + e2eEnabled, + llmE2eEnabled, + makeReadClient, + listLatestTraceIdByName, + getTraceDetails, + pollTrace, + byName, + ofType, + type ReadConfig, + type Observation, + type TraceScore, + type TraceDetails, +} from '../../src/tracing/readback.js'; From cc154a76aba55257350a8b14f3e481705b26c3c9 Mon Sep 17 00:00:00 2001 From: Em Jones Date: Mon, 22 Jun 2026 11:09:32 -0700 Subject: [PATCH 08/17] chore(deps): declare @langchain/langgraph-checkpoint as explicit dependency This package is imported directly in src/langgraph/checkpointer.ts, src/langgraph/engine.ts and test files; it was previously only a transitive dependency of @langchain/langgraph. --- package.json | 1 + 1 file changed, 1 insertion(+) diff --git a/package.json b/package.json index b588741..a506c12 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,7 @@ "dependencies": { "@langchain/core": "^1.2.0", "@langchain/langgraph": "^1.4.4", + "@langchain/langgraph-checkpoint": "^1.1.2", "@mariozechner/pi-agent-core": "^0.73.1", "@mariozechner/pi-ai": "^0.73.1", "@mariozechner/pi-coding-agent": "^0.73.1", From 7b10e68cbe9bd87071833e98080653ab6a2310af Mon Sep 17 00:00:00 2001 From: Em Jones Date: Mon, 22 Jun 2026 11:24:29 -0700 Subject: [PATCH 09/17] fix(implement): pass langfuse tracer to spawnAgent in implement phase Both the initial spawn and the retry spawn in runImplementPhase were omitting langfuse: config.langfuse, leaving the implement phase without observability coverage. Every other phase (scout, verify, review, close, retrospective) passes the tracer. This restores consistent tracing. --- bun.lock | 1 + src/phases/implement.ts | 2 ++ 2 files changed, 3 insertions(+) diff --git a/bun.lock b/bun.lock index 02fec40..4e6441c 100644 --- a/bun.lock +++ b/bun.lock @@ -7,6 +7,7 @@ "dependencies": { "@langchain/core": "^1.2.0", "@langchain/langgraph": "^1.4.4", + "@langchain/langgraph-checkpoint": "^1.1.2", "@mariozechner/pi-agent-core": "^0.73.1", "@mariozechner/pi-ai": "^0.73.1", "@mariozechner/pi-coding-agent": "^0.73.1", diff --git a/src/phases/implement.ts b/src/phases/implement.ts index e27ec65..ec36e81 100644 --- a/src/phases/implement.ts +++ b/src/phases/implement.ts @@ -54,6 +54,7 @@ export async function runImplementPhase( dataDir: config.dataDir, onHeartbeat: config.onAgentHeartbeat, onToolActivity: config.onToolActivity, + langfuse: config.langfuse, phase: 'implement', }); @@ -153,6 +154,7 @@ async function attemptRetry( dataDir: config.dataDir, onHeartbeat: config.onAgentHeartbeat, onToolActivity: config.onToolActivity, + langfuse: config.langfuse, phase: 'implement', }); From 774910448203255a9324217172ff09721dfbf3c8 Mon Sep 17 00:00:00 2001 From: Em Jones Date: Mon, 22 Jun 2026 12:06:39 -0700 Subject: [PATCH 10/17] fix(engine): abort on reviewer hard-rubric fail instead of revising MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The LangGraph engine's evaluatorNode recomputed revision routing with a naive rubricFailed() (any verdict==='fail' → revise), discarding the hard/soft classification that runReviewPhase already encodes. Reviewer hard-gate failures (principle-compliance, scope-discipline) are golden-principle violations — terminal aborts, not fixable-in-a-cycle revisions — but the engine looped them as soft revisions until the revision budget or an agent crash ended the run (the reviewer treadmill). Mirror runReviewPhase's split: reviewer hard fail → phase failure (routes to retrospective), soft fail → revise, verifier fail → revise. Adds a langgraph-parity regression scenario pinning hard-fail → abort (no revision cycle, no close). Co-Authored-By: Claude Opus 4.8 (1M context) --- src/__tests__/langgraph-parity.spec.ts | 31 ++++++++++++++++++++++++++ src/langgraph/engine.ts | 26 +++++++++++++++++---- 2 files changed, 53 insertions(+), 4 deletions(-) diff --git a/src/__tests__/langgraph-parity.spec.ts b/src/__tests__/langgraph-parity.spec.ts index fc92323..2a7fab2 100644 --- a/src/__tests__/langgraph-parity.spec.ts +++ b/src/__tests__/langgraph-parity.spec.ts @@ -149,6 +149,19 @@ const reviewerSoftFail: AgentResult = { }, }; +const reviewerHardFail: AgentResult = { + ...completed, + rubric: { + role: 'reviewer', + categories: [ + { category: 'principle-compliance', verdict: 'fail', detail: 'violates golden principle' }, + { category: 'test-sufficiency', verdict: 'pass', detail: 'OK' }, + { category: 'scope-discipline', verdict: 'pass', detail: 'OK' }, + { category: 'pattern-fit', verdict: 'pass', detail: 'OK' }, + ], + }, +}; + function agentRaw(result: AgentResult): string { return `\n<<>>\n`; } @@ -294,6 +307,24 @@ describe('LangGraph engine routing (phase-outcome sequences)', () => { ); }); + it('reviewer hard-fail aborts (no revision)', async () => { + // Hard-gate categories (principle-compliance, scope-discipline) are + // golden-principle violations: terminal, not revisable. The engine must + // route straight to retrospective — no revision cycle, no close. Regression + // guard for the reviewer-treadmill loop, where a hard fail was spun as a + // soft revision until the budget/crash ended it. + await assertSequence( + [ + spawn(scoutResult), // scout + spawn(completed), // implement c0 + spawn(completed), // verify c0 clean + spawn(reviewerHardFail), // review c0 hard-fail → abort + spawn(completed), // retrospective + ], + ['scout:completed', 'implement:completed', 'verify:completed', 'review:completed', 'retrospective:completed'], + ); + }); + it('revision budget exhausted (maxRevisionCycles=1)', async () => { await assertSequence( [ diff --git a/src/langgraph/engine.ts b/src/langgraph/engine.ts index d76e46e..67c9cf6 100644 --- a/src/langgraph/engine.ts +++ b/src/langgraph/engine.ts @@ -1,7 +1,7 @@ import { StateGraph, START, END } from '@langchain/langgraph'; import type { BaseCheckpointSaver } from '@langchain/langgraph-checkpoint'; import type { AgentName, AgentResult, PipelinePhase, PipelineProfile, RevisionRequest, TaskStatus } from '../types.js'; -import { PROFILE_PHASES } from '../types.js'; +import { PROFILE_PHASES, REVIEWER_HARD_CATEGORIES } from '../types.js'; import type { Notifier } from '../notify.js'; import type { RunState } from '../state/run-state.js'; import type { LangfuseTracer } from '../tracing/langfuse.js'; @@ -83,6 +83,20 @@ function rubricFailed(result: AgentResult): boolean { return result.rubric?.categories.some((c) => c.verdict === 'fail') ?? false; } +/** + * True when a reviewer rubric fails a *hard-gate* category + * (principle-compliance, scope-discipline). These are golden-principle + * violations: terminal aborts, not fixable-in-a-cycle revisions. Mirrors + * `runReviewPhase`'s hard/soft split so the LangGraph engine doesn't loop a hard + * fail as a revision (the reviewer-treadmill bug). Verifier rubrics have no + * hard/soft split — they route through `rubricFailed` (any fail → revise). + */ +function reviewerHardFailed(result: AgentResult): boolean { + if (result.rubric?.role !== 'reviewer') return false; + const hard = new Set(REVIEWER_HARD_CATEGORIES); + return result.rubric.categories.some((c) => c.verdict === 'fail' && hard.has(c.category)); +} + /** * Derive a fingerprint from a single evaluator's revision request. Mirrors the * legacy executor's `computeFingerprintFromRequests` (returns undefined when @@ -172,10 +186,14 @@ export async function executeLangGraph(args: LangGraphEngineArgs): Promise function evaluatorNode(phase: 'verify' | 'review', agent: AgentName) { return async (state: CaseGraphStateType): Promise> => { const result = await runPhase(phase, agent, state); - const failed = result.status !== 'completed'; - const failedRubric = !failed && rubricFailed(result); + const agentFailed = result.status !== 'completed'; + // Reviewer hard-rubric fails are terminal aborts, not revision triggers: + // route them through phase failure (→ retrospective) instead of spinning + // revision cycles. Soft reviewer fails and verifier fails still revise. + const hardAbort = !agentFailed && reviewerHardFailed(result); + const failedRubric = !agentFailed && !hardAbort && rubricFailed(result); return { - last: { phase, status: failed ? 'failed' : 'completed', rubricFailed: failedRubric }, + last: { phase, status: agentFailed || hardAbort ? 'failed' : 'completed', rubricFailed: failedRubric }, evaluator: failedRubric ? { phase, result } : null, }; }; From 1754f2428ce8a0d76997655acd3f9b553e6d088c Mon Sep 17 00:00:00 2001 From: Em Jones Date: Mon, 22 Jun 2026 12:06:39 -0700 Subject: [PATCH 11/17] fix(orchestrator): cut task branches from default branch, not HEAD ensureBranch created new task branches with `git checkout -b `, basing them on whatever HEAD happened to be. Launching a task while an unrelated feature branch was checked out made the new branch inherit that branch's entire diff as its baseline, so the reviewer reviewed the inherited delta instead of the task's own work. Add resolveBaseRef(): branch from origin's default HEAD, else local main/master, falling back to HEAD only when no default branch exists. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/entry/cli-orchestrator.ts | 28 ++++++++++++++++++++++++---- 1 file changed, 24 insertions(+), 4 deletions(-) diff --git a/src/entry/cli-orchestrator.ts b/src/entry/cli-orchestrator.ts index 6594ae5..81b767c 100644 --- a/src/entry/cli-orchestrator.ts +++ b/src/entry/cli-orchestrator.ts @@ -233,9 +233,28 @@ function defaultEvidenceExpectations(strategy: EvidenceStrategy, issue: IssueCon return EVIDENCE_TEMPLATES[strategy](issue); } +/** + * Resolve the ref new task branches should be cut from: the repo's default + * branch (origin's HEAD, else local main/master), never the current HEAD. + * Cutting from whatever happens to be checked out lets a task inherit an + * unrelated feature branch's diff as its baseline — the reviewer then reviews + * that inherited delta instead of the task's own work. Falls back to HEAD only + * when no default branch can be found. + */ +async function resolveBaseRef(repoPath: string): Promise { + const sym = await runCommand('git', ['symbolic-ref', '--short', 'refs/remotes/origin/HEAD'], { cwd: repoPath }); + if (sym.exitCode === 0 && sym.stdout.trim()) return sym.stdout.trim(); + for (const candidate of ['main', 'master']) { + const verify = await runCommand('git', ['rev-parse', '--verify', candidate], { cwd: repoPath }); + if (verify.exitCode === 0) return candidate; + } + return 'HEAD'; +} + /** * Create or checkout a git branch. - * If branch exists, checkout. Otherwise, create from HEAD. + * If branch exists, checkout. Otherwise, create from the repo's default branch + * (see `resolveBaseRef`) — NOT the current HEAD. * When `warnOnCreate` is true (resume flow), warns that the branch was recreated. */ async function ensureBranch(branchName: string, repoPath: string, warnOnCreate = false): Promise { @@ -247,12 +266,13 @@ async function ensureBranch(branchName: string, repoPath: string, warnOnCreate = throw new Error(`Failed to checkout branch ${branchName}: ${co.stderr.trim()}`); } } else { + const base = await resolveBaseRef(repoPath); if (warnOnCreate) { - process.stdout.write(` Warning: branch ${branchName} not found, recreating from HEAD\n`); + process.stdout.write(` Warning: branch ${branchName} not found, recreating from ${base}\n`); } - const create = await runCommand('git', ['checkout', '-b', branchName], { cwd: repoPath }); + const create = await runCommand('git', ['checkout', '-b', branchName, base], { cwd: repoPath }); if (create.exitCode !== 0) { - throw new Error(`Failed to create branch ${branchName}: ${create.stderr.trim()}`); + throw new Error(`Failed to create branch ${branchName} from ${base}: ${create.stderr.trim()}`); } } } From 9261b0e875ece801de1ffec84e5c9dc54cf1abf3 Mon Sep 17 00:00:00 2001 From: Em Jones Date: Mon, 22 Jun 2026 13:08:27 -0700 Subject: [PATCH 12/17] chore(toolchain): adopt vite-plus for fmt, lint, check, dev, and build - Add vite.config.ts with fmt, lint, and build blocks matching .oxfmtrc.json settings; includes bun-text-import plugin for .md/.yml/.yaml transforms - Delete dead vitest.config.ts stub (unused since bun:test migration) - Route package.json lint/format/format:check scripts through vp - Add vite-plus ^0.2.1 to devDependencies Scoped adoption: bun runtime, bun:test, bun:sqlite, and build:binary are unchanged. vp check/fmt/lint/build/dev all pass on a clean tree. 46 unit + 9 standalone specs green (bun src/dev/run-tests.ts). tsc --noEmit clean. bun src/index.ts --help boots. --- .gitignore | 7 + MIGRATE_IMPLEMENTATION.md | 187 ++++++------- bun.lock | 309 +++++++++++++++++++++ package.json | 9 +- podman-compose.yaml | 8 +- src/commands/onboard.ts | 4 +- src/interview/session.ts | 3 +- tasks/README.md | 28 +- test/e2e/langfuse-mocked-agent.e2e.spec.ts | 5 +- vite.config.ts | 59 ++++ vitest.config.ts | 7 - 11 files changed, 498 insertions(+), 128 deletions(-) create mode 100644 vite.config.ts delete mode 100644 vitest.config.ts diff --git a/.gitignore b/.gitignore index 1558f3d..e3208cd 100644 --- a/.gitignore +++ b/.gitignore @@ -28,3 +28,10 @@ dist/ # Local secrets for podman-compose (use .env.example as the template) .env +.sidecar/ +.sidecar-agent +.sidecar-task +.sidecar-pr +.sidecar-start.sh +.sidecar-base +.td-root diff --git a/MIGRATE_IMPLEMENTATION.md b/MIGRATE_IMPLEMENTATION.md index bb9e39d..344e16e 100644 --- a/MIGRATE_IMPLEMENTATION.md +++ b/MIGRATE_IMPLEMENTATION.md @@ -17,7 +17,7 @@ LangGraph (`@langchain/langgraph` 1.4.4 + peer `@langchain/core` 1.2.0, Bun-veri **Landed:** - **`src/pipeline-dispatch.ts` (NEW).** Extracted `dispatchNode` / `consultMatrix` / `handleFailure` / `PipelineCallbacks` out of `pipeline.ts`. Both engines call this one dispatcher, so per-phase semantics (matrix consult, abort prompts via `handleFailure`, scout findings hand-off, `previousResults` bookkeeping) are **identical by construction**. First param generalized to `DispatchNodeRef = { phase, startedAt? }` (legacy `DagNode` is assignable). -- **`src/langgraph/state.ts` (NEW).** `StateGraph` channels: `cycle`, `revisionCycles`, `pendingRevision`, `fingerprints` (Record), `last`, `evaluator`, `decision`, `revisionClosed`. Holds **orchestration** state only — agent context (scout findings, `previousResults`) and run-level `outcome`/`failedAgent` stay in the shared pipeline closure exactly as legacy keeps them. *(This is the object the 1.2 checkpointer will snapshot.)* +- **`src/langgraph/state.ts` (NEW).** `StateGraph` channels: `cycle`, `revisionCycles`, `pendingRevision`, `fingerprints` (Record), `last`, `evaluator`, `decision`, `revisionClosed`. Holds **orchestration** state only — agent context (scout findings, `previousResults`) and run-level `outcome`/`failedAgent` stay in the shared pipeline closure exactly as legacy keeps them. _(This is the object the 1.2 checkpointer will snapshot.)_ - **`src/langgraph/engine.ts` (NEW).** `executeLangGraph(...)` reproduces scout→implement→verify→review→close→retrospective with the revision loop, fingerprint short-circuit, revision-budget cap, and failure→retrospective routing via conditional edges. Emits the **same event stream** through the existing `EventAppender`, so td-status mirror, evidence markers, metrics, and `runs.jsonl` stay correct **for free** (the appender's `projectTaskJson`/`projectMarkers` is the single projection seam — no shadow DAG needed). - **`src/pipeline.ts`.** Branches on `CASE_ENGINE` inside `runPipelineBody`. Shared `dispatch` closure hoisted; legacy graph build/resume/`executeGraph` moved into the `else`. −282 LOC net (dispatcher relocated). - **`src/__tests__/langgraph-parity.spec.ts` (NEW).** Runs both engines over an identical mock runtime, asserts identical `(phase, outcome)` sequence (and pins each to an explicit expected). 6 cases: standard happy, tiny profile-skip, verifier revision, reviewer soft-fail revision, budget-exhausted (`maxRevisionCycles=1`), fingerprint short-circuit. **6/6 green.** @@ -27,9 +27,9 @@ LangGraph (`@langchain/langgraph` 1.4.4 + peer `@langchain/core` 1.2.0, Bun-veri **Deviations / decisions made during implementation:** 1. **Resume under `langgraph` is deferred to 1.2.** 1.1 is fresh-runs-only on the LangGraph path; event-log crash-resume stays legacy-only until the SQLite checkpointer lands. A td-persisted `pendingRevision` still seeds resume-at-implement (passed as `initialPendingRevision`, seeds `cycle`/`revisionCycles`). -2. **Replicated a legacy quirk for true parity.** When a **verify** failure is *denied* revision (budget exhausted or fingerprint match), the legacy executor still runs that cycle's **review** before closing — skipping the next cycle unblocks `verifyPassedPredicate`. The engine reproduces this: `revise` routes a denied verify-failure to `review` first (guarded by the `revisionClosed` channel so that trailing review can't itself re-trigger revision). A *review*-triggered denial closes directly (review already ran). +2. **Replicated a legacy quirk for true parity.** When a **verify** failure is _denied_ revision (budget exhausted or fingerprint match), the legacy executor still runs that cycle's **review** before closing — skipping the next cycle unblocks `verifyPassedPredicate`. The engine reproduces this: `revise` routes a denied verify-failure to `review` first (guarded by the `revisionClosed` channel so that trailing review can't itself re-trigger revision). A _review_-triggered denial closes directly (review already ran). 3. **`status_changed` is computed per-phase** (implement→implementing, verify→verifying, …, post-close→pr-opened) rather than via `projectStatusFromGraph`. The sequential engine never has verify+review running concurrently, so the legacy `evaluating` (concurrent) status is not emitted on the LangGraph path. Does not affect phase-outcome parity; revisit if a profile widens to true parallel supersteps. -4. **Skipped-phase `phase_end` events are not emitted** on the failure path (legacy emits `outcome:'skipped'` for bypassed pending nodes). Parity is asserted on *executed*-phase outcomes. If `projectMetrics`' `skippedPhases` fidelity matters under LangGraph, emit these in 1.3 when marker/td writes go node-direct. +4. **Skipped-phase `phase_end` events are not emitted** on the failure path (legacy emits `outcome:'skipped'` for bypassed pending nodes). Parity is asserted on _executed_-phase outcomes. If `projectMetrics`' `skippedPhases` fidelity matters under LangGraph, emit these in 1.3 when marker/td writes go node-direct. **Test-runner fix (`src/dev/run-tests.ts`) — required, not optional.** Bun's `mock.module()` is process-global and persists across files; `bun test ./src/__tests__/` loaded all specs into one process, so top-level mocks leaked (`pipeline-tool.spec`'s `pipeline.js` mock broke `pipeline.spec`/parity; `pipeline.spec`'s `task-store` mock broke `task-scanner`/`createTask`/`update-memory`). This was **pre-existing** (38 failures on clean HEAD). Fixed by running each unit spec in its own process (concurrency 8). Every spec passes in isolation; the suite is green. **Next session: keep specs isolated — do not collapse back to a single `bun test ` invocation.** @@ -72,7 +72,7 @@ LangGraph is now **unconditional**. The legacy DAG executor/builder, the event-r **Test triage (§9):** - **DIE (deleted):** `dag-builder.spec`, `dag-builder-scout.spec`, `dag-executor.spec`. -- **PORT:** `dag-status.spec` → **`phase-status.spec`** (asserts the engine's exported `phaseStatus` phase→status map; the legacy concurrent `evaluating` + graph-derived `merged` are intentionally absent — 1.1 deviation 3). `events-projections.spec` **kept as-is** (the projection functions are pure and unchanged until 2.2); the node-direct *write* behavior is the new `node-projection.spec`. `pipeline.spec` resume parts: the pendingRevision-seed resume tests **pass unchanged** (engine seeds from `initialPendingRevision`); the legacy **status-only** re-entry test was **deleted** (see deviation 1). +- **PORT:** `dag-status.spec` → **`phase-status.spec`** (asserts the engine's exported `phaseStatus` phase→status map; the legacy concurrent `evaluating` + graph-derived `merged` are intentionally absent — 1.1 deviation 3). `events-projections.spec` **kept as-is** (the projection functions are pure and unchanged until 2.2); the node-direct _write_ behavior is the new `node-projection.spec`. `pipeline.spec` resume parts: the pendingRevision-seed resume tests **pass unchanged** (engine seeds from `initialPendingRevision`); the legacy **status-only** re-entry test was **deleted** (see deviation 1). - **Converted:** `langgraph-parity.spec` → single-engine **routing oracle** (the legacy arm it compared against is gone; the 6 pinned `(phase, outcome)` sequences now stand alone as the conditional-edge contract — this is the §9 NET-NEW routing test). - **Trimmed:** `events-appender.spec` lost its 3 projection/marker tests (moved to `node-projection.spec`) and the `restoreState` test; the append/sequence/runId/state coverage stays. - **NET-NEW:** `node-projection.spec` (td write + marker-file drop + re-projection + dedupe — the evidence-gate coverage §9 requires node-direct). @@ -83,7 +83,7 @@ LangGraph is now **unconditional**. The legacy DAG executor/builder, the event-r **Deviations / decisions made during implementation:** -1. **Legacy status-only resume dropped (by design).** `seedGraphFromTaskStatus` let a run resume mid-pipeline from a coarse td status with **no checkpoint** (e.g. td says `verifying` → skip to verify). Checkpointer-only resume removes this: with no checkpoint, a run starts fresh from scout. This is intentional per §5 decision 1 (td is a human mirror, **not** a resume source) — a genuinely interrupted run *has* a checkpoint and resumes correctly (`checkpointer-resume.spec`). The `pipeline.spec` test `re-entry from verifying status skips implement phase` was deleted; td-persisted **pendingRevision** seeding survives. +1. **Legacy status-only resume dropped (by design).** `seedGraphFromTaskStatus` let a run resume mid-pipeline from a coarse td status with **no checkpoint** (e.g. td says `verifying` → skip to verify). Checkpointer-only resume removes this: with no checkpoint, a run starts fresh from scout. This is intentional per §5 decision 1 (td is a human mirror, **not** a resume source) — a genuinely interrupted run _has_ a checkpoint and resumes correctly (`checkpointer-resume.spec`). The `pipeline.spec` test `re-entry from verifying status skips implement phase` was deleted; td-persisted **pendingRevision** seeding survives. 2. **Two projections per phase, not per event.** `projectNodeState` fires at `phase_start` (running mirror) and `phase_end` (completed + markers), vs the appender's old fire-on-every-`append`. This preserves the live "running" td status while dropping the event-hop coupling. `pendingRevision` in td is now written at the next implement's `phase_start` (state carries it from the `revision_requested` reducer) plus dispatch's direct `store.setPendingRevision` calls — net final td state unchanged. 3. **TS narrowing workaround.** With the legacy in-scope failed-node loop gone, TS control-flow analysis narrows `outcome` to its `'completed'` initializer (it can't see the dispatch/`onPhaseFailed` closures mutate it). The final `if` reads `(outcome as string) === 'failed'` to keep the runtime failure branch. 4. **Carried open item (1.1 deviation 4):** skipped-phase `phase_end` events are **still not emitted**. `projectMetrics.skippedPhases` fidelity is therefore unchanged by this phase. If wanted, emit them from the engine when a profile bypasses a node — deferred (no current consumer). @@ -113,7 +113,8 @@ Langfuse now receives a per-run trace fed from the single observability seam (`p 3. **`generation` on `turn_end` only; domain `event()` exposed but not yet wired.** `agent_start/end` map to span open/close; pi `turn_start` carries no usage so only `turn_end` becomes a generation. `AgentSpan.event()` exists for §4's "domain events → `event()`" but the adapter still routes domain/tool events through the JSONL appender (dual observability) — no acceptance criterion rides on span-side `event()`, so it's deferred to avoid duplicate emission before the 2.2 cutover. **Gotchas for the next session:** -- **Run tests with `bun run test` (= `bun src/dev/run-tests.ts`, process-isolated, concurrency 8). This is the green, authoritative command.** A naive `bun test src/__tests__/` loads all specs into **one** process and Bun's process-global `mock.module()` leaks across files → **43 false failures** (was 38 pre-1.3; grew because 1.3 added `node-projection.spec` + converted `langgraph-parity.spec` + trimmed `events-appender.spec`, all of which register top-level mocks). Every spec passes in isolation; the isolated runner is **0 fail / 48 specs** (2.1 added `langfuse-dispatch.spec`). The 43 are leak victims (`createTask`, pipeline phase cases, …), not real regressions. *(If naive-`bun test` parity is ever wanted, add `mock.restore()` in an `afterAll` to the specs that `mock.module(...)` at top level — deferred; not blocking.)* + +- **Run tests with `bun run test` (= `bun src/dev/run-tests.ts`, process-isolated, concurrency 8). This is the green, authoritative command.** A naive `bun test src/__tests__/` loads all specs into **one** process and Bun's process-global `mock.module()` leaks across files → **43 false failures** (was 38 pre-1.3; grew because 1.3 added `node-projection.spec` + converted `langgraph-parity.spec` + trimmed `events-appender.spec`, all of which register top-level mocks). Every spec passes in isolation; the isolated runner is **0 fail / 48 specs** (2.1 added `langfuse-dispatch.spec`). The 43 are leak victims (`createTask`, pipeline phase cases, …), not real regressions. _(If naive-`bun test` parity is ever wanted, add `mock.restore()` in an `afterAll` to the specs that `mock.module(...)` at top level — deferred; not blocking.)_ - `better-sqlite3` does **not** load under Bun — the engine's checkpointer is the hand-rolled `BunSqliteSaver`. - The control path must **never read back from Langfuse** (§1 constraint 1, §7): the retrospective reads local `runs.jsonl` only. - **Uncommitted:** all of Phase 1 (1.1 → 1.3) **and Phase 2.1** are on branch `docs/migrate-langgraph-langfuse-rfc`, **not yet committed**. Suggested commit boundaries: Phase 1.3 as two logical commits (1: ⚠ BREAKING flip+delete+test-triage · 2: node-direct projections + `node-projection.spec`); Phase 2.1 as one additive commit. **Full Phase 2.1 file set:** `src/tracing/langfuse.ts` (NEW), `src/agent/adapters/pi-adapter.ts`, `src/pipeline.ts`, `src/types.ts`, `src/phases/{scout,verify,review,close,retrospective}.ts`, `src/__tests__/langfuse-dispatch.spec.ts` (NEW), `test/e2e/` (NEW), `.env.example` (NEW), `.gitignore`, `package.json`, `bun.lock`. **⚠ Exclude `PROMPT.md`** (untracked, unrelated scratch — not part of the migration). Commit before starting 2.2 for a clean bisect. @@ -125,7 +126,7 @@ The JSONL event log + its schema/appender/reducer are gone. Langfuse is now the **Landed:** -- **`src/state/run-state.ts` (NEW).** `RunState` — a JSONL-free in-memory container holding the **unchanged** `PipelineState` shape, with typed mutators (`startPhase`/`endPhase`/`setStatus`/`requestRevision`/`end`/`seedRevision`) ported from the reducer's per-case bodies. Replaces the `EventAppender` + `reduceEvents` pair: Phase 1.3 had the engine *drive* `PipelineState` via granular events and *read it back* via `appender.getState()`, so deleting the log meant **replacing the live state container**, not just removing a sink. Because the shape is identical, `projectTaskJson`/`projectMarkers`/`projectMetrics` (kept in `events/projections.ts`) are byte-identical by construction. +- **`src/state/run-state.ts` (NEW).** `RunState` — a JSONL-free in-memory container holding the **unchanged** `PipelineState` shape, with typed mutators (`startPhase`/`endPhase`/`setStatus`/`requestRevision`/`end`/`seedRevision`) ported from the reducer's per-case bodies. Replaces the `EventAppender` + `reduceEvents` pair: Phase 1.3 had the engine _drive_ `PipelineState` via granular events and _read it back_ via `appender.getState()`, so deleting the log meant **replacing the live state container**, not just removing a sink. Because the shape is identical, `projectTaskJson`/`projectMarkers`/`projectMetrics` (kept in `events/projections.ts`) are byte-identical by construction. - **Deleted:** `src/events/{schema,appender,reducer,errors}.ts`. **Kept** `src/events/{types,projections,plan}.ts` (state shape, projections, plan generation — no event-log dependency). - **`src/langgraph/engine.ts` + `src/pipeline.ts` + `src/pipeline-dispatch.ts`.** `appender` → `runState` throughout; `append({event})` calls became `runState.*` mutators. Orchestration-level domain events (`revision_requested` / `revision_budget_exhausted` / `fingerprint_match` / `scout_completed`) now land on the trace via a new **trace-level `LangfuseTracer.event()`** (closes 2.1 deviation 3 — they have no agent span). `config.eventAppender` → `config.runState` on `PipelineConfig`. - **`src/agent/adapters/pi-adapter.ts`.** Deleted the dead `tool_start`/`tool_end` → `eventAppender`/`traceWriter` JSONL branches; `span.toolStart/toolEnd` (Langfuse, unconditional) + `onToolActivity` (TUI) already cover tools. `eventAppender`/`traceWriter` dropped from `SpawnAgentOptions` and the 6 phase pass-throughs. @@ -138,8 +139,8 @@ The JSONL event log + its schema/appender/reducer are gone. Langfuse is now the **Deviations / decisions made during implementation:** -1. **State container replaces appender/reducer (not a pure deletion).** The plan called `projectTaskJson`/`projectMarkers` "orphaned" — stale relative to post-1.3 code, where `projectNodeState` uses them at runtime. The faithful 2.2 keeps the projections + `PipelineState` shape and swaps only the *driver* (events → `RunState` mutators). `reduceEvents`/`loadEventsFromFile`/`validateTransition`/the event schema are gone; the transition logic survives as plain methods. -2. **`ca watch` re-pointed to Langfuse, not an "in-process callback stream" (§5 decision 3 revised).** That decision predated the realization that `ca watch` is a *separate process* — there is no shared in-process stream cross-process. Per user direction, watch now loads + polls the Langfuse trace (full fidelity: tool spans, generations w/ tokens+cost, scores), reusing the 2.1 read-back client. Trade-off accepted: watch now **requires Langfuse keys + reachability** (no offline tail) and sees events at ingest latency (seconds). Reading Langfuse from a *human tool* does not violate §7 (that bars the *control path*). +1. **State container replaces appender/reducer (not a pure deletion).** The plan called `projectTaskJson`/`projectMarkers` "orphaned" — stale relative to post-1.3 code, where `projectNodeState` uses them at runtime. The faithful 2.2 keeps the projections + `PipelineState` shape and swaps only the _driver_ (events → `RunState` mutators). `reduceEvents`/`loadEventsFromFile`/`validateTransition`/the event schema are gone; the transition logic survives as plain methods. +2. **`ca watch` re-pointed to Langfuse, not an "in-process callback stream" (§5 decision 3 revised).** That decision predated the realization that `ca watch` is a _separate process_ — there is no shared in-process stream cross-process. Per user direction, watch now loads + polls the Langfuse trace (full fidelity: tool spans, generations w/ tokens+cost, scores), reusing the 2.1 read-back client. Trade-off accepted: watch now **requires Langfuse keys + reachability** (no offline tail) and sees events at ingest latency (seconds). Reading Langfuse from a _human tool_ does not violate §7 (that bars the _control path_). 3. **Domain `event()` is trace-level, not span-level (closes 2.1 deviation 3).** Orchestration events fire between phases (no agent span), so they attach to the run trace via `LangfuseTracer.event()`; per-call generations/tool spans stay span-nested as before. No more dual emission — the JSONL sink it would have duplicated is gone. 4. **`scout_completed`/`status_changed` are no longer state mutations.** They only bumped `lastSequence` in the reducer (observability-only); `scout_completed` is now a trace event, `status_changed` is folded into `RunState.setStatus`. Net td/metrics state unchanged. @@ -160,7 +161,7 @@ Two of those subsystems substantially re-implement what LangGraph and Langfuse p - LangGraph gives `StateGraph` (conditional edges, cycles, parallel supersteps) and a **checkpointer** that subsumes our replay-for-resume path. - Langfuse models the exact trace → span → event → score tree our event taxonomy already encodes, plus token/cost (which pi pre-computes per call but we never surface). -The agent runtime (pi) **stays** — LangGraph nodes wrap `agent.execute()`. This is not a rewrite of how agents run; it is a replacement of how they are *sequenced* and *observed*. +The agent runtime (pi) **stays** — LangGraph nodes wrap `agent.execute()`. This is not a rewrite of how agents run; it is a replacement of how they are _sequenced_ and _observed_. ### Expected net effect @@ -194,70 +195,70 @@ Every current feature, its present implementation, and where it lands after migr ### Orchestration / DAG -| Name | Current implementation | New implementation | Disposition | -|---|---|---|---| -| Graph construction (profiles: tiny/standard) | `buildGraph(profile, maxRevisionCycles)` — `src/dag/builder.ts:5`; `PROFILE_PHASES` `src/types.ts:119` | `StateGraph` definition; profile selects which nodes/edges are added | REPLACE | -| Ready-node detection + parallel dispatch | `findReadyNodes()` + `Promise.all` — `src/dag/executor.ts:64,177` | LangGraph native parallel supersteps (fan-out edges) | REPLACE — *parallel dispatch exists today; tiny/standard profiles are near-linear and exercise it only as graphs widen* | -| Conditional revision loops (implement→verify→review→implement N+1) | Edge predicates `revisionRequestedPredicate()` — `src/dag/builder.ts:89,140-178` | LangGraph conditional edges returning next node | REPLACE | -| Revision budget cap | `maxRevisionCycles` (default 2) — `src/pipeline.ts:106` | Counter channel in graph state + conditional-edge guard; LangGraph `recursionLimit` as backstop | REPLACE | -| Fingerprint loop detection (SHA-256 of failure reason; abort on repeat) | `handleEvaluatorPairCompletion()` — `src/dag/executor.ts:220-269` | Same logic as a node/edge function over graph state (preserved verbatim, relocated) | MOVE | -| Outcome matrix `(phase, outcome) → action` | `src/dag/outcome-table.ts` | Conditional-edge routing functions keyed off the same table | REPLACE | -| Failure routing → skip pending, run retrospective once | `src/dag/executor.ts:112` | Conditional edge to `retrospective` node; other pending nodes unreachable | REPLACE | -| Human override (retry/abort prompt, attended mode) | `src/pipeline.ts:303-313` | LangGraph `interrupt` (human-in-the-loop) or retain custom prompt around graph step | REPLACE — **decision needed** (see §5) | -| Scout non-blocking routing | Always routes `implement_0` — `src/pipeline.ts:289-293` | Unconditional edge scout→implement | REPLACE | -| Cross-phase state passing (`scoutSlot`, `previousResults`, `revision`) | Closures + `Map` — `src/pipeline.ts:82,165,297` | LangGraph state channels (typed `StateGraph` state object) | MOVE | +| Name | Current implementation | New implementation | Disposition | +| ----------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- | +| Graph construction (profiles: tiny/standard) | `buildGraph(profile, maxRevisionCycles)` — `src/dag/builder.ts:5`; `PROFILE_PHASES` `src/types.ts:119` | `StateGraph` definition; profile selects which nodes/edges are added | REPLACE | +| Ready-node detection + parallel dispatch | `findReadyNodes()` + `Promise.all` — `src/dag/executor.ts:64,177` | LangGraph native parallel supersteps (fan-out edges) | REPLACE — _parallel dispatch exists today; tiny/standard profiles are near-linear and exercise it only as graphs widen_ | +| Conditional revision loops (implement→verify→review→implement N+1) | Edge predicates `revisionRequestedPredicate()` — `src/dag/builder.ts:89,140-178` | LangGraph conditional edges returning next node | REPLACE | +| Revision budget cap | `maxRevisionCycles` (default 2) — `src/pipeline.ts:106` | Counter channel in graph state + conditional-edge guard; LangGraph `recursionLimit` as backstop | REPLACE | +| Fingerprint loop detection (SHA-256 of failure reason; abort on repeat) | `handleEvaluatorPairCompletion()` — `src/dag/executor.ts:220-269` | Same logic as a node/edge function over graph state (preserved verbatim, relocated) | MOVE | +| Outcome matrix `(phase, outcome) → action` | `src/dag/outcome-table.ts` | Conditional-edge routing functions keyed off the same table | REPLACE | +| Failure routing → skip pending, run retrospective once | `src/dag/executor.ts:112` | Conditional edge to `retrospective` node; other pending nodes unreachable | REPLACE | +| Human override (retry/abort prompt, attended mode) | `src/pipeline.ts:303-313` | LangGraph `interrupt` (human-in-the-loop) or retain custom prompt around graph step | REPLACE — **decision needed** (see §5) | +| Scout non-blocking routing | Always routes `implement_0` — `src/pipeline.ts:289-293` | Unconditional edge scout→implement | REPLACE | +| Cross-phase state passing (`scoutSlot`, `previousResults`, `revision`) | Closures + `Map` — `src/pipeline.ts:82,165,297` | LangGraph state channels (typed `StateGraph` state object) | MOVE | ### Resume / state -| Name | Current implementation | New implementation | Disposition | -|---|---|---|---| -| Crash recovery / mid-graph resume | Replay log: `loadEventsFromFile` → `reduceEvents` → `restoreGraphState` — `src/pipeline.ts:119-127`, `src/dag/restore.ts:4-18` | LangGraph checkpointer (SQLite) auto-restores last superstep | REPLACE | -| Resume from pending revision | `task.pendingRevision` seeded from td — `src/pipeline.ts:139-148` | `pendingRevision` lives in checkpointed graph state; td still seeds first run | MOVE | -| Pipeline state model | Event-sourced `PipelineState` via `reduceEvents` — `src/events/reducer.ts` | LangGraph state channels; checkpointer snapshots replace event replay | REPLACE | -| Task state persistence (authoritative `TaskJson`) | Hidden `` JSON in td issue description — `src/state/td-client.ts`, `src/state/task-store.ts` | **Unchanged** — td remains the task-grain store | KEEP | -| Working memory (per-agent context between phases) | `working-memory.json` r/w — `src/memory/working-memory.ts:32-96`; `ca update-memory` | **Unchanged** — local JSON, not event-derived | KEEP | +| Name | Current implementation | New implementation | Disposition | +| ------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------- | ----------- | +| Crash recovery / mid-graph resume | Replay log: `loadEventsFromFile` → `reduceEvents` → `restoreGraphState` — `src/pipeline.ts:119-127`, `src/dag/restore.ts:4-18` | LangGraph checkpointer (SQLite) auto-restores last superstep | REPLACE | +| Resume from pending revision | `task.pendingRevision` seeded from td — `src/pipeline.ts:139-148` | `pendingRevision` lives in checkpointed graph state; td still seeds first run | MOVE | +| Pipeline state model | Event-sourced `PipelineState` via `reduceEvents` — `src/events/reducer.ts` | LangGraph state channels; checkpointer snapshots replace event replay | REPLACE | +| Task state persistence (authoritative `TaskJson`) | Hidden `` JSON in td issue description — `src/state/td-client.ts`, `src/state/task-store.ts` | **Unchanged** — td remains the task-grain store | KEEP | +| Working memory (per-agent context between phases) | `working-memory.json` r/w — `src/memory/working-memory.ts:32-96`; `ca update-memory` | **Unchanged** — local JSON, not event-derived | KEEP | ### Observability -| Name | Current implementation | New implementation | Disposition | -|---|---|---|---| -| Granular event log (phase/tool/domain events) | JSONL `run-*.jsonl` — `src/events/appender.ts:48`, schema `src/events/schema.ts` | Langfuse dispatch at the subscriber seam (trace/span/event); **log deleted** | MOVE → Langfuse | -| Tool activity tracing (sanitized args/results) | `tool_execution_start/end` → event + `onToolActivity` — `src/agent/adapters/pi-adapter.ts:79-126` | Langfuse nested spans (via same subscriber) | MOVE → Langfuse | -| LLM-call telemetry (tokens) | Cumulative only: `ctx.getContextUsage().tokens` — `src/agent/orchestrator-session.ts:246` | Langfuse **generation** spans from `turn_end.message.usage` (per call) | UPGRADE | -| LLM-call **cost** ($) | Not tracked | Langfuse generation `usage.cost` — pi pre-computes per call (`pi-ai types.d.ts:144-157`) | NEW | -| Eval rubric scores (verifier/reviewer) | Embedded in `AgentResult` / metrics | Langfuse **score()** — first-class eval dashboards | UPGRADE | -| Phase metrics (duration, status, artifacts) | `projectMetrics()` — `src/events/projections.ts:61` | Langfuse spans + retained run-summary | MOVE → Langfuse | -| Run summary log (`runs.jsonl`) | `writeRunMetrics()` — `src/metrics/writer.ts:12` | **Kept local** — retrospective's durable read source | KEEP | -| Prior-run linking (`priorRunId`) | `findPriorRunId()` reads `runs.jsonl` — `src/versioning/prompt-tracker.ts:56-82` | **Unchanged** — reads kept `runs.jsonl` | KEEP | -| Live TUI activity feed / heartbeat (10s) | `onToolActivity` / `onAgentHeartbeat` callbacks → notifier — `src/agent/adapters/pi-adapter.ts` | **Unchanged** — synchronous in-process callbacks (Langfuse cannot drive live local UI) | KEEP | -| Live event tail (`ca watch`) | Polls JSONL — `src/watch/watcher.ts:26-77` | Langfuse trace UI (remote) **or** re-point `ca watch` at in-process callback stream | REPLACE — **decision needed** (see §5) | +| Name | Current implementation | New implementation | Disposition | +| ---------------------------------------------- | ------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------- | -------------------------------------- | +| Granular event log (phase/tool/domain events) | JSONL `run-*.jsonl` — `src/events/appender.ts:48`, schema `src/events/schema.ts` | Langfuse dispatch at the subscriber seam (trace/span/event); **log deleted** | MOVE → Langfuse | +| Tool activity tracing (sanitized args/results) | `tool_execution_start/end` → event + `onToolActivity` — `src/agent/adapters/pi-adapter.ts:79-126` | Langfuse nested spans (via same subscriber) | MOVE → Langfuse | +| LLM-call telemetry (tokens) | Cumulative only: `ctx.getContextUsage().tokens` — `src/agent/orchestrator-session.ts:246` | Langfuse **generation** spans from `turn_end.message.usage` (per call) | UPGRADE | +| LLM-call **cost** ($) | Not tracked | Langfuse generation `usage.cost` — pi pre-computes per call (`pi-ai types.d.ts:144-157`) | NEW | +| Eval rubric scores (verifier/reviewer) | Embedded in `AgentResult` / metrics | Langfuse **score()** — first-class eval dashboards | UPGRADE | +| Phase metrics (duration, status, artifacts) | `projectMetrics()` — `src/events/projections.ts:61` | Langfuse spans + retained run-summary | MOVE → Langfuse | +| Run summary log (`runs.jsonl`) | `writeRunMetrics()` — `src/metrics/writer.ts:12` | **Kept local** — retrospective's durable read source | KEEP | +| Prior-run linking (`priorRunId`) | `findPriorRunId()` reads `runs.jsonl` — `src/versioning/prompt-tracker.ts:56-82` | **Unchanged** — reads kept `runs.jsonl` | KEEP | +| Live TUI activity feed / heartbeat (10s) | `onToolActivity` / `onAgentHeartbeat` callbacks → notifier — `src/agent/adapters/pi-adapter.ts` | **Unchanged** — synchronous in-process callbacks (Langfuse cannot drive live local UI) | KEEP | +| Live event tail (`ca watch`) | Polls JSONL — `src/watch/watcher.ts:26-77` | Langfuse trace UI (remote) **or** re-point `ca watch` at in-process callback stream | REPLACE — **decision needed** (see §5) | ### Evidence / task mirror -| Name | Current implementation | New implementation | Disposition | -|---|---|---|---| -| Evidence markers (`tested` / `reviewed` / `manual-tested`) | Disk files written via `projectMarkers()` — `src/events/appender.ts:76-84`; `ca mark-*` | Node writes marker file **directly** on phase completion; checkpointer holds marker set | MOVE (drop event hop; disk stays truth) | -| td status mirror (native status + labels) | `projectTaskJson()` after each event — `src/events/appender.ts:72`; `caseToTdStatus` `src/state/td-client.ts:76` | Node writes td **directly** on phase end (already a synchronous projection) | MOVE (drop event hop) | -| td CRUD / focus / resolveFocusedTask | `src/state/td-client.ts` | **Unchanged** | KEEP | +| Name | Current implementation | New implementation | Disposition | +| ---------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------- | --------------------------------------- | +| Evidence markers (`tested` / `reviewed` / `manual-tested`) | Disk files written via `projectMarkers()` — `src/events/appender.ts:76-84`; `ca mark-*` | Node writes marker file **directly** on phase completion; checkpointer holds marker set | MOVE (drop event hop; disk stays truth) | +| td status mirror (native status + labels) | `projectTaskJson()` after each event — `src/events/appender.ts:72`; `caseToTdStatus` `src/state/td-client.ts:76` | Node writes td **directly** on phase end (already a synchronous projection) | MOVE (drop event hop) | +| td CRUD / focus / resolveFocusedTask | `src/state/td-client.ts` | **Unchanged** | KEEP | ### Agent runtime -| Name | Current implementation | New implementation | Disposition | -|---|---|---|---| -| Per-phase agent execution | `PiRuntimeAdapter.spawn` → `agent.execute()` — `src/agent/adapters/pi-adapter.ts:29-186` | **Unchanged** — wrapped as a LangGraph node | KEEP | -| Per-agent tool sets (mutable vs read-only) | `createPiTools()` per agent | **Unchanged** | KEEP | -| System-prompt loading per agent | Loaded from `agents/*.md` | **Unchanged** | KEEP | -| Model resolution + override | `ModelRegistry` + `CASE_MODEL_OVERRIDE` | **Unchanged** | KEEP | -| Per-phase timeout (600s default) | pi-adapter timeout | **Unchanged** (or LangGraph node timeout) | KEEP | -| Result parsing → `AgentResult` | `parseAgentResult()` | **Unchanged** | KEEP | -| Runtime pluggability interface | `CaseAgentRuntime` — `src/agent/runtime.ts` | **Unchanged** — LangGraph node calls through it | KEEP | +| Name | Current implementation | New implementation | Disposition | +| ------------------------------------------ | ---------------------------------------------------------------------------------------- | ----------------------------------------------- | ----------- | +| Per-phase agent execution | `PiRuntimeAdapter.spawn` → `agent.execute()` — `src/agent/adapters/pi-adapter.ts:29-186` | **Unchanged** — wrapped as a LangGraph node | KEEP | +| Per-agent tool sets (mutable vs read-only) | `createPiTools()` per agent | **Unchanged** | KEEP | +| System-prompt loading per agent | Loaded from `agents/*.md` | **Unchanged** | KEEP | +| Model resolution + override | `ModelRegistry` + `CASE_MODEL_OVERRIDE` | **Unchanged** | KEEP | +| Per-phase timeout (600s default) | pi-adapter timeout | **Unchanged** (or LangGraph node timeout) | KEEP | +| Result parsing → `AgentResult` | `parseAgentResult()` | **Unchanged** | KEEP | +| Runtime pluggability interface | `CaseAgentRuntime` — `src/agent/runtime.ts` | **Unchanged** — LangGraph node calls through it | KEEP | ### Self-improvement -| Name | Current implementation | New implementation | Disposition | -|---|---|---|---| -| Retrospective phase | Reads in-memory `metricsSnapshot` + `previousResults` — `src/phases/retrospective.ts:24-26,57-76` | **Unchanged** logic; snapshot computed from graph state / kept `runs.jsonl` | KEEP | -| Prompt versioning | `promptVersions` in metrics — `src/versioning/` | **Unchanged** | KEEP | +| Name | Current implementation | New implementation | Disposition | +| ------------------- | ------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------- | ----------- | +| Retrospective phase | Reads in-memory `metricsSnapshot` + `previousResults` — `src/phases/retrospective.ts:24-26,57-76` | **Unchanged** logic; snapshot computed from graph state / kept `runs.jsonl` | KEEP | +| Prompt versioning | `promptVersions` in metrics — `src/versioning/` | **Unchanged** | KEEP | --- @@ -299,37 +300,37 @@ Three homes, zero overlap: Two phases, severable because the event log's two roles (resume source, observability source) die in different phases. **Phase 1** swaps orchestration to LangGraph and severs the resume role; the log survives **write-only** as the observability source. **Phase 2** adds Langfuse, then severs the observability role and deletes the log. Each phase is a sequence of additive/flagged/reversible steps followed by **exactly one labeled breaking cutover** — so a bisect localizes any regression to one phase, and the breaking commit in each phase is singular. -Invariant across the whole migration until `2.2`: the granular `run-*.jsonl` keeps being **written** (the appender is untouched). Phase 1 only stops *reading* it for resume; Phase 2 stops writing it. +Invariant across the whole migration until `2.2`: the granular `run-*.jsonl` keeps being **written** (the appender is untouched). Phase 1 only stops _reading_ it for resume; Phase 2 stops writing it. ### Phase 1 — Orchestration → LangGraph No observability change. Event log still written (now only the metrics/observability source). Langfuse absent. `ca watch` still polls JSONL. -**1.1 — Wrap pi as a LangGraph node (parallel path, no cutover).** *Additive · reversible.* +**1.1 — Wrap pi as a LangGraph node (parallel path, no cutover).** _Additive · reversible._ Introduce `StateGraph` reproducing the current linear+revision flow; each node calls the existing `CaseAgentRuntime`. Gate behind `CASE_ENGINE=langgraph`. Old executor remains default. -*Acceptance:* a tiny-profile run completes through the LangGraph path with identical phase outcomes to the legacy executor. +_Acceptance:_ a tiny-profile run completes through the LangGraph path with identical phase outcomes to the legacy executor. -**1.2 — Stand up the checkpointer; dual-write; prove resume parity.** *Additive · reversible.* +**1.2 — Stand up the checkpointer; dual-write; prove resume parity.** _Additive · reversible._ Add the LangGraph SQLite checkpointer in `.todos/` (co-location per §6). Run both resume mechanisms; assert restored graph state matches `reduceEvents` on the same crash point. -*Acceptance:* kill a run mid-`implement_1`; both paths resume to the same node set and `pendingRevision`. +_Acceptance:_ kill a run mid-`implement_1`; both paths resume to the same node set and `pendingRevision`. -**1.3 — ⚠ BREAKING: resume cutover + default flip.** *The one breaking change of Phase 1. Guarded by 1.2's parity test.* +**1.3 — ⚠ BREAKING: resume cutover + default flip.** _The one breaking change of Phase 1. Guarded by 1.2's parity test._ Flip the default to LangGraph and delete the legacy engine: remove `loadEventsFromFile` → `reduceEvents` → `restoreGraphState` and the old executor/builder. Relocate the td mirror + marker writes to **node-direct** (write on node completion; remove those projection side-effects from the event path — the raw appender stays, only its derived writes move). After this, resume is checkpointer-only and orchestration no longer touches the event log. -*Acceptance:* resume works with the replay path gone; td status + labels and marker files still update each phase; `runs.jsonl`/metrics unchanged; full suite green. +_Acceptance:_ resume works with the replay path gone; td status + labels and marker files still update each phase; `runs.jsonl`/metrics unchanged; full suite green. -*End state of Phase 1:* LangGraph + checkpointer own orchestration; event log is a write-only observability sink; everything else (Langfuse, `ca watch`) unchanged. +_End state of Phase 1:_ LangGraph + checkpointer own orchestration; event log is a write-only observability sink; everything else (Langfuse, `ca watch`) unchanged. ### Phase 2 — Observability → Langfuse No orchestration change. Begins additive; the single breaking cutover is the log deletion. -**2.1 — Add Langfuse dispatch at the subscriber seam.** *Additive · fire-and-forget · reversible.* +**2.1 — Add Langfuse dispatch at the subscriber seam.** _Additive · fire-and-forget · reversible._ In `pi-adapter.ts:68`, map `agent_start/end`, `turn_start/end`, `tool_execution_*`, domain events, and rubrics to Langfuse trace/span/generation/event/score. Keep `onToolActivity`/heartbeat feeding the TUI. Langfuse failures must not affect the run. Observability is now **dual** (JSONL + Langfuse). -*Acceptance:* a run produces a complete Langfuse trace with per-call token + cost; with Langfuse unreachable, the run still completes and the TUI feed is intact. +_Acceptance:_ a run produces a complete Langfuse trace with per-call token + cost; with Langfuse unreachable, the run still completes and the TUI feed is intact. -**2.2 — ⚠ BREAKING: delete granular event log + re-point `ca watch`.** *The one breaking change of Phase 2.* +**2.2 — ⚠ BREAKING: delete granular event log + re-point `ca watch`.** _The one breaking change of Phase 2._ Delete `src/events/{schema,appender,reducer}.ts` and the now-orphaned `projectTaskJson`/`projectMarkers`. Re-point `ca watch` from JSONL polling to the in-process callback stream (per §5 decision 3). **Keep** `runs.jsonl`, `findPriorRunId`, working memory, markers, td. -*Acceptance:* full suite green; `ca watch` tails live activity; retrospective still reads `runs.jsonl`; Langfuse trace complete. Breaking surface = any external consumer of `run-*.jsonl` and `ca watch`'s source. +_Acceptance:_ full suite green; `ca watch` tails live activity; retrospective still reads `runs.jsonl`; Langfuse trace complete. Breaking surface = any external consumer of `run-*.jsonl` and `ca watch`'s source. --- @@ -337,7 +338,7 @@ Delete `src/events/{schema,appender,reducer}.ts` and the now-orphaned `projectTa 1. **Resume mechanism — DECIDED: LangGraph SQLite checkpointer.** Not td-embedded graph state (td stays a coarse human-facing projection — it lacks per-cycle keys, `revisionCycles`, the fingerprint set, and full `AgentResult` bodies), and not a hand-rolled snapshot. The checkpointer owns engine state; td keeps mirroring coarse status for humans. Co-location with td's SQLite must be verified (§6). 2. **Human override mechanism — DECIDED: LangGraph `interrupt`.** Native human-in-the-loop; composes with checkpointed resume. (Alt considered: custom retry/abort prompt wrapped around graph steps.) -3. **`ca watch` future — DECIDED: re-point at the in-process callback stream. → REVISED at 2.2: load + poll the Langfuse trace.** The callback-stream plan assumed a shared in-process channel, but `ca watch` is a *separate process* — nothing in-process is shared cross-process. 2.2 instead has watch load the run's Langfuse observations then poll-with-cursor (full fidelity, reuses the 2.1 read-back client). Trade-off: watch now requires Langfuse (no offline tail) + ingest latency; reading Langfuse from a human tool does not breach §7. See §0 Phase 2.2 deviation 2. (Alts considered: in-process callback tee — impossible cross-process; a minimal activity-log file — rejected, resurrects the JSONL we deleted.) +3. **`ca watch` future — DECIDED: re-point at the in-process callback stream. → REVISED at 2.2: load + poll the Langfuse trace.** The callback-stream plan assumed a shared in-process channel, but `ca watch` is a _separate process_ — nothing in-process is shared cross-process. 2.2 instead has watch load the run's Langfuse observations then poll-with-cursor (full fidelity, reuses the 2.1 read-back client). Trade-off: watch now requires Langfuse (no offline tail) + ingest latency; reading Langfuse from a human tool does not breach §7. See §0 Phase 2.2 deviation 2. (Alts considered: in-process callback tee — impossible cross-process; a minimal activity-log file — rejected, resurrects the JSONL we deleted.) 4. **Revision budget mechanism — DECIDED: custom counter channel + edge guard.** Explicit, matches today's `maxRevisionCycles`. LangGraph `recursionLimit` retained only as a runaway backstop. (Alt considered: `recursionLimit` alone — too blunt.) --- @@ -352,13 +353,13 @@ Delete `src/events/{schema,appender,reducer}.ts` and the now-orphaned `projectTa ## 7. Risks -| Risk | Impact | Mitigation | -|---|---|---| -| Event-sourcing → snapshot semantics shift | Lose "replay full event stream to derive new metrics retroactively" | Langfuse holds the audit trace; retro metrics derived live and persisted to `runs.jsonl` | -| Langfuse retention evicts history the control path needs | Self-improvement loop breaks | Hard rule: control path never reads Langfuse; retro reads local `runs.jsonl` | -| Langfuse outage during a run | Lost observability for that run | Fire-and-forget dispatch; run + TUI unaffected (checkpointer + callbacks are local) | -| LangGraph edge re-expression drifts from outcome matrix | Subtle routing bugs | Phase 1.1/1.2 parity test vs. legacy executor on identical inputs before the 1.3 cutover | -| Marker / td drift after dropping event projection | Gates or status out of sync | Phase 1.3 writes them node-direct (same synchronous point as today) + suite assertions | +| Risk | Impact | Mitigation | +| -------------------------------------------------------- | ------------------------------------------------------------------- | ---------------------------------------------------------------------------------------- | +| Event-sourcing → snapshot semantics shift | Lose "replay full event stream to derive new metrics retroactively" | Langfuse holds the audit trace; retro metrics derived live and persisted to `runs.jsonl` | +| Langfuse retention evicts history the control path needs | Self-improvement loop breaks | Hard rule: control path never reads Langfuse; retro reads local `runs.jsonl` | +| Langfuse outage during a run | Lost observability for that run | Fire-and-forget dispatch; run + TUI unaffected (checkpointer + callbacks are local) | +| LangGraph edge re-expression drifts from outcome matrix | Subtle routing bugs | Phase 1.1/1.2 parity test vs. legacy executor on identical inputs before the 1.3 cutover | +| Marker / td drift after dropping event projection | Gates or status out of sync | Phase 1.3 writes them node-direct (same synchronous point as today) + suite assertions | --- @@ -376,24 +377,24 @@ The phases delete whole subsystems, so their tests must be triaged — not blank ### DIE — remove with the code -| Test | Deleted dependency | When | -|---|---|---| -| `dag-builder.spec` | `dag/builder buildGraph` (→ `StateGraph` def) | 1.3 | -| `dag-builder-scout.spec` | `dag/builder` | 1.3 | -| `dag-executor.spec` | `dag/executor executeGraph,findReadyNodes` (→ LangGraph runs the graph) | 1.3 | -| `events-appender.spec` | `events/appender` | 2.2 | -| `events-reducer.spec` | `events/reducer reduceEvents,loadEventsFromFile` | 2.2 | -| `events-validation.spec` | `events/errors validateTransition` (no event lifecycle) | 2.2 | +| Test | Deleted dependency | When | +| ------------------------ | ----------------------------------------------------------------------- | ---- | +| `dag-builder.spec` | `dag/builder buildGraph` (→ `StateGraph` def) | 1.3 | +| `dag-builder-scout.spec` | `dag/builder` | 1.3 | +| `dag-executor.spec` | `dag/executor executeGraph,findReadyNodes` (→ LangGraph runs the graph) | 1.3 | +| `events-appender.spec` | `events/appender` | 2.2 | +| `events-reducer.spec` | `events/reducer reduceEvents,loadEventsFromFile` | 2.2 | +| `events-validation.spec` | `events/errors validateTransition` (no event lifecycle) | 2.2 | > ⚠ `events-reducer.spec` is the **resume-correctness oracle**. Its assertions are the parity target the checkpointer must match in 1.2. Retire only after 1.3 cutover is green — do not delete in step order ahead of its replacement. ### PORT — behavior survives, must stay tested -| Test | Behavior preserved | Re-point to | -|---|---|---| -| `events-projections.spec` | `projectTaskJson` status mapping; **`projectMarkers` (evidence gates)**; `projectMetrics` | node-direct td-write + marker-write (1.3); metrics → `runs.jsonl`/Langfuse | -| `dag-status.spec` | `projectStatusFromGraph` (node states → `TaskStatus`) | same logic over LangGraph state channels (1.3) | -| resume assertions in `pipeline.spec` | crash → correct node set + `pendingRevision` | checkpointer restore (1.2) | +| Test | Behavior preserved | Re-point to | +| ------------------------------------ | ----------------------------------------------------------------------------------------- | -------------------------------------------------------------------------- | +| `events-projections.spec` | `projectTaskJson` status mapping; **`projectMarkers` (evidence gates)**; `projectMetrics` | node-direct td-write + marker-write (1.3); metrics → `runs.jsonl`/Langfuse | +| `dag-status.spec` | `projectStatusFromGraph` (node states → `TaskStatus`) | same logic over LangGraph state channels (1.3) | +| resume assertions in `pipeline.spec` | crash → correct node set + `pendingRevision` | checkpointer restore (1.2) | > ⚠ `projectMarkers` coverage must exist node-direct after 1.3 — markers are the evidence gates (§1 constraint 4). Losing this test silently weakens a gate. @@ -415,4 +416,4 @@ Deleting the DIE bucket leaves holes. Add: - **1.2:** checkpointer resume parity (the new oracle replacing `events-reducer.spec`). - **1.3:** LangGraph graph-construction + conditional-edge routing (replaces builder/executor tests; routing still keys off `outcome-table`). -- **2.1:** Langfuse dispatch is fire-and-forget — assert *run completes + TUI feed intact with Langfuse unreachable* (§7 risk row). +- **2.1:** Langfuse dispatch is fire-and-forget — assert _run completes + TUI feed intact with Langfuse unreachable_ (§7 risk row). diff --git a/bun.lock b/bun.lock index 4e6441c..55ac5a1 100644 --- a/bun.lock +++ b/bun.lock @@ -23,6 +23,7 @@ "oxfmt": "^0.51.0", "oxlint": "^1.65.0", "typescript": "^5.7.0", + "vite-plus": "^0.2.1", }, }, }, @@ -98,14 +99,28 @@ "@aws/lambda-invoke-store": ["@aws/lambda-invoke-store@0.2.4", "", {}, "sha512-iY8yvjE0y651BixKNPgmv1WrQc+GZ142sb0z4gYnChDDY2YqI4P/jsSopBWrKfAt7LOJAkOXt7rC/hms+WclQQ=="], + "@babel/code-frame": ["@babel/code-frame@7.29.7", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw=="], + + "@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.29.7", "", {}, "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg=="], + "@babel/runtime": ["@babel/runtime@7.29.2", "", {}, "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g=="], + "@blazediff/core": ["@blazediff/core@1.9.1", "", {}, "sha512-ehg3jIkYKulZh+8om/O25vkvSsXXwC+skXmyA87FFx6A/45eqOkZsBltMw/TVteb0mloiGT8oGRTcjRAz66zaA=="], + "@borewit/text-codec": ["@borewit/text-codec@0.2.2", "", {}, "sha512-DDaRehssg1aNrH4+2hnj1B7vnUGEjU6OIlyRdkMd0aUdIUvKXrJfXsy8LVtXAy7DRvYVluWbMspsRhz2lcW0mQ=="], "@cfworker/json-schema": ["@cfworker/json-schema@4.1.1", "", {}, "sha512-gAmrUZSGtKc3AiBL71iNWxDsyUC5uMaKKGdvzYsBoTW/xi42JQHl7eKV2OYzCUqvc+D2RCcf7EXY2iCyFIk6og=="], + "@emnapi/core": ["@emnapi/core@1.10.0", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" } }, "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw=="], + + "@emnapi/runtime": ["@emnapi/runtime@1.10.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA=="], + + "@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w=="], + "@google/genai": ["@google/genai@1.46.0", "", { "dependencies": { "google-auth-library": "^10.3.0", "p-retry": "^4.6.2", "protobufjs": "^7.5.4", "ws": "^8.18.0" }, "peerDependencies": { "@modelcontextprotocol/sdk": "^1.25.2" }, "optionalPeers": ["@modelcontextprotocol/sdk"] }, "sha512-ewPMN5JkKfgU5/kdco9ZhXBHDPhVqZpMQqIFQhwsHLf8kyZfx1cNpw1pHo1eV6PGEW7EhIBFi3aYZraFndAXqg=="], + "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="], + "@langchain/core": ["@langchain/core@1.2.0", "", { "dependencies": { "@cfworker/json-schema": "^4.0.2", "@standard-schema/spec": "^1.1.0", "js-tiktoken": "^1.0.12", "langsmith": ">=0.5.0 <1.0.0", "mustache": "^4.2.0", "p-queue": "^6.6.2", "zod": "^3.25.76 || ^4" } }, "sha512-nXmyH0FbcsASlRmC9sbqX0gjQdxgB9KcS13vkw9PMaH0zzylwZkGFU9sY0XCPa2/AokmaNTU9DOW3IUDfAtQow=="], "@langchain/langgraph": ["@langchain/langgraph@1.4.4", "", { "dependencies": { "@langchain/langgraph-checkpoint": "^1.1.2", "@langchain/langgraph-sdk": "~1.9.23", "@langchain/protocol": "^0.0.16", "@standard-schema/spec": "1.1.0" }, "peerDependencies": { "@langchain/core": "^1.1.48", "zod": "^3.25.32 || ^4.2.0", "zod-to-json-schema": "^3.x" }, "optionalPeers": ["zod-to-json-schema"] }, "sha512-20p+/xHRIUIEkk6dsoA576X7D5+FY+LkShsGjBpKrwATzQU0IJ2dfpBaP+4Z4wwpL9ArpDxjoRQR58kycdxU8A=="], @@ -148,8 +163,14 @@ "@mistralai/mistralai": ["@mistralai/mistralai@2.2.1", "", { "dependencies": { "ws": "^8.18.0", "zod": "^3.25.0 || ^4.0.0", "zod-to-json-schema": "^3.25.0" } }, "sha512-uKU8CZmL2RzYKmplsU01hii4p3pe4HqJefpWNRWXm1Tcm0Sm4xXfwSLIy4k7ZCPlbETCGcp69E7hZs+WOJ5itQ=="], + "@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.5", "", { "dependencies": { "@tybys/wasm-util": "^0.10.2" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" } }, "sha512-AWPoBRJ9tsnVhor4sjO7rkni+7p+2IAEFj6cx06UgP10jkQHqay/36uRV/bFkgrh18D9vb4cr8Q0Pthskgzy+Q=="], + "@nodable/entities": ["@nodable/entities@2.1.0", "", {}, "sha512-nyT7T3nbMyBI/lvr6L5TyWbFJAI9FTgVRakNoBqCD+PmID8DzFrrNdLLtHMwMszOtqZa8PAOV24ZqDnQrhQINA=="], + "@oxc-project/runtime": ["@oxc-project/runtime@0.136.0", "", {}, "sha512-u0EutjK5y6NHJkl5jNJCs8zbup1z6A/UEWgajrYzqcEU3UX05HjqybhMQOLhSM0eKGISyM6WfSMMuklYSmH2wA=="], + + "@oxc-project/types": ["@oxc-project/types@0.136.0", "", {}, "sha512-39Al/B3v9esnHCX7S8l9Se2+s2tb9b2jcMd+bZ2L659VG73kNyGPpPrL5Zi/p0ty7p4pTTU2/Dd+g27hv94XCg=="], + "@oxfmt/binding-android-arm-eabi": ["@oxfmt/binding-android-arm-eabi@0.51.0", "", { "os": "android", "cpu": "arm" }, "sha512-Ni0sCqg5CIHaLIYFGj+ncbcumylvNC6FE4rfD0KfdmnWHbPJ+zev0qZCXKxy2hFVa0fYRK0yPzf5nzPbkZou7g=="], "@oxfmt/binding-android-arm64": ["@oxfmt/binding-android-arm64@0.51.0", "", { "os": "android", "cpu": "arm64" }, "sha512-eu5lAZjuo0KAkp+M24EhDqfOwA8owQ8d7wyBlOUUGRbDLHpU3IRlDHp8Dif+YqGlxs6jra7yS6WQu/NkPhAxeg=="], @@ -188,6 +209,18 @@ "@oxfmt/binding-win32-x64-msvc": ["@oxfmt/binding-win32-x64-msvc@0.51.0", "", { "os": "win32", "cpu": "x64" }, "sha512-73RqdAuVKQTkjZIDw08JaDHUM4lav5Qu+CaPwg4QbbA7k8o7LEW0p3UsfZ/F8dsO/pwVYh3RzFcanwLRTTahbQ=="], + "@oxlint-tsgolint/darwin-arm64": ["@oxlint-tsgolint/darwin-arm64@0.23.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-gOs9PVr2wEg4ox9z0aJo+RKhhImW86YL5N6yav8BK/rgPsIrwN/igSZ+pbRr723NFvUNKde9fgMhRA6JrXAOZw=="], + + "@oxlint-tsgolint/darwin-x64": ["@oxlint-tsgolint/darwin-x64@0.23.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-kjJ8B+7n4tB9VJdxS5A9GdJt6/bYpzbu4lXp2uO1S3sRmCB5gDEABlGoiePNApRWaW+xqL4b4xgiE727jSLhuA=="], + + "@oxlint-tsgolint/linux-arm64": ["@oxlint-tsgolint/linux-arm64@0.23.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-6dCZuKNu135seMXilkRk9SpCx6i1XgmiipYGalLij5WVRX6ZYS8c4xI7preN/zv9fCXhsQclTIMDu2Y/cytTjw=="], + + "@oxlint-tsgolint/linux-x64": ["@oxlint-tsgolint/linux-x64@0.23.0", "", { "os": "linux", "cpu": "x64" }, "sha512-3bdilnyA7kmSTjK27rvjIjSxL5SIg3wt7vwNiRkouWB83ytssyKnuGvxSYJxgMEmFpSutzaBzcCUM2jDtPGcgA=="], + + "@oxlint-tsgolint/win32-arm64": ["@oxlint-tsgolint/win32-arm64@0.23.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-j+OEp44SVYiQ+ZD+uttsX7u6L9SvmbbQ77SO1pSFCcJlsVMeCk8qZsjhKfGKuT/jIA+ipOJMVs/+pqUfObBWNw=="], + + "@oxlint-tsgolint/win32-x64": ["@oxlint-tsgolint/win32-x64@0.23.0", "", { "os": "win32", "cpu": "x64" }, "sha512-5MyjFuqf+g8OUPJBSGWHJtmoWnzFJYyOg4To9WMQshZYEWig/vtu7JtJ03VWnzHv9LJkAUeApY0gVCOywFR/iQ=="], + "@oxlint/binding-android-arm-eabi": ["@oxlint/binding-android-arm-eabi@1.65.0", "", { "os": "android", "cpu": "arm" }, "sha512-jDVaGNURT5pEA9qcabh6WusIoBNybOMMDPCx+EFt+gxo6rVvoUf0+73Xy5x81+ZrxU+ewk5uRBYifjy5pgkcnA=="], "@oxlint/binding-android-arm64": ["@oxlint/binding-android-arm64@1.65.0", "", { "os": "android", "cpu": "arm64" }, "sha512-v0z80IWNA7c9RhUydq9YprBxCVZrQ6Ixls2tdxUC1F/1FFqSfa7xTX+EJf0mj6+BKRg2zWXqWfcbJUnETlLlIw=="], @@ -226,6 +259,10 @@ "@oxlint/binding-win32-x64-msvc": ["@oxlint/binding-win32-x64-msvc@1.65.0", "", { "os": "win32", "cpu": "x64" }, "sha512-D7L/oBbskLss21bYrRbFuIs81AiSQV+wRzwck54dOkHIlq2qu1xjLz8u6jCqGH8Fltk8bB5DLBpVhE7v/fA8XQ=="], + "@oxlint/plugins": ["@oxlint/plugins@1.68.0", "", {}, "sha512-titLmukUt/h8ho7Svlf0xSBjoy2ccZKrXjpXpZCj+v6V4CJccC2KyP45BLSCMx8YIpifMyiDyUptM4+5sruKbQ=="], + + "@polka/url": ["@polka/url@1.0.0-next.29", "", {}, "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww=="], + "@protobufjs/aspromise": ["@protobufjs/aspromise@1.1.2", "", {}, "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ=="], "@protobufjs/base64": ["@protobufjs/base64@1.1.2", "", {}, "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg=="], @@ -246,6 +283,38 @@ "@protobufjs/utf8": ["@protobufjs/utf8@1.1.0", "", {}, "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw=="], + "@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.0.3", "", { "os": "android", "cpu": "arm64" }, "sha512-454rs7jHngixp/NMxd5srYD57OnzSlZ/eFTETjORQHLwJG1lRtmNOJcBerZlfu4GjKqeq8aCCIQrMdHyhI51Hw=="], + + "@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.0.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-PcAhP+ynjURNyy8SKGl5DQP94aGuB/7JrXJb/t7P+hanXvQVMWzUvRRhBAcg/lNRadBhoUPqSoP4xw5tR/KBEA=="], + + "@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.0.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-9YpfeUvSE2RS7wysJ81uOZkXJz7f7Q55H2Gvp3VEw/EsahqDtrphrZ0EwDLK5vvKOzaCrBsjF8JmnMLcUt78Gg=="], + + "@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.0.3", "", { "os": "freebsd", "cpu": "x64" }, "sha512-yB1IlAsSNHncV6SCTL27/MVGR5htvQsoGxIv5KMGXALp+Ll1wYsn+x98M9MW7qa+NdSbvrrY7ANI4wLJ0n1e6g=="], + + "@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.0.3", "", { "os": "linux", "cpu": "arm" }, "sha512-Yi30IVAAfLUCy2MseFjbB1jAMDl1VMCAas5StnYp8da9+CKvMd2H2cbEjWcw5NPaPqzvYkVIaF1nNUG+b7u/sw=="], + + "@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.0.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-jsO7R8To+AdlYgUmN5sHSCZbfhtMBkO0WUx8iORQnPcMMdgr7qM2DQmMwgabs3GhNztdmoKkMKQFHD6DTMCIQw=="], + + "@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.0.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-VWkUHwWriDciit80wleYwKILoR/KMvxh/IdwS/paX+ZgpuRpCrKLUdadJbc0NpBEiyhpYawsJ73j9aCvOH+f7Q=="], + + "@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.0.3", "", { "os": "linux", "cpu": "ppc64" }, "sha512-5f1laC0SlIR0yDbFCd8acUhvJIag6N3zC5P7oUPN6wX0aOma+uKJ0wBDH5aq7I1PVI2ttTlhJwzwRIBnLiSGEg=="], + + "@rolldown/binding-linux-s390x-gnu": ["@rolldown/binding-linux-s390x-gnu@1.0.3", "", { "os": "linux", "cpu": "s390x" }, "sha512-Iq4ko0r4XsgbrF/LunNgHtAGLRRVE2kXonAXQ/MV0mC6jQpMOhW1SvtZja2EhC/kd05++bP78dsqBeIQyYJ6Yg=="], + + "@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.0.3", "", { "os": "linux", "cpu": "x64" }, "sha512-B8m6tD5+/N5FeNQFbKlLA/2yVq9ycQP1SeedyEYYKWBNR3ZQbkvIUcNnDNM03lO1l5F2roiiFJGgvoLLyZXtSg=="], + + "@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.0.3", "", { "os": "linux", "cpu": "x64" }, "sha512-pSdpdUJHkuCxun9LE7jvgUB9qsRgaiyNNCX7m/AvHTcq67AiT/Yhoxvw5zPfhrM8k/BfP8ce/hMOpthKDpEUow=="], + + "@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.0.3", "", { "os": "none", "cpu": "arm64" }, "sha512-OXXS3RKJgX2uLwM+gYyuH5omcH8fL1LJs96pZGgtetVCahON57+d4SJHzTgZiOjxgGkSnpXpOsWuPDGAKAigEg=="], + + "@rolldown/binding-wasm32-wasi": ["@rolldown/binding-wasm32-wasi@1.0.3", "", { "dependencies": { "@emnapi/core": "1.10.0", "@emnapi/runtime": "1.10.0", "@napi-rs/wasm-runtime": "^1.1.4" }, "cpu": "none" }, "sha512-JTtb8BWFynicNSoPrehsCzBtOKjZ6jhMiPFEmOiuXg1Fl8dn2KHQob+GuPSGR0dryQa1PQJbzjF3dqO/whhjLg=="], + + "@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.0.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-gEdFFEN70A/jxb2svrWsN3aDL7OUtmvlOy+6fa2jxG8K0wQ1ZbdeLGnidov6Yu5/733dI5ySfzFlQ/cb0bSz1g=="], + + "@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.0.3", "", { "os": "win32", "cpu": "x64" }, "sha512-eXB7CHuaQdqmJcc3koCNtNPmT/bj2gc999kUFgBxG8Ac0NdgXc4rkCHhqrgrhN3zddvvvrgzj1e90SuSfmyIXA=="], + + "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.1", "", {}, "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw=="], + "@silvia-odwyer/photon-node": ["@silvia-odwyer/photon-node@0.3.4", "", {}, "sha512-bnly4BKB3KDTFxrUIcgCLbaeVVS8lrAkri1pEzskpmxu9MdfGQTy8b8EgcD83ywD3RPMsIulY8xJH5Awa+t9fA=="], "@sinclair/typebox": ["@sinclair/typebox@0.34.49", "", {}, "sha512-brySQQs7Jtn0joV8Xh9ZV/hZb9Ozb0pmazDIASBkYKCjXrXU3mpcFahmK/z4YDhGkQvP9mWJbVyahdtU5wQA+A=="], @@ -270,14 +339,28 @@ "@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], + "@testing-library/dom": ["@testing-library/dom@10.4.1", "", { "dependencies": { "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", "@types/aria-query": "^5.0.1", "aria-query": "5.3.0", "dom-accessibility-api": "^0.5.9", "lz-string": "^1.5.0", "picocolors": "1.1.1", "pretty-format": "^27.0.2" } }, "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg=="], + + "@testing-library/user-event": ["@testing-library/user-event@14.6.1", "", { "peerDependencies": { "@testing-library/dom": ">=7.21.4" } }, "sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw=="], + "@tokenizer/inflate": ["@tokenizer/inflate@0.4.1", "", { "dependencies": { "debug": "^4.4.3", "token-types": "^6.1.1" } }, "sha512-2mAv+8pkG6GIZiF1kNg1jAjh27IDxEPKwdGul3snfztFerfPGI1LjDezZp3i7BElXompqEtPmoPx6c2wgtWsOA=="], "@tokenizer/token": ["@tokenizer/token@0.3.0", "", {}, "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A=="], "@tootallnate/quickjs-emscripten": ["@tootallnate/quickjs-emscripten@0.23.0", "", {}, "sha512-C5Mc6rdnsaJDjO3UpGW/CQTHtCKaYlScZTly4JIu97Jxo/odCiH0ITnDXSJPTOrEKk/ycSZ0AOgTmkDtkOsvIA=="], + "@tybys/wasm-util": ["@tybys/wasm-util@0.10.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg=="], + + "@types/aria-query": ["@types/aria-query@5.0.4", "", {}, "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw=="], + "@types/bun": ["@types/bun@1.3.14", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="], + "@types/chai": ["@types/chai@5.2.3", "", { "dependencies": { "@types/deep-eql": "*", "assertion-error": "^2.0.1" } }, "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA=="], + + "@types/deep-eql": ["@types/deep-eql@4.0.2", "", {}, "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw=="], + + "@types/estree": ["@types/estree@1.0.9", "", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="], + "@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="], "@types/mime-types": ["@types/mime-types@2.1.4", "", {}, "sha512-lfU4b34HOri+kAY5UheuFMWPDOI+OPceBSHZKp69gEyTL/mmJ4cnU6Y/rlme3UL3GyOn6Y42hyIEw0/q8sWx5w=="], @@ -288,6 +371,42 @@ "@types/yauzl": ["@types/yauzl@2.10.3", "", { "dependencies": { "@types/node": "*" } }, "sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q=="], + "@vitest/browser": ["@vitest/browser@4.1.9", "", { "dependencies": { "@blazediff/core": "1.9.1", "@vitest/mocker": "4.1.9", "@vitest/utils": "4.1.9", "magic-string": "^0.30.21", "pngjs": "^7.0.0", "sirv": "^3.0.2", "tinyrainbow": "^3.1.0", "ws": "^8.19.0" }, "peerDependencies": { "vitest": "4.1.9" } }, "sha512-j1BKtWmPcqpMhmx/L9EPLgAJpCb0zKfwoWLmqBbxaogCXHjOwHFSEoHCBfnGtx93xKQwilZ26m+UOsHqHMkRNg=="], + + "@vitest/browser-preview": ["@vitest/browser-preview@4.1.9", "", { "dependencies": { "@testing-library/dom": "^10.4.1", "@testing-library/user-event": "^14.6.1", "@vitest/browser": "4.1.9" }, "peerDependencies": { "vitest": "4.1.9" } }, "sha512-a4/OrkMDb/WUnE4OOB/4FJbK3rYVO7YykqtUgcTKG4p2a0R3XcjPVu7SLRHFBs2+NIYhv5yxp1Lz3dbdGBjIow=="], + + "@vitest/expect": ["@vitest/expect@4.1.9", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.1.9", "@vitest/utils": "4.1.9", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" } }, "sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA=="], + + "@vitest/mocker": ["@vitest/mocker@4.1.9", "", { "dependencies": { "@vitest/spy": "4.1.9", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["msw", "vite"] }, "sha512-EVkXzBjrPGM+cK8/ANWgBrkUCfJfb38/EfTSO8h7pWvKkyPkpWxvR7BkD2MyItMF62C97zAEoqdpUixwR/e+Rw=="], + + "@vitest/pretty-format": ["@vitest/pretty-format@4.1.9", "", { "dependencies": { "tinyrainbow": "^3.1.0" } }, "sha512-s0iufns3iIFitdgm+YR7g1whCAaGtXz459VS9/PqyKDEEFgYIhsHOQmXgIgDuYCt7DeQmiZT0Qe2OA2p4ZPu5A=="], + + "@vitest/runner": ["@vitest/runner@4.1.9", "", { "dependencies": { "@vitest/utils": "4.1.9", "pathe": "^2.0.3" } }, "sha512-KXLMDtc7oe70+3mJfGrPUWPesswH+3sTxAMAMl8DG7I8IUQT4XW718dY5ID3vPUcmlu27CcKfY4P3h3I29SLJg=="], + + "@vitest/snapshot": ["@vitest/snapshot@4.1.9", "", { "dependencies": { "@vitest/pretty-format": "4.1.9", "@vitest/utils": "4.1.9", "magic-string": "^0.30.21", "pathe": "^2.0.3" } }, "sha512-Jc7RKGNBo8Z28WYIm0Niej4xdSPByRf6mU58VpHQkd6Zh05rlnA+twjbK5HyeIGHxrzsc3mJgS43uM0CZKzaIA=="], + + "@vitest/spy": ["@vitest/spy@4.1.9", "", {}, "sha512-fHpsS6mIi+PiEW+vcRVOMkX1oSaPKne3VOclSFICPcGOmfKgXPU5iAah+wcNcj2xPrCCmfq99IDGf+EojhhvhA=="], + + "@vitest/utils": ["@vitest/utils@4.1.9", "", { "dependencies": { "@vitest/pretty-format": "4.1.9", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" } }, "sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA=="], + + "@voidzero-dev/vite-plus-core": ["@voidzero-dev/vite-plus-core@0.2.1", "", { "dependencies": { "@oxc-project/runtime": "=0.136.0", "@oxc-project/types": "=0.136.0", "lightningcss": "^1.30.2", "postcss": "^8.5.6" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@arethetypeswrong/core": "^0.18.1", "@tsdown/css": "0.22.3", "@tsdown/exe": "0.22.3", "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.1.18", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "publint": "^0.3.8", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "typescript": "^5.0.0 || ^6.0.0", "unplugin-unused": "^0.5.0", "unrun": "*", "yaml": "^2.4.2" }, "optionalPeers": ["@arethetypeswrong/core", "@tsdown/css", "@tsdown/exe", "@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "publint", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "typescript", "unplugin-unused", "unrun", "yaml"] }, "sha512-iWdtOlLezgYcDqIzxZx1yOUhY93vUB+ob+mRYBNr7/3Hf80uRyTQbqVD1WtsYaANbzeUi81SQ1ZoUraXHO+u8A=="], + + "@voidzero-dev/vite-plus-darwin-arm64": ["@voidzero-dev/vite-plus-darwin-arm64@0.2.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-9AfN/5LKRks8gbTaHPiQHT0L4yboy2xB6x6vvCRWxQMWxPS6/ZJLf5kUIZeE7I1z33AEyLKKkDscsZZVMgMLgg=="], + + "@voidzero-dev/vite-plus-darwin-x64": ["@voidzero-dev/vite-plus-darwin-x64@0.2.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-Q1vyimRbf4M82qIQSWRyr7NJaH9ag5G7vVEfGVVJlQHNprI+Q8zj2Phcs/PGf6QcyjcL8UclLznQTHU9NgnKZw=="], + + "@voidzero-dev/vite-plus-linux-arm64-gnu": ["@voidzero-dev/vite-plus-linux-arm64-gnu@0.2.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-WHW3DziqedRfhJ2upq6kC4y/pmdQWYt322DVB7+4Xb4oOa/CT9GtnSrWIiXVJ4PSO42v54+YsSTKPH2HC5RbtA=="], + + "@voidzero-dev/vite-plus-linux-arm64-musl": ["@voidzero-dev/vite-plus-linux-arm64-musl@0.2.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-vUY7hYycZW0qEevpl7ImzZJFnOEKRYCaCOX4TBW0vk6MJZ+zj/xW7e0LOggzJcz2wbYAgLDqp5h+b8wV9dguDA=="], + + "@voidzero-dev/vite-plus-linux-x64-gnu": ["@voidzero-dev/vite-plus-linux-x64-gnu@0.2.1", "", { "os": "linux", "cpu": "x64" }, "sha512-tFxpToEaykBGxMQHp8M/qmr1yruRRED+c9gA1h9kmplqot04OxuqzRCWu/IiIvMJ0v3JFdOP3gqkyjXLLJhxIA=="], + + "@voidzero-dev/vite-plus-linux-x64-musl": ["@voidzero-dev/vite-plus-linux-x64-musl@0.2.1", "", { "os": "linux", "cpu": "x64" }, "sha512-2scSS7wEbLO2758fqr1/bAULg7nLCFa5V8LO2b5w3g1CrTYdMTDt2WX1ghPesIi+70pYGydRbXo6iaaN43zfMg=="], + + "@voidzero-dev/vite-plus-win32-arm64-msvc": ["@voidzero-dev/vite-plus-win32-arm64-msvc@0.2.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-3+5FJYhi9SqBszjngI2LBmvoiqEwxJWyQ5UsOUtNz6/d+yDrDw+tOgHLl4OKIh5aVNZeIGXzxvP6h24kcEqIyg=="], + + "@voidzero-dev/vite-plus-win32-x64-msvc": ["@voidzero-dev/vite-plus-win32-x64-msvc@0.2.1", "", { "os": "win32", "cpu": "x64" }, "sha512-5sOEwEoU5PW7ObmJ5VCakU09Oh14rYCoLQJkFqvOph6PK30lN5iqWGk0KigEyfcd7Zv+fZg9EmcERDol/3Xl9w=="], + "agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="], "ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="], @@ -296,6 +415,10 @@ "any-promise": ["any-promise@1.3.0", "", {}, "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A=="], + "aria-query": ["aria-query@5.3.0", "", { "dependencies": { "dequal": "^2.0.3" } }, "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A=="], + + "assertion-error": ["assertion-error@2.0.1", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="], + "ast-types": ["ast-types@0.13.4", "", { "dependencies": { "tslib": "^2.0.1" } }, "sha512-x1FCFnFifvYDDzTaLII71vG5uvDwgtmDTEVWAxrgeiR8VjMONcCXJx7E+USjDtHlwFmt9MysbqgF9b9Vjr6w+w=="], "balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], @@ -316,6 +439,8 @@ "bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="], + "chai": ["chai@6.2.2", "", {}, "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg=="], + "chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="], "cli-highlight": ["cli-highlight@2.1.11", "", { "dependencies": { "chalk": "^4.0.0", "highlight.js": "^10.7.1", "mz": "^2.4.0", "parse5": "^5.1.1", "parse5-htmlparser2-tree-adapter": "^6.0.0", "yargs": "^16.0.0" }, "bin": { "highlight": "bin/highlight" } }, "sha512-9KDcoEVwyUXrjcJNvHD0NFc/hiwe/WPVYIleQh2O1N2Zro5gWJZ/K+3DGn8w8P/F6FxOgzyC5bxDyHIgCSPhGg=="], @@ -326,22 +451,30 @@ "color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], + "convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="], + "data-uri-to-buffer": ["data-uri-to-buffer@6.0.2", "", {}, "sha512-7hvf7/GW8e86rW0ptuwS3OcBGDjIi6SZva7hCyWC0yYry2cOPmLIjXAUHI6DK2HsnwJd9ifmt57i8eV2n4YNpw=="], "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], "degenerator": ["degenerator@5.0.1", "", { "dependencies": { "ast-types": "^0.13.4", "escodegen": "^2.1.0", "esprima": "^4.0.1" } }, "sha512-TllpMR/t0M5sqCXfj85i4XaAzxmS5tVA16dqvdkMwGmzI+dXLXnw3J+3Vdv7VKw+ThlTMboK6i9rnZ6Nntj5CQ=="], + "dequal": ["dequal@2.0.3", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="], + "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], "diff": ["diff@8.0.3", "", {}, "sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ=="], + "dom-accessibility-api": ["dom-accessibility-api@0.5.16", "", {}, "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg=="], + "ecdsa-sig-formatter": ["ecdsa-sig-formatter@1.0.11", "", { "dependencies": { "safe-buffer": "^5.0.1" } }, "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ=="], "emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], "end-of-stream": ["end-of-stream@1.4.5", "", { "dependencies": { "once": "^1.4.0" } }, "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg=="], + "es-module-lexer": ["es-module-lexer@2.1.0", "", {}, "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ=="], + "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], "escodegen": ["escodegen@2.1.0", "", { "dependencies": { "esprima": "^4.0.1", "estraverse": "^5.2.0", "esutils": "^2.0.2" }, "optionalDependencies": { "source-map": "~0.6.1" }, "bin": { "esgenerate": "bin/esgenerate.js", "escodegen": "bin/escodegen.js" } }, "sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w=="], @@ -350,10 +483,14 @@ "estraverse": ["estraverse@5.3.0", "", {}, "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA=="], + "estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="], + "esutils": ["esutils@2.0.3", "", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="], "eventemitter3": ["eventemitter3@4.0.7", "", {}, "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw=="], + "expect-type": ["expect-type@1.3.0", "", {}, "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA=="], + "extend": ["extend@3.0.2", "", {}, "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="], "extract-zip": ["extract-zip@2.0.1", "", { "dependencies": { "debug": "^4.1.1", "get-stream": "^5.1.0", "yauzl": "^2.10.0" }, "optionalDependencies": { "@types/yauzl": "^2.9.1" }, "bin": { "extract-zip": "cli.js" } }, "sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg=="], @@ -364,12 +501,16 @@ "fd-slicer": ["fd-slicer@1.1.0", "", { "dependencies": { "pend": "~1.2.0" } }, "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g=="], + "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], + "fetch-blob": ["fetch-blob@3.2.0", "", { "dependencies": { "node-domexception": "^1.0.0", "web-streams-polyfill": "^3.0.3" } }, "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ=="], "file-type": ["file-type@21.3.3", "", { "dependencies": { "@tokenizer/inflate": "^0.4.1", "strtok3": "^10.3.4", "token-types": "^6.1.1", "uint8array-extras": "^1.4.0" } }, "sha512-pNwbwz8c3aZ+GvbJnIsCnDjKvgCZLHxkFWLEFxU3RMa+Ey++ZSEfisvsWQMcdys6PpxQjWUOIDi1fifXsW3YRg=="], "formdata-polyfill": ["formdata-polyfill@4.0.10", "", { "dependencies": { "fetch-blob": "^3.1.2" } }, "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g=="], + "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + "gaxios": ["gaxios@7.1.4", "", { "dependencies": { "extend": "^3.0.2", "https-proxy-agent": "^7.0.1", "node-fetch": "^3.3.2" } }, "sha512-bTIgTsM2bWn3XklZISBTQX7ZSddGW+IO3bMdGaemHZ3tbqExMENHLx6kKZ/KlejgrMtj8q7wBItt51yegqalrA=="], "gcp-metadata": ["gcp-metadata@8.1.2", "", { "dependencies": { "gaxios": "^7.0.0", "google-logging-utils": "^1.0.0", "json-bigint": "^1.0.0" } }, "sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg=="], @@ -414,6 +555,8 @@ "js-tiktoken": ["js-tiktoken@1.0.21", "", { "dependencies": { "base64-js": "^1.5.1" } }, "sha512-biOj/6M5qdgx5TKjDnFT1ymSpM5tbd3ylwDtrQvFQSu0Z7bBYko2dF+W/aUkXUPuk6IVpRxk/3Q2sHOzGlS36g=="], + "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], + "json-bigint": ["json-bigint@1.0.0", "", { "dependencies": { "bignumber.js": "^9.0.0" } }, "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ=="], "json-schema-to-ts": ["json-schema-to-ts@3.1.1", "", { "dependencies": { "@babel/runtime": "^7.18.3", "ts-algebra": "^2.0.0" } }, "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g=="], @@ -430,10 +573,38 @@ "langsmith": ["langsmith@0.7.10", "", { "dependencies": { "p-queue": "6.6.2" }, "peerDependencies": { "@opentelemetry/api": "*", "@opentelemetry/exporter-trace-otlp-proto": "*", "@opentelemetry/sdk-trace-base": "*", "openai": "*", "ws": ">=7" }, "optionalPeers": ["@opentelemetry/api", "@opentelemetry/exporter-trace-otlp-proto", "@opentelemetry/sdk-trace-base", "openai", "ws"] }, "sha512-3EjJx9zGMzqF60eT9JADHF+Hn/T5ayTgEVp4d3M5yvJIJi3q6seX0p5jT8ecBCWBi1kIvvssWrcDxfwgSier7Q=="], + "lightningcss": ["lightningcss@1.32.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="], + + "lightningcss-android-arm64": ["lightningcss-android-arm64@1.32.0", "", { "os": "android", "cpu": "arm64" }, "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg=="], + + "lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.32.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ=="], + + "lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.32.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w=="], + + "lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.32.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig=="], + + "lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.32.0", "", { "os": "linux", "cpu": "arm" }, "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw=="], + + "lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ=="], + + "lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg=="], + + "lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA=="], + + "lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg=="], + + "lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.32.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw=="], + + "lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.32.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q=="], + "long": ["long@5.3.2", "", {}, "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA=="], "lru-cache": ["lru-cache@7.18.3", "", {}, "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA=="], + "lz-string": ["lz-string@1.5.0", "", { "bin": { "lz-string": "bin/bin.js" } }, "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ=="], + + "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], + "marked": ["marked@15.0.12", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA=="], "mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="], @@ -444,12 +615,16 @@ "minipass": ["minipass@7.1.3", "", {}, "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A=="], + "mrmime": ["mrmime@2.0.1", "", {}, "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ=="], + "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], "mustache": ["mustache@4.2.0", "", { "bin": { "mustache": "bin/mustache" } }, "sha512-71ippSywq5Yb7/tVYyGbkBggbU8H3u5Rz56fH60jGFgr8uHwxs+aSKeqmluIVzM0m0kB7xQjKS6qPfd0b2ZoqQ=="], "mz": ["mz@2.7.0", "", { "dependencies": { "any-promise": "^1.0.0", "object-assign": "^4.0.1", "thenify-all": "^1.0.0" } }, "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q=="], + "nanoid": ["nanoid@3.3.15", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA=="], + "netmask": ["netmask@2.0.2", "", {}, "sha512-dBpDMdxv9Irdq66304OLfEmQ9tbNRFnFTuZiLo+bD+r332bBmMJ8GBLXklIXXgxd3+v9+KUnZaUR5PJMa75Gsg=="], "node-domexception": ["node-domexception@1.0.0", "", {}, "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ=="], @@ -458,6 +633,8 @@ "object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="], + "obug": ["obug@2.1.3", "", {}, "sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg=="], + "once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="], "openai": ["openai@6.26.0", "", { "peerDependencies": { "ws": "^8.18.0", "zod": "^3.25 || ^4.0" }, "optionalPeers": ["ws", "zod"], "bin": { "openai": "bin/cli" } }, "sha512-zd23dbWTjiJ6sSAX6s0HrCZi41JwTA1bQVs0wLQPZ2/5o2gxOJA5wh7yOAUgwYybfhDXyhwlpeQf7Mlgx8EOCA=="], @@ -466,6 +643,8 @@ "oxlint": ["oxlint@1.65.0", "", { "optionalDependencies": { "@oxlint/binding-android-arm-eabi": "1.65.0", "@oxlint/binding-android-arm64": "1.65.0", "@oxlint/binding-darwin-arm64": "1.65.0", "@oxlint/binding-darwin-x64": "1.65.0", "@oxlint/binding-freebsd-x64": "1.65.0", "@oxlint/binding-linux-arm-gnueabihf": "1.65.0", "@oxlint/binding-linux-arm-musleabihf": "1.65.0", "@oxlint/binding-linux-arm64-gnu": "1.65.0", "@oxlint/binding-linux-arm64-musl": "1.65.0", "@oxlint/binding-linux-ppc64-gnu": "1.65.0", "@oxlint/binding-linux-riscv64-gnu": "1.65.0", "@oxlint/binding-linux-riscv64-musl": "1.65.0", "@oxlint/binding-linux-s390x-gnu": "1.65.0", "@oxlint/binding-linux-x64-gnu": "1.65.0", "@oxlint/binding-linux-x64-musl": "1.65.0", "@oxlint/binding-openharmony-arm64": "1.65.0", "@oxlint/binding-win32-arm64-msvc": "1.65.0", "@oxlint/binding-win32-ia32-msvc": "1.65.0", "@oxlint/binding-win32-x64-msvc": "1.65.0" }, "peerDependencies": { "oxlint-tsgolint": ">=0.22.1" }, "optionalPeers": ["oxlint-tsgolint"], "bin": { "oxlint": "bin/oxlint" } }, "sha512-ChUuE3Q7XnAbscvT4XLMsH7HFJmLgLVv9lu+RRgFL5wSXnDqUOzTp5IS8qWDBGd/ZDSzQ2tbX8fjAmijlGLC7A=="], + "oxlint-tsgolint": ["oxlint-tsgolint@0.23.0", "", { "optionalDependencies": { "@oxlint-tsgolint/darwin-arm64": "0.23.0", "@oxlint-tsgolint/darwin-x64": "0.23.0", "@oxlint-tsgolint/linux-arm64": "0.23.0", "@oxlint-tsgolint/linux-x64": "0.23.0", "@oxlint-tsgolint/win32-arm64": "0.23.0", "@oxlint-tsgolint/win32-x64": "0.23.0" }, "bin": { "tsgolint": "bin/tsgolint.js" } }, "sha512-3mBv3CoPbh8dFbzfDGIWa2ytZjn2v+3EX4aKRXjIhsoGFzG8GCjfRirz3rwZf1wYbZzsNLTSgpw8VjQuWdp/jA=="], + "p-finally": ["p-finally@1.0.0", "", {}, "sha512-LICb2p9CB7FS+0eR1oqWnHhp0FljGLZCWBE9aix0Uye9W8LTQPwMTYVGWQWIw9RdQiDg4+epXQODwIYJtSJaow=="], "p-queue": ["p-queue@6.6.2", "", { "dependencies": { "eventemitter3": "^4.0.4", "p-timeout": "^3.2.0" } }, "sha512-RwFpb72c/BhQLEXIZ5K2e+AhgNVmIejGlTgiB9MzZ0e93GRvqZ7uSi0dvRF7/XIXDeNkra2fNHBxTyPDGySpjQ=="], @@ -488,10 +667,22 @@ "path-scurry": ["path-scurry@2.0.2", "", { "dependencies": { "lru-cache": "^11.0.0", "minipass": "^7.1.2" } }, "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg=="], + "pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="], + "pend": ["pend@1.2.0", "", {}, "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg=="], "pi-askuserquestion": ["pi-askuserquestion@github:ghoseb/pi-askuserquestion#f8a7c69", { "peerDependencies": { "@mariozechner/pi-coding-agent": "*", "@mariozechner/pi-tui": "*", "@sinclair/typebox": "*" } }, "ghoseb-pi-askuserquestion-f8a7c69", "sha512-s81KWTbC1HDsky7Bq41FCgQLyL6OxpPI8AtlVb+prPKMQH1XvKoUvghhApiFX+qY6daOQShpkhSG2cEwmV5oKw=="], + "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], + + "picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="], + + "pngjs": ["pngjs@7.0.0", "", {}, "sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow=="], + + "postcss": ["postcss@8.5.15", "", { "dependencies": { "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A=="], + + "pretty-format": ["pretty-format@27.5.1", "", { "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", "react-is": "^17.0.1" } }, "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ=="], + "proper-lockfile": ["proper-lockfile@4.1.2", "", { "dependencies": { "graceful-fs": "^4.2.4", "retry": "^0.12.0", "signal-exit": "^3.0.2" } }, "sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA=="], "protobufjs": ["protobufjs@7.5.4", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.2", "@protobufjs/base64": "^1.1.2", "@protobufjs/codegen": "^2.0.4", "@protobufjs/eventemitter": "^1.1.0", "@protobufjs/fetch": "^1.1.0", "@protobufjs/float": "^1.0.2", "@protobufjs/inquire": "^1.1.0", "@protobufjs/path": "^1.1.2", "@protobufjs/pool": "^1.1.0", "@protobufjs/utf8": "^1.1.0", "@types/node": ">=13.7.0", "long": "^5.0.0" } }, "sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg=="], @@ -502,14 +693,22 @@ "pump": ["pump@3.0.4", "", { "dependencies": { "end-of-stream": "^1.1.0", "once": "^1.3.1" } }, "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA=="], + "react-is": ["react-is@17.0.2", "", {}, "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w=="], + "require-directory": ["require-directory@2.1.1", "", {}, "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q=="], "retry": ["retry@0.12.0", "", {}, "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow=="], + "rolldown": ["rolldown@1.0.3", "", { "dependencies": { "@oxc-project/types": "=0.133.0", "@rolldown/pluginutils": "^1.0.0" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.0.3", "@rolldown/binding-darwin-arm64": "1.0.3", "@rolldown/binding-darwin-x64": "1.0.3", "@rolldown/binding-freebsd-x64": "1.0.3", "@rolldown/binding-linux-arm-gnueabihf": "1.0.3", "@rolldown/binding-linux-arm64-gnu": "1.0.3", "@rolldown/binding-linux-arm64-musl": "1.0.3", "@rolldown/binding-linux-ppc64-gnu": "1.0.3", "@rolldown/binding-linux-s390x-gnu": "1.0.3", "@rolldown/binding-linux-x64-gnu": "1.0.3", "@rolldown/binding-linux-x64-musl": "1.0.3", "@rolldown/binding-openharmony-arm64": "1.0.3", "@rolldown/binding-wasm32-wasi": "1.0.3", "@rolldown/binding-win32-arm64-msvc": "1.0.3", "@rolldown/binding-win32-x64-msvc": "1.0.3" }, "bin": { "rolldown": "./bin/cli.mjs" } }, "sha512-i00lAJ2ks1BYr7rjNjKC7BcqAS7nVfiT3QX1SI5aY+AFHblCmaUf9OE9dbdzDvW6dJxbi2ZCZiy9v3CcwOiX3g=="], + "safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="], + "siginfo": ["siginfo@2.0.0", "", {}, "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g=="], + "signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="], + "sirv": ["sirv@3.0.2", "", { "dependencies": { "@polka/url": "^1.0.0-next.24", "mrmime": "^2.0.0", "totalist": "^3.0.0" } }, "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g=="], + "smart-buffer": ["smart-buffer@4.2.0", "", {}, "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg=="], "socks": ["socks@2.8.7", "", { "dependencies": { "ip-address": "^10.0.1", "smart-buffer": "^4.2.0" } }, "sha512-HLpt+uLy/pxB+bum/9DzAgiKS8CX1EvbWxI4zlmgGCExImLdiad2iCwXT5Z4c9c3Eq8rP2318mPW2c+QbtjK8A=="], @@ -518,6 +717,12 @@ "source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], + "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], + + "stackback": ["stackback@0.0.2", "", {}, "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="], + + "std-env": ["std-env@4.1.0", "", {}, "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ=="], + "string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], "strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="], @@ -532,10 +737,20 @@ "thenify-all": ["thenify-all@1.6.0", "", { "dependencies": { "thenify": ">= 3.1.0 < 4" } }, "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA=="], + "tinybench": ["tinybench@2.9.0", "", {}, "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg=="], + + "tinyexec": ["tinyexec@1.2.4", "", {}, "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg=="], + + "tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="], + "tinypool": ["tinypool@2.1.0", "", {}, "sha512-Pugqs6M0m7Lv1I7FtxN4aoyToKg1C4tu+/381vH35y8oENM/Ai7f7C4StcoK4/+BSw9ebcS8jRiVrORFKCALLw=="], + "tinyrainbow": ["tinyrainbow@3.1.0", "", {}, "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw=="], + "token-types": ["token-types@6.1.2", "", { "dependencies": { "@borewit/text-codec": "^0.2.1", "@tokenizer/token": "^0.3.0", "ieee754": "^1.2.1" } }, "sha512-dRXchy+C0IgK8WPC6xvCHFRIWYUbqqdEIKPaKo/AcTUNzwLTK6AH7RjdLWsEZcAN/TBdtfUw3PYEgPr5VPr6ww=="], + "totalist": ["totalist@3.0.1", "", {}, "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ=="], + "ts-algebra": ["ts-algebra@2.0.0", "", {}, "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw=="], "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], @@ -552,8 +767,16 @@ "uuid": ["uuid@14.0.0", "", { "bin": { "uuid": "dist-node/bin/uuid" } }, "sha512-Qo+uWgilfSmAhXCMav1uYFynlQO7fMFiMVZsQqZRMIXp0O7rR7qjkj+cPvBHLgBqi960QCoo/PH2/6ZtVqKvrg=="], + "vite": ["vite@8.0.16", "", { "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", "postcss": "^8.5.15", "rolldown": "1.0.3", "tinyglobby": "^0.2.17" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.1.18", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw=="], + + "vite-plus": ["vite-plus@0.2.1", "", { "dependencies": { "@oxc-project/types": "=0.136.0", "@oxlint/plugins": "=1.68.0", "@vitest/browser": "4.1.9", "@vitest/browser-preview": "4.1.9", "@vitest/expect": "4.1.9", "@vitest/mocker": "4.1.9", "@vitest/pretty-format": "4.1.9", "@vitest/runner": "4.1.9", "@vitest/snapshot": "4.1.9", "@vitest/spy": "4.1.9", "@vitest/utils": "4.1.9", "@voidzero-dev/vite-plus-core": "0.2.1", "oxfmt": "=0.55.0", "oxlint": "=1.70.0", "oxlint-tsgolint": "=0.23.0", "vitest": "4.1.9" }, "optionalDependencies": { "@voidzero-dev/vite-plus-darwin-arm64": "0.2.1", "@voidzero-dev/vite-plus-darwin-x64": "0.2.1", "@voidzero-dev/vite-plus-linux-arm64-gnu": "0.2.1", "@voidzero-dev/vite-plus-linux-arm64-musl": "0.2.1", "@voidzero-dev/vite-plus-linux-x64-gnu": "0.2.1", "@voidzero-dev/vite-plus-linux-x64-musl": "0.2.1", "@voidzero-dev/vite-plus-win32-arm64-msvc": "0.2.1", "@voidzero-dev/vite-plus-win32-x64-msvc": "0.2.1" }, "peerDependencies": { "@vitest/browser-playwright": "4.1.9", "@vitest/browser-webdriverio": "4.1.9" }, "optionalPeers": ["@vitest/browser-playwright", "@vitest/browser-webdriverio"], "bin": { "oxfmt": "bin/oxfmt", "oxlint": "bin/oxlint", "vp": "bin/vp" } }, "sha512-q5q/Y38UkWFsNg1JO+RyRdPUqoewaSqIlMyK2p83GKNUvf4D38Ntb3PToRTDZbTRh7mWt+B+d0DQBv4nCDpMcQ=="], + + "vitest": ["vitest@4.1.9", "", { "dependencies": { "@vitest/expect": "4.1.9", "@vitest/mocker": "4.1.9", "@vitest/pretty-format": "4.1.9", "@vitest/runner": "4.1.9", "@vitest/snapshot": "4.1.9", "@vitest/spy": "4.1.9", "@vitest/utils": "4.1.9", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.1.0", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.1.9", "@vitest/browser-preview": "4.1.9", "@vitest/browser-webdriverio": "4.1.9", "@vitest/coverage-istanbul": "4.1.9", "@vitest/coverage-v8": "4.1.9", "@vitest/ui": "4.1.9", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/coverage-istanbul", "@vitest/coverage-v8", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "./vitest.mjs" } }, "sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ=="], + "web-streams-polyfill": ["web-streams-polyfill@3.3.3", "", {}, "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw=="], + "why-is-node-running": ["why-is-node-running@2.3.0", "", { "dependencies": { "siginfo": "^2.0.0", "stackback": "0.0.2" }, "bin": { "why-is-node-running": "cli.js" } }, "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w=="], + "wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="], "wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="], @@ -600,8 +823,18 @@ "path-scurry/lru-cache": ["lru-cache@11.2.7", "", {}, "sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA=="], + "pretty-format/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], + + "pretty-format/ansi-styles": ["ansi-styles@5.2.0", "", {}, "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA=="], + + "rolldown/@oxc-project/types": ["@oxc-project/types@0.133.0", "", {}, "sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA=="], + "string-width/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + "vite-plus/oxfmt": ["oxfmt@0.55.0", "", { "dependencies": { "tinypool": "2.1.0" }, "optionalDependencies": { "@oxfmt/binding-android-arm-eabi": "0.55.0", "@oxfmt/binding-android-arm64": "0.55.0", "@oxfmt/binding-darwin-arm64": "0.55.0", "@oxfmt/binding-darwin-x64": "0.55.0", "@oxfmt/binding-freebsd-x64": "0.55.0", "@oxfmt/binding-linux-arm-gnueabihf": "0.55.0", "@oxfmt/binding-linux-arm-musleabihf": "0.55.0", "@oxfmt/binding-linux-arm64-gnu": "0.55.0", "@oxfmt/binding-linux-arm64-musl": "0.55.0", "@oxfmt/binding-linux-ppc64-gnu": "0.55.0", "@oxfmt/binding-linux-riscv64-gnu": "0.55.0", "@oxfmt/binding-linux-riscv64-musl": "0.55.0", "@oxfmt/binding-linux-s390x-gnu": "0.55.0", "@oxfmt/binding-linux-x64-gnu": "0.55.0", "@oxfmt/binding-linux-x64-musl": "0.55.0", "@oxfmt/binding-openharmony-arm64": "0.55.0", "@oxfmt/binding-win32-arm64-msvc": "0.55.0", "@oxfmt/binding-win32-ia32-msvc": "0.55.0", "@oxfmt/binding-win32-x64-msvc": "0.55.0" }, "peerDependencies": { "svelte": "^5.0.0", "vite-plus": "*" }, "optionalPeers": ["svelte", "vite-plus"], "bin": { "oxfmt": "bin/oxfmt" } }, "sha512-jSj2wCTakwgPMxkfiVZX0jf+nX+Nz6xlyAZjqNE0qXTFdCBPYlP6JAN+ODjmealw7DXBjOzYbdsqwBMAZnPZ6A=="], + + "vite-plus/oxlint": ["oxlint@1.70.0", "", { "optionalDependencies": { "@oxlint/binding-android-arm-eabi": "1.70.0", "@oxlint/binding-android-arm64": "1.70.0", "@oxlint/binding-darwin-arm64": "1.70.0", "@oxlint/binding-darwin-x64": "1.70.0", "@oxlint/binding-freebsd-x64": "1.70.0", "@oxlint/binding-linux-arm-gnueabihf": "1.70.0", "@oxlint/binding-linux-arm-musleabihf": "1.70.0", "@oxlint/binding-linux-arm64-gnu": "1.70.0", "@oxlint/binding-linux-arm64-musl": "1.70.0", "@oxlint/binding-linux-ppc64-gnu": "1.70.0", "@oxlint/binding-linux-riscv64-gnu": "1.70.0", "@oxlint/binding-linux-riscv64-musl": "1.70.0", "@oxlint/binding-linux-s390x-gnu": "1.70.0", "@oxlint/binding-linux-x64-gnu": "1.70.0", "@oxlint/binding-linux-x64-musl": "1.70.0", "@oxlint/binding-openharmony-arm64": "1.70.0", "@oxlint/binding-win32-arm64-msvc": "1.70.0", "@oxlint/binding-win32-ia32-msvc": "1.70.0", "@oxlint/binding-win32-x64-msvc": "1.70.0" }, "peerDependencies": { "oxlint-tsgolint": ">=0.22.1", "vite-plus": "*" }, "optionalPeers": ["oxlint-tsgolint", "vite-plus"], "bin": { "oxlint": "bin/oxlint" } }, "sha512-D6JgHtzkhRwvEC+A0Nw5AEc5bk8x5i1pHzvZIEf/a0C4hOzmAACNGtkDGPyFaxxX3ZVGxCPeig3P3rMM8XU3/g=="], + "wrap-ansi/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], "@aws-crypto/crc32/@aws-sdk/types/@smithy/types": ["@smithy/types@4.13.1", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-787F3yzE2UiJIQ+wYW1CVg2odHjmaWLGksnKQHUrK/lYZSEcy1msuLVvxaR/sI2/aDe9U+TBuLsXnr3vod1g0g=="], @@ -622,6 +855,82 @@ "string-width/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], + "vite-plus/oxfmt/@oxfmt/binding-android-arm-eabi": ["@oxfmt/binding-android-arm-eabi@0.55.0", "", { "os": "android", "cpu": "arm" }, "sha512-+rFDOqQe5LOWgxrAJaZgLRudr6GQm0wGI6gtu7vVkrdLGjNMUSGbAlaCr8j7F2H2Er97vYQCU8WDb30onqMM1g=="], + + "vite-plus/oxfmt/@oxfmt/binding-android-arm64": ["@oxfmt/binding-android-arm64@0.55.0", "", { "os": "android", "cpu": "arm64" }, "sha512-ctulLq8s3x8Zmvw6+iccB09TIKERAklRSmbJ10gk8mlAn05qZxoyo52dj3Hi9IJcmDSwF54fQaTVh2CbL6PInw=="], + + "vite-plus/oxfmt/@oxfmt/binding-darwin-arm64": ["@oxfmt/binding-darwin-arm64@0.55.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-xDQczLH9pw/RBk1h/GH0qcGMm8hQtmtVHBNLSH3lk1gEIR09hZ4L+mJQl4VqiVAvPK9VG9PYrWWuSQLt7xTbiA=="], + + "vite-plus/oxfmt/@oxfmt/binding-darwin-x64": ["@oxfmt/binding-darwin-x64@0.55.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-JaNoFCkF2CJdGgpPSMbuO9HVyXyoNGIhMHPvp6NYAjeVKw9XEYc0HcUWJLPQa3Q69WV5wMa9m5jPMJPtbLtcRg=="], + + "vite-plus/oxfmt/@oxfmt/binding-freebsd-x64": ["@oxfmt/binding-freebsd-x64@0.55.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-DNbszhpg6S2MIzax5azdHFTTBIVkR5xr8yyRZuA4yoDAwOkzIp3tmldgKZM2+VlT+hJIG0xUksA+elISzMEAfA=="], + + "vite-plus/oxfmt/@oxfmt/binding-linux-arm-gnueabihf": ["@oxfmt/binding-linux-arm-gnueabihf@0.55.0", "", { "os": "linux", "cpu": "arm" }, "sha512-2snoaoRfFFyGnbOcKUK36rREBYxe/Xgz3uHbiA5zbCB/s6R4DQj4mHqYAaWWhgizCUSDxV8cE9zAZ0XleNpKGw=="], + + "vite-plus/oxfmt/@oxfmt/binding-linux-arm-musleabihf": ["@oxfmt/binding-linux-arm-musleabihf@0.55.0", "", { "os": "linux", "cpu": "arm" }, "sha512-q1aktHF/WRpSK81BX1dE/9vWrS2jGw1Nax2kb4DBLGAewubCLcoNyp4Zl/NSMgbv3vUS46Z33wIQkBVYOP3PYg=="], + + "vite-plus/oxfmt/@oxfmt/binding-linux-arm64-gnu": ["@oxfmt/binding-linux-arm64-gnu@0.55.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-VD0y36aENezl/3tsclA/4G53Cc7iV+7Uoh7gz4yvcOTaEYBtJpQsE6PKDGTtUtOvGS4kv51ybfXY/nWZejO5IA=="], + + "vite-plus/oxfmt/@oxfmt/binding-linux-arm64-musl": ["@oxfmt/binding-linux-arm64-musl@0.55.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-r8xlKJFcsRmn0H5jZrdORae6RX9jDBrZVvOoxF+bCQtampQJClv80aZEHsv+NsLsp2KCE5ql79O7DpPVzYWpXA=="], + + "vite-plus/oxfmt/@oxfmt/binding-linux-ppc64-gnu": ["@oxfmt/binding-linux-ppc64-gnu@0.55.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-GRKv/HXHcwIVld/WU61rF0g0R16hl5EJ+ScKdpjevT57lnLnagj/U2YUbXf2mT+2Pg1uCzWC+mvGicPV3CDdLQ=="], + + "vite-plus/oxfmt/@oxfmt/binding-linux-riscv64-gnu": ["@oxfmt/binding-linux-riscv64-gnu@0.55.0", "", { "os": "linux", "cpu": "none" }, "sha512-rdv57enTiPtpSYRMKfAiEbQb0Puw5t9N7isVinDoo5qeLDScro2gznmZqSgSWbVZRzLisTeCTW8Qwgw0bOHv3A=="], + + "vite-plus/oxfmt/@oxfmt/binding-linux-riscv64-musl": ["@oxfmt/binding-linux-riscv64-musl@0.55.0", "", { "os": "linux", "cpu": "none" }, "sha512-7v1nNrlD43VY6+sYQ6efYyb3lE6QY182304PD/768ZxTjOmFd/3dQa3u/nGBUAXYdGSWOQc5N3PnS0QzUXyEIA=="], + + "vite-plus/oxfmt/@oxfmt/binding-linux-s390x-gnu": ["@oxfmt/binding-linux-s390x-gnu@0.55.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-f4lJLUSPOgScjFl9LiflKCTocyNRwE25JmTMbN4XQdDjoZzEHjqf3wA3VESF1/csg7i8m7+EQLbrZyYDqe10UQ=="], + + "vite-plus/oxfmt/@oxfmt/binding-linux-x64-gnu": ["@oxfmt/binding-linux-x64-gnu@0.55.0", "", { "os": "linux", "cpu": "x64" }, "sha512-MihqiPziJNoWy4MqNSV+jVA1g+07iQDjZiR0vaCaDoPgFEiJpCMsxamktzLV07cEeQsSJ04vQaU4CzCQwIvtDA=="], + + "vite-plus/oxfmt/@oxfmt/binding-linux-x64-musl": ["@oxfmt/binding-linux-x64-musl@0.55.0", "", { "os": "linux", "cpu": "x64" }, "sha512-Yqghym7KYAVjP9MmSrNZiDeerMuoejNjo0r3ox5H3GDKk8eAfl8VyJm9i+pWCLDCTnAbcTUMMN2ZKjUYXH1v3g=="], + + "vite-plus/oxfmt/@oxfmt/binding-openharmony-arm64": ["@oxfmt/binding-openharmony-arm64@0.55.0", "", { "os": "none", "cpu": "arm64" }, "sha512-s5SDvVVSbyQl1V5UU3Yl12M+XLUQ3rl5SglNqgAA2K4PXUtQhyNSS00wivONPEnNo5W01rCou8WkDNyvI/RGHg=="], + + "vite-plus/oxfmt/@oxfmt/binding-win32-arm64-msvc": ["@oxfmt/binding-win32-arm64-msvc@0.55.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-7p9FB5R32tw2KyyNX3wpQrR2WHwEHvMEiBlGXxeTCaRMCVNx3UtFMAUbaQ/pRNWIrEUZmYhJ6tcUH52uPTRYjQ=="], + + "vite-plus/oxfmt/@oxfmt/binding-win32-ia32-msvc": ["@oxfmt/binding-win32-ia32-msvc@0.55.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-ZYqj3fDnOT1IaVGMP5kpmkQl4F3tQIm2ZyAxvqkJYmI0xgWWak4ss4XYwv3VDfM+TWXeC9K4uQ/wW5jm/5XABA=="], + + "vite-plus/oxfmt/@oxfmt/binding-win32-x64-msvc": ["@oxfmt/binding-win32-x64-msvc@0.55.0", "", { "os": "win32", "cpu": "x64" }, "sha512-eEYT5tivGnGbPHuOHuQpi6CGLObhh0re/5jcNQHihD2GRYkTM85dyi5a19zjP8Q00t1uqAx+/QGLUGdHeqzWyg=="], + + "vite-plus/oxlint/@oxlint/binding-android-arm-eabi": ["@oxlint/binding-android-arm-eabi@1.70.0", "", { "os": "android", "cpu": "arm" }, "sha512-zFh0P4cswmRvw6nkyb89dr18rRanuaCPAsEXsFDoQY8WdaquI8Pt4NWFjaMJg6L23cy5NeN8J9cBnREbWzZhaw=="], + + "vite-plus/oxlint/@oxlint/binding-android-arm64": ["@oxlint/binding-android-arm64@1.70.0", "", { "os": "android", "cpu": "arm64" }, "sha512-qI8o4HZjeGiBrWv+pJv4lH0Yi2Gl/JSp/EumBUApezJprIKa5PS4nU0lQsQngtky8k+SplQIOjv6hwu0SSxeyg=="], + + "vite-plus/oxlint/@oxlint/binding-darwin-arm64": ["@oxlint/binding-darwin-arm64@1.70.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-8KjgVVHI5F9nVwHCRwwA78Ty7zNKP4Wd9OeN5PSv3iu/F/u1RVXoOCgLhWqust6HmwQG6xc8c+RCyaWENy24+w=="], + + "vite-plus/oxlint/@oxlint/binding-darwin-x64": ["@oxlint/binding-darwin-x64@1.70.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-WVydssv5PSUBXFJTdNBWlmGkbNmvPGaFt/2SUT/EZRB6bq6bEOHmMlbnupZD5jmlEvi9+mZJHi8TCw15lyfSfQ=="], + + "vite-plus/oxlint/@oxlint/binding-freebsd-x64": ["@oxlint/binding-freebsd-x64@1.70.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-hJucmUf8OlinHNb1R7fI4Fw6WsAstOz7i8nmkWQfiHoZXtbufNm+MxiDTIMk1ggh2Ro4vLzgQ+bKvRY54MZoRA=="], + + "vite-plus/oxlint/@oxlint/binding-linux-arm-gnueabihf": ["@oxlint/binding-linux-arm-gnueabihf@1.70.0", "", { "os": "linux", "cpu": "arm" }, "sha512-1BnS7wbCYDSXwWzJJ+mc3NURoha6m6m6RT5c6vgAY3oz7C3OVXP+S0awo2mRq97arrJkVvO3qRQfyAHL+76xtQ=="], + + "vite-plus/oxlint/@oxlint/binding-linux-arm-musleabihf": ["@oxlint/binding-linux-arm-musleabihf@1.70.0", "", { "os": "linux", "cpu": "arm" }, "sha512-yKy/UdbR55+M2yEcuiV5DCNC/gdQAjr/GioUy50QwBzSrKm8ueWADqyRLS9Xk+qjNeCYGg6A8FvUBds56ttfqg=="], + + "vite-plus/oxlint/@oxlint/binding-linux-arm64-gnu": ["@oxlint/binding-linux-arm64-gnu@1.70.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-0A5XJ4alvmqFUFP/4oYSyaO+qLto/HrKEWTSaegiVl+HOufFngK2BjYw9x4RbwBt/du5QG6l5q1zeWiJYYG5yg=="], + + "vite-plus/oxlint/@oxlint/binding-linux-arm64-musl": ["@oxlint/binding-linux-arm64-musl@1.70.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-JiylyurlB0CLSedNtx1gzv3FvfWPF1h/2Y3BJszPLNt5XQFlBsH5ke0Jle3iJb3uqu5m2e7A/DwzpuCAHdiU+A=="], + + "vite-plus/oxlint/@oxlint/binding-linux-ppc64-gnu": ["@oxlint/binding-linux-ppc64-gnu@1.70.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-J8VPG7I3/HmgaU4u8pNU2kFx2+0U+vPLS1dXFxXOaR/2TQ0f8AC7DRz0SRGRI1bfphnX2hVYTTtLuhL4nYKL+Q=="], + + "vite-plus/oxlint/@oxlint/binding-linux-riscv64-gnu": ["@oxlint/binding-linux-riscv64-gnu@1.70.0", "", { "os": "linux", "cpu": "none" }, "sha512-N2+4lV2KLN+oXTIIIwmWDhwkrnvqf5oX7Hw0zPjk+RuIVgiBQSOlJWF7uQoFx2siEYX0ZQ5cfSbEAHm+J3t7Wg=="], + + "vite-plus/oxlint/@oxlint/binding-linux-riscv64-musl": ["@oxlint/binding-linux-riscv64-musl@1.70.0", "", { "os": "linux", "cpu": "none" }, "sha512-1e2L7cFCvx9QDzq6NPP+0tABKb5z6nWHyddWTNKprEsjO9xNrAtPowuCGpjNXxkTdsMiZ4jc8YQ5SstZd4XK6g=="], + + "vite-plus/oxlint/@oxlint/binding-linux-s390x-gnu": ["@oxlint/binding-linux-s390x-gnu@1.70.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-Kwu/l/8GcYibCWA9m9N5pRXMIKVSsL/YbgpLzYkqDhWTiqdRfnNJ/+nqIKRKQiFbHWsdlHEhzMwruJK+qcEruA=="], + + "vite-plus/oxlint/@oxlint/binding-linux-x64-gnu": ["@oxlint/binding-linux-x64-gnu@1.70.0", "", { "os": "linux", "cpu": "x64" }, "sha512-tap04CsHYOl0nSAQJfPNIuBxqEPB2HnhQqwaOXLg1jnp2XfRo8Fa814dA4QC4zpvTWXCjAAaCY1W5LOORkEQuQ=="], + + "vite-plus/oxlint/@oxlint/binding-linux-x64-musl": ["@oxlint/binding-linux-x64-musl@1.70.0", "", { "os": "linux", "cpu": "x64" }, "sha512-hzJa/WgvtJpbBD9rgfy0qe+MjbxOXNUT0bfR1S6EQQzfTtBFA9xg5q8KSwRrQ2QfSS+TaP4j+4mVPQrfNc6UNg=="], + + "vite-plus/oxlint/@oxlint/binding-openharmony-arm64": ["@oxlint/binding-openharmony-arm64@1.70.0", "", { "os": "none", "cpu": "arm64" }, "sha512-xbsaNSNzVSnaJACCUYr1HQMyY/Q/Q1LkePmHG3UvZPvGCYGNxrsZp9OmtA6ick8xH47ltRRbRrPCM1YXYcyC+A=="], + + "vite-plus/oxlint/@oxlint/binding-win32-arm64-msvc": ["@oxlint/binding-win32-arm64-msvc@1.70.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-icAEsUI7JbW1TMRdEXV83mVAInhRVQYuuAlPpxdGwJ95chNdnCzjloRW8GglT0WvzOEZSio6fnYSk2DJ2Hv7LQ=="], + + "vite-plus/oxlint/@oxlint/binding-win32-ia32-msvc": ["@oxlint/binding-win32-ia32-msvc@1.70.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-FHMSWbVsPVs/f+Jcl04ws4JJ2wUnauyTzlpxWRG/lSO/8GpX08Fo2gQZqdA6CrRFI+zvkxl+N/KwJGWfUwYVZA=="], + + "vite-plus/oxlint/@oxlint/binding-win32-x64-msvc": ["@oxlint/binding-win32-x64-msvc@1.70.0", "", { "os": "win32", "cpu": "x64" }, "sha512-ptOlKwCz7n4AKs5VweMqG6DAg677FmKOK+vBkkL9DMNgFATIQ+upqUYBTOEwRQyRAx1ncGlPlXleV2hIcm3z4g=="], + "wrap-ansi/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], } } diff --git a/package.json b/package.json index a506c12..2e7c9bc 100644 --- a/package.json +++ b/package.json @@ -12,9 +12,9 @@ "scripts": { "build": "tsc", "typecheck": "tsc --noEmit", - "lint": "oxlint", - "format": "oxfmt .", - "format:check": "oxfmt --check .", + "lint": "vp lint", + "format": "vp fmt --write", + "format:check": "vp fmt --check", "test": "bun src/dev/run-tests.ts", "test:e2e": "bun test --cwd test/e2e langfuse-mocked-agent.e2e.spec.ts", "test:e2e:llm": "bun test --cwd test/e2e langfuse-llm-smoke.e2e.spec.ts", @@ -45,7 +45,8 @@ "@types/node": "^22.0.0", "oxfmt": "^0.51.0", "oxlint": "^1.65.0", - "typescript": "^5.7.0" + "typescript": "^5.7.0", + "vite-plus": "^0.2.1" }, "trustedDependencies": [ "@ast-grep/cli" diff --git a/podman-compose.yaml b/podman-compose.yaml index 4c34966..1c16dd8 100644 --- a/podman-compose.yaml +++ b/podman-compose.yaml @@ -91,7 +91,7 @@ services: clickhouse: image: docker.io/clickhouse/clickhouse-server restart: always - user: "101:101" + user: '101:101' environment: CLICKHOUSE_DB: default CLICKHOUSE_USER: ${CLICKHOUSE_USER:-clickhouse} @@ -125,7 +125,7 @@ services: volumes: - langfuse_minio_data:/data healthcheck: - test: ["CMD", "mc", "ready", "local"] + test: ['CMD', 'mc', 'ready', 'local'] interval: 1s timeout: 5s retries: 5 @@ -142,7 +142,7 @@ services: volumes: - langfuse_redis_data:/data healthcheck: - test: ["CMD", "redis-cli", "ping"] + test: ['CMD', 'redis-cli', 'ping'] interval: 3s timeout: 10s retries: 10 @@ -151,7 +151,7 @@ services: image: docker.io/postgres:${POSTGRES_VERSION:-17} restart: always healthcheck: - test: ["CMD-SHELL", "pg_isready -U postgres"] + test: ['CMD-SHELL', 'pg_isready -U postgres'] interval: 3s timeout: 3s retries: 10 diff --git a/src/commands/onboard.ts b/src/commands/onboard.ts index 0377c43..0d709c2 100644 --- a/src/commands/onboard.ts +++ b/src/commands/onboard.ts @@ -286,9 +286,7 @@ async function loadOrCreateManifest(caseRoot: string): Promise