From 63870aa251550a3600a95847ce4a2149fa51aca4 Mon Sep 17 00:00:00 2001 From: Bailey Dixon Date: Mon, 13 Jul 2026 09:41:05 -0400 Subject: [PATCH 1/3] feat: carry plan context into issue and agent flows --- DEVLOG.md | 44 ++ docs/agents/engagement-modes.md | 58 +- docs/concepts/orchestration.md | 40 +- docs/concepts/primitives.md | 66 ++- docs/reference/mcp.md | 379 ++++++------ src/app/(app)/w/[slug]/issues/[id]/page.tsx | 15 +- src/components/issue-detail/issue-main.tsx | 12 +- .../issue-detail/plan-step-context-card.tsx | 348 +++++++++++ .../relations/issue-relations-graph.tsx | 79 ++- src/server/audit.ts | 87 ++- src/server/routers/__tests__/apiKey.test.ts | 62 ++ .../routers/__tests__/execution-plan.test.ts | 72 ++- src/server/routers/__tests__/issue.test.ts | 96 ++- src/server/routers/__tests__/relation.test.ts | 70 +++ src/server/routers/execution-plan.ts | 114 +++- src/server/routers/issue.ts | 211 ++++--- src/server/routers/relation.ts | 244 +++++--- .../__tests__/agent-dispatch-inbox.test.ts | 159 +++++ src/server/services/__tests__/mcp.test.ts | 138 +++++ .../__tests__/orchestration-context.test.ts | 222 +++++++ .../services/__tests__/orchestration.test.ts | 330 ++++++++++- .../services/__tests__/run-dispatcher.test.ts | 86 ++- src/server/services/agent-dispatch-inbox.ts | 84 ++- src/server/services/api-key-auth.ts | 34 +- .../services/dispatch/run-dispatcher.ts | 106 +++- src/server/services/execution-plan-service.ts | 43 ++ src/server/services/execution-step-runtime.ts | 55 ++ src/server/services/mcp.ts | 39 +- src/server/services/orchestration-context.ts | 558 ++++++++++++++++++ src/server/services/orchestration-service.ts | 120 +++- 30 files changed, 3377 insertions(+), 594 deletions(-) create mode 100644 src/components/issue-detail/plan-step-context-card.tsx create mode 100644 src/server/services/__tests__/orchestration-context.test.ts create mode 100644 src/server/services/execution-step-runtime.ts create mode 100644 src/server/services/orchestration-context.ts diff --git a/DEVLOG.md b/DEVLOG.md index 27fe1407..6a47c73a 100644 --- a/DEVLOG.md +++ b/DEVLOG.md @@ -12194,3 +12194,47 @@ reconciliation mutation occurred: PostgreSQL's descending sort places null the newer COMPLETED run. v0.8.2 orders attempts by `lastEventAt` instead (set on every run state) and adds the exact stalled-then-completed history to the watchdog regression fixture. + +## 2026-07-13 — Plan-linked issue context and orchestration integrity + +Inspected the current `forge` and `forge-worker` production services, the live +Rich Rendering goal/plan, current source, and orchestration references. Live +data confirmed that materialized plan-step issues retained only a small plan +backlink: four DONE steps still had BACKLOG issues, dependency edges were not +visible on issue Relations, runtime prompts omitted Goal/Plan/DAG context, and +terminal STALLED runs could be selected as an agent's current run. + +- Added a bounded shared orchestration-context builder used by provider runtime + starts/resumes, `agent.inbox.list`, and `agent.context.bundle`. It includes + Goal/Plan/Step contracts, retry feedback, dependency/dependent issue links, + completed worker evidence, and non-excluded ContextSet refs without guessing + a step for multi-step plan-anchor issues. +- Materialized issue detail now renders a primary Goal → Plan → Step context + card with success criteria, instructions, completion/verification contract, + feedback, sibling progress, and navigable dependencies. The Relations graph + derives plan dependency edges from `dependsOnStepIds` without persisting + duplicate `IssueRelation` rows. +- Runtime-only planners with a linked Goal issue now open durable DISCUSS runs + and receive the decompose prompt; unanchored runtime planners are reported as + non-dispatchable instead of queueing a webhook guaranteed to dead-letter. + Worker/reviewer evidence and the planner trigger survive runtime handoff. +- Ordinary assignment of a materialized issue synchronizes its intended step + worker, but TODO/BLOCKED/REVIEW or non-running-plan steps are scheduled rather + than dispatched. Only readiness can open the step-bound execution run. + Both inbox delivery and the background runtime scanner honor that gate; + provider/output start now audits READY → RUNNING. +- Current-run resolution is limited to ACTIVE/WAITING and the linked agent. + Verdicts require a REVIEW step on the active RUNNING attempt; agent verdicts + additionally require that agent's active REVIEW run. CANCELED steps no longer + count toward Goal achievement. +- Added safety guards around live-plan archive/delete and step removal, routed + RUNNING transitions through activation, validated updated ContextSet scope, + and fixed initiative-only API-key narrowing for issue reads/list filters. +- Updated orchestration, engagement-mode, primitive, and MCP reference docs to + match the delivered behavior and to state honestly that crew `maxParallel` + remains informational rather than enforced. + +Verification: lint and typecheck passed; 1,126 unit/integration tests passed +(one live-only test skipped); and a forced fresh production build plus all 37 +Playwright tests passed. This change is local only; production was inspected +read-only and was not deployed or mutated. diff --git a/docs/agents/engagement-modes.md b/docs/agents/engagement-modes.md index 80925477..19223b60 100644 --- a/docs/agents/engagement-modes.md +++ b/docs/agents/engagement-modes.md @@ -1,16 +1,16 @@ # Engagement Modes -> Set a mode when you assign or mention an agent to say *how far* you want the +> Set a mode when you assign or mention an agent to say _how far_ you want the > work taken. Configure the defaults in **Settings → Dispatch Rules**. The > resolved mode rides on the `AgentRun` and shows as a chip on the run strip and > in Mission Control. ## Why modes exist -When you hand an agent some work, the agent knows *what* you wrote — never *how -far* you want it taken. Without a mode, every dispatch surface tells the agent -essentially the same thing: *"You're assigned this issue. Work it, then -summarise."* So the agent picks the safest-for-it reading: **do the whole +When you hand an agent some work, the agent knows _what_ you wrote — never _how +far_ you want it taken. Without a mode, every dispatch surface tells the agent +essentially the same thing: _"You're assigned this issue. Work it, then +summarise."_ So the agent picks the safest-for-it reading: **do the whole thing, to its own interpretation, right now.** That guess is wrong as often as it's right. Sometimes you want a full build. @@ -27,12 +27,12 @@ run the completion gates. ## The four modes -| Mode | Intent | What the agent may do | "Done" means | -|------|--------|------------------------|--------------| -| **Execute** | Do the work to completion | Edit code, transition status, open PRs/artifacts | Issue moved to review/done; `runs.complete` with artifacts; honors `artifactRequired` + `verificationChecklist` | -| **Research** | Investigate & report | Read, search, run read-only tools | A findings comment **with a confidence flag**; issue status untouched | -| **Review** | Critique existing work | Inspect a PR/diff/issue, judge it | A verdict (approve / request changes) | -| **Discuss** | Answer / weigh in | Reply in-thread, ask questions | A reply comment; no work product | +| Mode | Intent | What the agent may do | "Done" means | +| ------------ | ------------------------- | ------------------------------------------------ | --------------------------------------------------------------------------------------------------------------- | +| **Execute** | Do the work to completion | Edit code, transition status, open PRs/artifacts | Issue moved to review/done; `runs.complete` with artifacts; honors `artifactRequired` + `verificationChecklist` | +| **Research** | Investigate & report | Read, search, run read-only tools | A findings comment **with a confidence flag**; issue status untouched | +| **Review** | Critique existing work | Inspect a PR/diff/issue, judge it | A verdict (approve / request changes) | +| **Discuss** | Answer / weigh in | Reply in-thread, ask questions | A reply comment; no work product | These map onto primitives Forge already has — they don't add a parallel universe. **Research** leans on the existing confidence flag; **Review** leans @@ -43,7 +43,7 @@ on review gates / action requests; **Execute** leans on `expectedOutput` / "Do the work but stop before the irreversible step" is **Execute + an approval/review gate**, not a fifth mode. Set `requireApprovalBeforeStart` or open a review gate on the deploy step. Deploy-safety stays orthogonal to mode, -so it composes with *all* modes. +so it composes with _all_ modes. ::: ## Where the mode lives @@ -55,22 +55,22 @@ prompt, and read by the watchdog, the auto-transition logic, and Mission Control. This is distinct from `Workspace.autoDispatchMode` (ROUND_ROBIN / -CAPABILITY_MATCH / … — which decides *which agent gets picked*). Engagement mode -is *what the picked agent is asked to do*. Different axis. +CAPABILITY_MATCH / … — which decides _which agent gets picked_). Engagement mode +is _what the picked agent is asked to do_. Different axis. ## Defaults by surface -Defaults depend on *how* the work was dispatched — "mentions talk, assignments +Defaults depend on _how_ the work was dispatched — "mentions talk, assignments act": -| Surface | Entry point | Default mode | Why | -|---------|-------------|--------------|-----| -| **Assignment** | assign an agent → `AGENT_ASSIGNED` | **Execute** | Assigning *is* handing over the work | -| **Queue / auto-dispatch** | queued issue + auto-dispatch | **Execute** | Queued = ready to be worked | -| **@-mention in a comment** | mention in a comment | **policy-driven** (below) | A mention is a tap on the shoulder, not a work order | -| **Chat** | Mission Control chat | **Discuss** | Chat is conversational by nature | -| **Goal / plan step / crew** | plan or crew dispatch | **the step's mode**, else **Execute** | A plan declares its own intent per step | -| **Watcher fan-out** | stalled / priority change / … | **awareness only** | Watchers observe; they don't get a work order | +| Surface | Entry point | Default mode | Why | +| --------------------------- | ---------------------------------- | ---------------------------------------------- | --------------------------------------------------------------------------------------------- | +| **Assignment** | assign an agent → `AGENT_ASSIGNED` | **Execute** | Assigning _is_ handing over the work | +| **Queue / auto-dispatch** | queued issue + auto-dispatch | **Execute** | Queued = ready to be worked | +| **@-mention in a comment** | mention in a comment | **policy-driven** (below) | A mention is a tap on the shoulder, not a work order | +| **Chat** | Mission Control chat | **Discuss** | Chat is conversational by nature | +| **Goal / plan step / crew** | plan or crew dispatch | **Execute** for workers; **Review** for judges | Worker and judge roles define the run intent; `ExecutionStep` does not store a separate mode. | +| **Watcher fan-out** | stalled / priority change / … | **awareness only** | Watchers observe; they don't get a work order | Every default is overridable per dispatch, and the assignment + mention defaults are configurable per workspace. @@ -80,7 +80,7 @@ are configurable per workspace. A mention is the one surface where teams genuinely disagree, so it's a workspace policy — `Workspace.mentionEngagementPolicy`: -- **Infer** *(default)* — no explicit marker → the agent infers intent from the +- **Infer** _(default)_ — no explicit marker → the agent infers intent from the comment, **and if it's ambiguous, asks before doing anything irreversible.** Maximum fluidity; the "ask first" clause is the safety net. This is the direct antidote to "it assumed I wanted it fully built." @@ -109,11 +109,11 @@ Each mode has a canonical instruction block injected into the agent's turn (via `RunInput.instructions`, prepended in the dispatcher) and surfaced in `agent.context.bundle` so pull-based agents see it too. For example: -- **Research:** *"This is a research pass. Investigate and report your findings +- **Research:** _"This is a research pass. Investigate and report your findings as a comment with a confidence flag. Do not modify code, move the issue, or - open a PR. If you believe execution is warranted, say so and stop."* -- **Execute:** *"Take this to completion. The definition of done is in - `expectedOutput`; verify against `verificationChecklist`."* (+ artifact / + open a PR. If you believe execution is warranted, say so and stop."_ +- **Execute:** _"Take this to completion. The definition of done is in + `expectedOutput`; verify against `verificationChecklist`."_ (+ artifact / approval clauses when set.) Forge also injects the shared run protocol, including a contract version: @@ -196,7 +196,7 @@ is reused everywhere, with the warm-earthy tokens: - [Agents overview](/agents/overview.html) — the two ways to run agent work (direct dispatch vs. Goal orchestration), both riding on `AgentRun`. - [Auto-dispatch](/agents/auto-dispatch.html) — queued issues default to - Execute; how an agent gets *picked* (a different axis from mode). + Execute; how an agent gets _picked_ (a different axis from mode). - [Dispatch Rules](/agents/dispatch-rules.html) — where the mode defaults and mention policy are configured. - [SLAs & Watchdogs](/agents/slas-and-watchdogs.html) — why a Research run isn't diff --git a/docs/concepts/orchestration.md b/docs/concepts/orchestration.md index 0c8c3a0f..046180db 100644 --- a/docs/concepts/orchestration.md +++ b/docs/concepts/orchestration.md @@ -63,7 +63,7 @@ and time caps you set. A Goal is one of [**two ways to run agent work**](/agents/overview.html) — the autonomous one. The other is direct dispatch (assign an agent to an issue). The -two are different ways to *start* work, but they share the same observable +two are different ways to _start_ work, but they share the same observable substrate, so orchestrated work isn't a black box: - **Each step that starts running opens a real `AgentRun`.** When a step @@ -84,8 +84,13 @@ substrate, so orchestrated work isn't a black box: one issue. So planning, issues, and runs are one connected graph: a Goal owns a plan, a -plan's steps can become issues, and running a step opens a run — all linked, all -visible in the same surfaces. +plan's steps can become issues, and running a step opens a run. A materialized +issue renders its Goal → Plan → Step contract and sibling progress in the main +issue column; its Relations graph derives dependency edges from +`dependsOnStepIds` without duplicating `IssueRelation` rows. Agent runtime +prompts, `agent.inbox.list`, and `agent.context.bundle` use the same structured +orchestration context, including retry feedback and completed dependency +evidence. ## The loop @@ -98,7 +103,7 @@ visible in the same surfaces. └────┬────┘ │ plans.decompose (picks PLANNER; creates DRAFT plan) ▼ - ┌──────────┐ PLANNER dispatched via webhook + event + ┌──────────┐ PLANNER dispatched via webhook or runtime run │ PLANNING │──────────────────────────────────────────┐ └────┬─────┘ │ │ plans.addSteps (PLANNER fills the DRAFT plan) │ @@ -163,9 +168,12 @@ transactional with the status change. ### Worker dispatch payload -A step entering `READY` emits `EXECUTION_STEP_READY` and queues a webhook -delivery to the resolved worker (explicit `assignedAgentId`, else the -crew's `WORKER`). The event payload carries: +A step entering `READY` emits `EXECUTION_STEP_READY` and dispatches the +resolved worker (explicit `assignedAgentId`, else the crew's `WORKER`). A +webhook worker receives the event payload below. A provider-backed runtime +receives the same step fields plus a hydrated Goal/Plan/DAG context block in +its prompt; pull-based agents see the structured form in `agent.inbox.list` +and `agent.context.bundle`. ```jsonc { @@ -174,11 +182,13 @@ crew's `WORKER`). The event payload carries: "title": "...", "body": "...", "expectedOutput": "...", - "verification": [ /* completion-contract checklist */ ], - "contextSetId": "...", // the plan's shared ContextSet + "verification": [ + /* completion-contract checklist */ + ], + "contextSetId": "...", // the plan's shared ContextSet "assignedAgentId": "...", - "lastFeedback": "...", // populated on a retry dispatch - "retryCount": 1 + "lastFeedback": "...", // populated on a retry dispatch + "retryCount": 1, } ``` @@ -260,12 +270,14 @@ agentCrews.archive({ id }) ## Crews -A **crew** is a reusable, standing team — distinct from a *plan run*, +A **crew** is a reusable, standing team — distinct from a _plan run_, which is a single decompose-and-execute pass against one goal. The crew is the roster (who can plan / work / review); the plan run is the work. One crew runs many goals over its lifetime; each goal/plan points back at -its crew via `Goal.crewId` / `ExecutionPlan.crewId`. `maxParallel` caps -how many of the crew's steps run simultaneously. +its crew via `Goal.crewId` / `ExecutionPlan.crewId`. `maxParallel` is stored +and displayed as the crew's intended concurrency ceiling; readiness dispatch +does not enforce that ceiling yet, so operators should not treat it as a hard +runtime limit. The loop resolves roles from crew membership: the PLANNER decomposes, a WORKER executes each READY step, a REVIEWER judges steps that enter diff --git a/docs/concepts/primitives.md b/docs/concepts/primitives.md index 0309b0ea..696bb067 100644 --- a/docs/concepts/primitives.md +++ b/docs/concepts/primitives.md @@ -23,14 +23,14 @@ tracking toggle. Bailey's standing rule is that workspace-level values should be columns here, not constants in handlers. See [Settings → Workspace](/guide/settings.html) for the full set. -| Field of note | Meaning | -| --- | --- | -| `key` | Immutable issue prefix. | -| `cycleLengthDays` | Default sprint length (default 7). | -| `cycleCooldownDays` | Gap between sprints (default 0). | -| `attachmentQuotaMb` | Per-workspace attachment cap (default 1024). | -| `timeTrackingEnabled` | Master toggle for `TimeEntry`. | -| `autoDispatch`, `autoDispatchMode` | Dispatcher config. | +| Field of note | Meaning | +| ---------------------------------- | -------------------------------------------- | +| `key` | Immutable issue prefix. | +| `cycleLengthDays` | Default sprint length (default 7). | +| `cycleCooldownDays` | Gap between sprints (default 0). | +| `attachmentQuotaMb` | Per-workspace attachment cap (default 1024). | +| `timeTrackingEnabled` | Master toggle for `TimeEntry`. | +| `autoDispatch`, `autoDispatchMode` | Dispatcher config. | ## Project @@ -56,16 +56,16 @@ they reach them through their project. The unit of work. -| Field of note | Meaning | -| --- | --- | -| `key` | Display id (e.g. `WRK-42`). Workspace key + auto-incremented number. | -| `priority` | `NONE`, `LOW`, `MEDIUM`, `HIGH`, `URGENT`. | -| `status` | One of the workspace's `Status` rows; default categories `BACKLOG`, `TODO`, `IN_PROGRESS`, `IN_REVIEW`, `DONE`, `CANCELED`. | -| `claimedById` | Human user owning the issue (nullable). | -| `assignedAgentId` | Agent doing the work (nullable). | -| `projectId` | Optional project membership. | -| `cycleId` | Optional sprint membership. | -| `slaMinutes` | Per-issue SLA for the breach watchdog. | +| Field of note | Meaning | +| ----------------- | --------------------------------------------------------------------------------------------------------------------------- | +| `key` | Display id (e.g. `WRK-42`). Workspace key + auto-incremented number. | +| `priority` | `NONE`, `LOW`, `MEDIUM`, `HIGH`, `URGENT`. | +| `status` | One of the workspace's `Status` rows; default categories `BACKLOG`, `TODO`, `IN_PROGRESS`, `IN_REVIEW`, `DONE`, `CANCELED`. | +| `claimedById` | Human user owning the issue (nullable). | +| `assignedAgentId` | Agent doing the work (nullable). | +| `projectId` | Optional project membership. | +| `cycleId` | Optional sprint membership. | +| `slaMinutes` | Per-issue SLA for the breach watchdog. | The two assignment slots — `claimedById` and `assignedAgentId` — coexist intentionally. See [Agents → Overview](/agents/overview.html). @@ -121,11 +121,11 @@ deliberate. Directed, typed link between two issues. -| Field | Meaning | -| --- | --- | -| `fromIssueId` | The source issue. | -| `toIssueId` | The target issue. | -| `kind` | One of `BLOCKS`, `BLOCKED_BY`, `DUPLICATES`, `DUPLICATED_BY`, `RELATED`, `PARENT_OF`, `CHILD_OF`. | +| Field | Meaning | +| ------------- | ------------------------------------------------------------------------------------------------- | +| `fromIssueId` | The source issue. | +| `toIssueId` | The target issue. | +| `kind` | One of `BLOCKS`, `BLOCKED_BY`, `DUPLICATES`, `DUPLICATED_BY`, `RELATED`, `PARENT_OF`, `CHILD_OF`. | Relations are cascade-deleted from either end — when you delete an issue, all relations involving it disappear. Pairs of inverse kinds @@ -146,12 +146,12 @@ namespace. Polymorphic via `targetType` + `targetId`. -| Field | Meaning | -| --- | --- | -| `targetType` | `issue`, `comment`, `project`, `initiative`, ... | -| `targetId` | The id of the target row. | -| `objectKey` | MinIO object key. | -| `mimeType`, `sizeBytes`, `name` | Standard file metadata. | +| Field | Meaning | +| ------------------------------- | ------------------------------------------------ | +| `targetType` | `issue`, `comment`, `project`, `initiative`, ... | +| `targetId` | The id of the target row. | +| `objectKey` | MinIO object key. | +| `mimeType`, `sizeBytes`, `name` | Standard file metadata. | Attachments live in MinIO behind presigned URLs. The init/finalize/ download/delete pattern is in the `attachments.*` MCP namespace and the @@ -215,7 +215,7 @@ ordering. See [Saved Views](/guide/saved-views.html). Per-(workspace, user) markdown scratchpad. Surfaced via the dashboard `` and the `notes.*` MCP namespace. Pinned notes float to the top; `archivedAt` is the soft-delete path. Notes are -**personal** — agents leave notes for *themselves*, not for the +**personal** — agents leave notes for _themselves_, not for the operator. The `note.convertToIssue` mutation spawns a real Issue from a note's body without auto-archiving the source. See [Quick Notes](/guide/quick-notes.html). @@ -299,8 +299,10 @@ other side, AgentRun gains `producedArtifactIds`, `verificationResult`, and `followUps` so the final response is structured. -MCP `agent.context.bundle` for issues now exposes a -`completionContract` block; MCP `runs.complete` is the structured +MCP `agent.context.bundle` for issues exposes both a `completionContract` +block and, for plan-linked issues, an `orchestrationContext` block with the +Goal, Plan, current Step, dependencies, retry feedback, source-run evidence, +and non-excluded ContextSet references. MCP `runs.complete` is the structured submission tool. ### ExecutionPlan + ExecutionStep diff --git a/docs/reference/mcp.md b/docs/reference/mcp.md index 39d91b96..63e2253a 100644 --- a/docs/reference/mcp.md +++ b/docs/reference/mcp.md @@ -92,7 +92,7 @@ Both compose with **scope pruning**: whenever a key is presented, `tools/list` drops any tool the key couldn't call anyway (same check as `tools/call`). A FULL-scope key sees everything within the selected profile; a narrowly-scoped key sees less. `tools/call` is never restricted by these selectors — they only -shape what's *advertised*, so full capability stays reachable for any tool the +shape what's _advertised_, so full capability stays reachable for any tool the caller names and is authorized for. ``` @@ -125,22 +125,22 @@ long tail without advertising every direct tool: ### `issues` -| Tool | Summary | -|--------------|------------------------------------------------------------------------------| -| `list` | Paged list with filters: `status`, `priority`, `projectId`, `assignedAgentId`, `labelIds[]`, `queued`. | -| `get` | Fetch by `id`. Optional `include` hydrates description / comments / attachments / relations / currentRun / labels in one round-trip. | -| `create` | `{ title, description?, projectId?, priority?, statusId?, labelIds? }` → full issue. | -| `queue` | Set `queued: true`. Dispatcher only sees queued + unassigned issues. | -| `transition` | Change status by `statusId`. | -| `claim` | Set human `claimedById = caller`. Sets soft expiry `claimExpiresAt`. | -| `release` | Clear human claim. | -| `assign` | Assign agent. Identify by `agentId` or `profileKey`. | -| `reassign` | Atomic handoff — see below. | -| `assigned` | List issues assigned to an agent. | -| `watch` | Add the caller as a watcher of `issueId`. Idempotent. Identity is inferred from the API key (`linkedAgentId` → agent-watch, otherwise user-watch). | -| `unwatch` | Remove the caller's watch on `issueId`. | -| `listWatchers` | List watchers of an issue with user/agent identity fields. | -| `listWatching` | List issues the caller is currently watching. | +| Tool | Summary | +| -------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- | +| `list` | Paged list with filters: `status`, `priority`, `projectId`, `assignedAgentId`, `labelIds[]`, `queued`. | +| `get` | Fetch by `id`. Optional `include` hydrates description / comments / attachments / relations / currentRun / labels in one round-trip. | +| `create` | `{ title, description?, projectId?, priority?, statusId?, labelIds? }` → full issue. | +| `queue` | Set `queued: true`. Dispatcher only sees queued + unassigned issues. | +| `transition` | Change status by `statusId`. | +| `claim` | Set human `claimedById = caller`. Sets soft expiry `claimExpiresAt`. | +| `release` | Clear human claim. | +| `assign` | Assign agent. Identify by `agentId` or `profileKey`. | +| `reassign` | Atomic handoff — see below. | +| `assigned` | List issues assigned to an agent. | +| `watch` | Add the caller as a watcher of `issueId`. Idempotent. Identity is inferred from the API key (`linkedAgentId` → agent-watch, otherwise user-watch). | +| `unwatch` | Remove the caller's watch on `issueId`. | +| `listWatchers` | List watchers of an issue with user/agent identity fields. | +| `listWatching` | List issues the caller is currently watching. | Agent-linked keys are mode-gated for issue state. When the calling agent has an active/waiting `AgentRun` on the issue in **RESEARCH**, **REVIEW**, or @@ -171,10 +171,10 @@ to hydrate optional sections in one round-trip: "id": "cle9k...", "include": { "description": true, - "comments": true, // or { "limit": 50 } — max 100, default 20 + "comments": true, // or { "limit": 50 } — max 100, default 20 "attachments": true, "relations": true, - "currentRun": true, // most recent non-terminal AgentRun + "currentRun": true, // most recent non-terminal AgentRun "labels": true } } @@ -185,13 +185,13 @@ or `ABANDONED` (so `ACTIVE` and `STALLED` qualify). ### `comments` -| Tool | Summary | -|------------------|------------------------------------------------------------------------| -| `create` | Post a comment on an issue. Optional `confidence: "LOW" \| "MEDIUM" \| "HIGH"` annotates how much to scrutinize the claim (only rendered for agent-authored rows). Optional `actionRequest` bundles a recommendation card in the same call; pass `actionRequest.options[]` to make that card a multi-vote poll. | -| `update` | Edit an issue comment body. Input `{ id, body }`; authors can edit their own comments, while workspace OWNER/ADMIN or ADMIN-scoped MCP keys can override. Preserves prior bodies in `revisions`, sets `editedAt`, and emits normal comment audit/activity. | -| `delete` | Soft-delete/archive an issue comment. Input `{ id }`; same author/admin authorization as `update`. Sets `deletedAt`, keeps the row for audit/history, and removes it from agent-facing comment lists/context bundles. | -| `upsertStatus` | Idempotent rolling STATUS comment for the calling agent's run. | -| `list` | Paginated history. `{ issueId, before?, limit? = 50 (max 200) }` — newest first, hides soft-deleted, includes `author` + `authoringAgent`. Scope: `READ_ISSUES`. | +| Tool | Summary | +| -------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `create` | Post a comment on an issue. Optional `confidence: "LOW" \| "MEDIUM" \| "HIGH"` annotates how much to scrutinize the claim (only rendered for agent-authored rows). Optional `actionRequest` bundles a recommendation card in the same call; pass `actionRequest.options[]` to make that card a multi-vote poll. | +| `update` | Edit an issue comment body. Input `{ id, body }`; authors can edit their own comments, while workspace OWNER/ADMIN or ADMIN-scoped MCP keys can override. Preserves prior bodies in `revisions`, sets `editedAt`, and emits normal comment audit/activity. | +| `delete` | Soft-delete/archive an issue comment. Input `{ id }`; same author/admin authorization as `update`. Sets `deletedAt`, keeps the row for audit/history, and removes it from agent-facing comment lists/context bundles. | +| `upsertStatus` | Idempotent rolling STATUS comment for the calling agent's run. | +| `list` | Paginated history. `{ issueId, before?, limit? = 50 (max 200) }` — newest first, hides soft-deleted, includes `author` + `authoringAgent`. Scope: `READ_ISSUES`. | **`confidence`** is persisted regardless of caller, but the UI only renders the chip for agent-authored comments. Use it from agents to @@ -213,48 +213,48 @@ capped at 20 entries — oldest dropped first). Read history via ### `projects` -| Tool | Summary | -|-----------|----------------------------------------| -| `list` | List workspace projects. | -| `create` | Create a project. | -| `update` | Update name/description/initiativeId. | -| `archive` | Archive (soft-delete). | +| Tool | Summary | +| --------- | ------------------------------------- | +| `list` | List workspace projects. | +| `create` | Create a project. | +| `update` | Update name/description/initiativeId. | +| `archive` | Archive (soft-delete). | ### `cycles` > The product label is **"Sprints"**, but the namespace, route, and data > model stay `cycle*` — only display strings were renamed. -| Tool | Summary | -|---------------|--------------------------------------------------------| -| `list` | List cycles for the workspace. | -| `get` | Fetch by id. | -| `current` | Return the active cycle (or `null`). | -| `create` | Create a cycle. | -| `update` | Update name/dates. | -| `plan` | Bulk add/remove issues for upcoming cycle. | -| `rollover` | Move incomplete issues from current to next cycle. | -| `addIssue` | Add a single issue to a cycle. | -| `removeIssue` | Remove a single issue from a cycle. | +| Tool | Summary | +| ------------- | -------------------------------------------------- | +| `list` | List cycles for the workspace. | +| `get` | Fetch by id. | +| `current` | Return the active cycle (or `null`). | +| `create` | Create a cycle. | +| `update` | Update name/dates. | +| `plan` | Bulk add/remove issues for upcoming cycle. | +| `rollover` | Move incomplete issues from current to next cycle. | +| `addIssue` | Add a single issue to a cycle. | +| `removeIssue` | Remove a single issue from a cycle. | ### `initiatives` -| Tool | Summary | -|-----------------|--------------------------------------------------| -| `list` | List initiatives. | -| `get` | Fetch by id. | -| `create` | Create initiative. | -| `update` | Update name/description. | -| `linkProject` | Attach a project to this initiative. | -| `unlinkProject` | Detach a project (sets `initiativeId = null`). | +| Tool | Summary | +| --------------- | ---------------------------------------------- | +| `list` | List initiatives. | +| `get` | Fetch by id. | +| `create` | Create initiative. | +| `update` | Update name/description. | +| `linkProject` | Attach a project to this initiative. | +| `unlinkProject` | Detach a project (sets `initiativeId = null`). | ### `relations` -| Tool | Summary | -|----------------|------------------------------------------------------------------| -| `add` | `{ fromIssueId, toIssueId, kind }` — directed link. | -| `remove` | Remove a relation by id. | -| `listForIssue` | List both inbound and outbound relations for an issue. | +| Tool | Summary | +| -------------- | ------------------------------------------------------ | +| `add` | `{ fromIssueId, toIssueId, kind }` — directed link. | +| `remove` | Remove a relation by id. | +| `listForIssue` | List both inbound and outbound relations for an issue. | `kind` is one of `BLOCKS`, `BLOCKED_BY`, `DUPLICATES`, `RELATES_TO`. @@ -265,14 +265,14 @@ workspace's active `ConnectionMapping(kind="repo")` and only mutate Forge state. They do not comment on GitHub, close GitHub issues, edit PRs, or apply GitHub labels. -| Tool | Summary | -|---|---| -| `parseUrl` | Parse a GitHub issue/PR URL into `{ owner, repo, repoFullName, type, number, url }`. Scope: `READ_ISSUES`. | -| `listLinked` | List GitHub resources linked to a Forge issue. Scope: `READ_ISSUES`. | -| `link` | Link a GitHub issue or PR URL to an existing Forge issue with `kind: SOURCE \| IMPLEMENTS \| REVIEWS \| RELATES_TO`. Scope: `WRITE_ISSUES`. | -| `importIssue` | Create or return the Forge issue sourced from a mapped GitHub issue number. Scope: `WRITE_ISSUES`. | -| `sync` | Refresh a linked GitHub resource snapshot and apply configured Forge status/title rules. Scope: `WRITE_ISSUES`. | -| `search` | Search issues/PRs within a mapped repository. Scope: `READ_ISSUES`. | +| Tool | Summary | +| ------------- | ------------------------------------------------------------------------------------------------------------------------------------------- | +| `parseUrl` | Parse a GitHub issue/PR URL into `{ owner, repo, repoFullName, type, number, url }`. Scope: `READ_ISSUES`. | +| `listLinked` | List GitHub resources linked to a Forge issue. Scope: `READ_ISSUES`. | +| `link` | Link a GitHub issue or PR URL to an existing Forge issue with `kind: SOURCE \| IMPLEMENTS \| REVIEWS \| RELATES_TO`. Scope: `WRITE_ISSUES`. | +| `importIssue` | Create or return the Forge issue sourced from a mapped GitHub issue number. Scope: `WRITE_ISSUES`. | +| `sync` | Refresh a linked GitHub resource snapshot and apply configured Forge status/title rules. Scope: `WRITE_ISSUES`. | +| `search` | Search issues/PRs within a mapped repository. Scope: `READ_ISSUES`. | `importIssue` input accepts `{ mappingId?, repoFullName?, number, projectId?, labelIds?, queue? }`; pass either `mappingId` or a repo full name that has an @@ -284,26 +284,26 @@ already imported. > Gated on `Workspace.timeTrackingEnabled`. All tools `403` when disabled. -| Tool | Summary | -|-----------|----------------------------------------------------------| -| `start` | Start a running timer on an issue. | -| `stop` | Stop the caller's running timer. | -| `log` | Manually log a `{ issueId, durationMin }` entry. | -| `list` | List entries (filter by user/issue/date range). | -| `summary` | Aggregated totals by user/issue/day. | -| `running` | Return the caller's running timer (or `null`). | +| Tool | Summary | +| --------- | ------------------------------------------------ | +| `start` | Start a running timer on an issue. | +| `stop` | Stop the caller's running timer. | +| `log` | Manually log a `{ issueId, durationMin }` entry. | +| `list` | List entries (filter by user/issue/date range). | +| `summary` | Aggregated totals by user/issue/day. | +| `running` | Return the caller's running timer (or `null`). | ### `attachments` -| Tool | Summary | -|------------------|----------------------------------------------------------------------| -| `initUpload` | Get a presigned MinIO/S3 PUT URL. Returns `{ uploadUrl, key }`. | -| `finalize` | Register the uploaded blob as an Attachment row. | -| `attachLink` | Record an external URL (LINK kind). No bytes uploaded. | -| `list` | List attachments for a `(targetType, targetId)` pair. | -| `getDownloadUrl` | Get a presigned GET URL for browser/agent download. | -| `getInline` | Server-side bytes fetch — returns `{ id, mimeType, sizeBytes, filename, base64 }`. Default cap 1 MB; allowlist is image/*+pdf+text. | -| `delete` | Delete attachment + remove blob. | +| Tool | Summary | +| ---------------- | ------------------------------------------------------------------------------------------------------------------------------------ | +| `initUpload` | Get a presigned MinIO/S3 PUT URL. Returns `{ uploadUrl, key }`. | +| `finalize` | Register the uploaded blob as an Attachment row. | +| `attachLink` | Record an external URL (LINK kind). No bytes uploaded. | +| `list` | List attachments for a `(targetType, targetId)` pair. | +| `getDownloadUrl` | Get a presigned GET URL for browser/agent download. | +| `getInline` | Server-side bytes fetch — returns `{ id, mimeType, sizeBytes, filename, base64 }`. Default cap 1 MB; allowlist is image/\*+pdf+text. | +| `delete` | Delete attachment + remove blob. | **`attachLink`** input: `{ targetType, targetId, externalUrl, linkTitle? }`. `targetType` is one of `"issue" | "initiative" | "cycle" | "comment"`. @@ -324,7 +324,7 @@ suggestion to fall back to `getDownloadUrl`. `maxBytes` defaults to ### `pins` | Tool | Summary | -|--------|---------------------------------------------------------------| +| ------ | ------------------------------------------------------------- | | `list` | List the caller's pinned issues. | | `set` | Set the full pinned set (idempotent — pass the desired list). | @@ -338,19 +338,19 @@ is tRPC-only via `pin.listAll` / `pin.add` / `pin.remove` / ### `notes` Per-(workspace, user) markdown scratchpad. Each tool operates **only** -on the calling actor's own notes — agents leave notes for *themselves*, +on the calling actor's own notes — agents leave notes for _themselves_, not for the operator. To leave a note for someone else, use `comments.create` on the relevant issue (their inbox picks up the @-mention via the existing fan-out). -| Tool | Scope | Summary | -|-----------------|----------------|------------------------------------------------------------------------| -| `notes.create` | `WRITE_ISSUES` | Create a personal note. Inputs: `{ title?, body, pinned?, kind?, journalDate? }`. | -| `notes.list` | `READ_ISSUES` | List the caller's own notes (NOTE-kind by default). Inputs: `{ archived?, kind?, limit? }`. | -| `notes.update` | `WRITE_ISSUES` | Patch one of the caller's notes. Inputs: `{ id, title?, body?, pinned? }`. | -| `notes.archive` | `WRITE_ISSUES` | Soft-archive one of the caller's notes. Inputs: `{ id }`. | -| `notes.todayJournal` | `WRITE_ISSUES` | Get-or-create today's JOURNAL entry for the caller (timezone-aware). Idempotent. | -| `notes.listJournal` | `READ_ISSUES` | List recent journal entries. Inputs: `{ from?, to?, limit? = 30 }`. | +| Tool | Scope | Summary | +| -------------------- | -------------- | ------------------------------------------------------------------------------------------- | +| `notes.create` | `WRITE_ISSUES` | Create a personal note. Inputs: `{ title?, body, pinned?, kind?, journalDate? }`. | +| `notes.list` | `READ_ISSUES` | List the caller's own notes (NOTE-kind by default). Inputs: `{ archived?, kind?, limit? }`. | +| `notes.update` | `WRITE_ISSUES` | Patch one of the caller's notes. Inputs: `{ id, title?, body?, pinned? }`. | +| `notes.archive` | `WRITE_ISSUES` | Soft-archive one of the caller's notes. Inputs: `{ id }`. | +| `notes.todayJournal` | `WRITE_ISSUES` | Get-or-create today's JOURNAL entry for the caller (timezone-aware). Idempotent. | +| `notes.listJournal` | `READ_ISSUES` | List recent journal entries. Inputs: `{ from?, to?, limit? = 30 }`. | Returns the resulting `Note` row(s). Cross-actor mutation is blocked at the resolver — passing an `id` owned by another user yields a @@ -362,8 +362,8 @@ tRPC proc covers that case. ### `analytics` -| Tool | Summary | -|-----------|------------------------------------------------------------------------| +| Tool | Summary | +| --------- | ----------------------------------------------------------------------- | | `summary` | Workspace summary: counts by status, throughput, breaches. Coarse only. | > Dispatch analytics (`analytics.dispatch.*`) are tRPC-only — see @@ -371,8 +371,8 @@ tRPC proc covers that case. ### `standup` -| Tool | Summary | -|---------|----------------------------------------------------------------------| +| Tool | Summary | +| ------- | ----------------------------------------------------------------------------------------------------------------------------------- | | `draft` | Compose a "closed / opened / continuing / blocked" markdown draft from the caller's last 24h (configurable up to 168h) of activity. | `standup.draft` accepts `{ sinceHours?: number }` (default 24, max 168) @@ -388,10 +388,10 @@ matching `issues.create`'s fallback). Scopes: `READ_ISSUES`, ### `agents` -| Tool | Summary | -|-------------|----------------------------------------------------------------------| -| `me` | Returns the calling agent's row. Inferred from `ApiKey.linkedAgentId`. | -| `heartbeat` | Update presence: `{ status?: ONLINE | BUSY | OFFLINE }`. | +| Tool | Summary | +| ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---- | ----------- | +| `me` | Returns the calling agent's row. Inferred from `ApiKey.linkedAgentId`. | +| `heartbeat` | Update presence: `{ status?: ONLINE | BUSY | OFFLINE }`. | | `list` | Workspace agents. `{ includeArchived? = false, runtimeId? }` → `{ id, profileKey, name, status, runtimeMode, provider, capabilities, archivedAt, runtime: { id, name, kind } }[]`. Scope: `READ_USERS`. | **`me`** rejects keys without `linkedAgentId` set. The intended pattern is @@ -415,13 +415,13 @@ Forge expects a persistent or single-session runtime. Scope required: `WRITE_COMMENTS`. -| Tool | Summary | -|---|---| -| `appendMessage` | Single-shot agent reply. Persists a `ChatMessage` (role: AGENT). | -| `startDraft` | Begin a streaming reply. Returns `{ draftId }`. Publishes a `started` event on the `chat-thread-stream` channel. Nothing persisted yet. | -| `appendDraftChunk` | Publish one token delta. Ephemeral SSE only; no DB write. | -| `finalizeDraft` | Persist the complete reply, swap the client draft bubble, publish `finalized`. | -| `getThread` | Read `{ thread, messages[] }` for a thread the calling agent is the addressee of. `{ threadId, before?, limit? = 50 (max 200) }`. Each message includes `id, role, body, contextSnapshot, sourceRunId, createdAt, finalizedDraftId`. | +| Tool | Summary | +| ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `appendMessage` | Single-shot agent reply. Persists a `ChatMessage` (role: AGENT). | +| `startDraft` | Begin a streaming reply. Returns `{ draftId }`. Publishes a `started` event on the `chat-thread-stream` channel. Nothing persisted yet. | +| `appendDraftChunk` | Publish one token delta. Ephemeral SSE only; no DB write. | +| `finalizeDraft` | Persist the complete reply, swap the client draft bubble, publish `finalized`. | +| `getThread` | Read `{ thread, messages[] }` for a thread the calling agent is the addressee of. `{ threadId, before?, limit? = 50 (max 200) }`. Each message includes `id, role, body, contextSnapshot, sourceRunId, createdAt, finalizedDraftId`. | **Single-shot:** @@ -430,7 +430,7 @@ Scope required: `WRITE_COMMENTS`. { "threadId": "cle9k...", "body": "Here's the summary: ...", - "sourceRunId": "run_01H..." // optional — links to an AgentRun + "sourceRunId": "run_01H..." // optional — links to an AgentRun } // → { "messageId": "...", "threadId": "..." } ``` @@ -465,13 +465,13 @@ Scope required: `ADMIN`. Powers the `forge` CLI's local daemon registration and heartbeat loop. See [/agents/runtimes.html](/agents/runtimes.html) for the broader Runtime primitive. -| Tool | Summary | -|---|---| -| `register` | Create (or restore) a Runtime row. `{ name, kind, endpoint?, providersAvailable, info? }`. `ownerId` is set from the calling key's `userId`; AGENT-kind keys leave it null. | -| `heartbeat` | Bump `Runtime.heartbeatAt`. `{ runtimeId, info? }`. Optional `info` updates sanitized runtime version/environment metadata. | -| `list` | List workspace runtimes. `{ kind?, includeArchived? = false }` → mirror of `trpc.runtime.list` shape, includes `_count: { agents }` + `owner` summary. Secrets are redacted to `hasSecret`. | -| `configure` | Update adapter config. `{ runtimeId, config, merge? = true }`. Hermes accepts `localWorkspaceTools`, `toolCapabilities`, `workspaceRoot`, and `modeToolPolicyEnforced`; Codex app-server also accepts sandbox/approval config. Scope `ADMIN`. | -| `archive` | Deregister (archive) a runtime — `{ runtimeId }` sets `archivedAt`, so it drops out of `list` (unless `includeArchived`). The teardown counterpart of `register`; exposed as `forge runtimes archive `. Scope `ADMIN`. | +| Tool | Summary | +| ------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `register` | Create (or restore) a Runtime row. `{ name, kind, endpoint?, providersAvailable, info? }`. `ownerId` is set from the calling key's `userId`; AGENT-kind keys leave it null. | +| `heartbeat` | Bump `Runtime.heartbeatAt`. `{ runtimeId, info? }`. Optional `info` updates sanitized runtime version/environment metadata. | +| `list` | List workspace runtimes. `{ kind?, includeArchived? = false }` → mirror of `trpc.runtime.list` shape, includes `_count: { agents }` + `owner` summary. Secrets are redacted to `hasSecret`. | +| `configure` | Update adapter config. `{ runtimeId, config, merge? = true }`. Hermes accepts `localWorkspaceTools`, `toolCapabilities`, `workspaceRoot`, and `modeToolPolicyEnforced`; Codex app-server also accepts sandbox/approval config. Scope `ADMIN`. | +| `archive` | Deregister (archive) a runtime — `{ runtimeId }` sets `archivedAt`, so it drops out of `list` (unless `includeArchived`). The teardown counterpart of `register`; exposed as `forge runtimes archive `. Scope `ADMIN`. | | `reportInfo` | Agent-linked runtime metadata report. `{ runtimeId?, info }`; omitted `runtimeId` resolves to the calling agent's attached Runtime. Stores a sanitized whitelist such as `runtimeVersion`, `bridgeVersion`, `codexVersion`, `containerImage`, `hostname`, `os`, `arch`, and `workspaceRoot`. | **`register`** is intentionally not deduping server-side — the CLI caches @@ -483,14 +483,14 @@ heartbeat returns a missing-row error. `recordUsage` requires `WRITE_ISSUES` and an agent-linked key whose `linkedAgentId` matches the run's `agentId`. `list` requires `READ_ISSUES`. -| Tool | Summary | -|---|---| -| `open` | Open (or resume) a tracked run on an issue **you** are working — `{ issueId, summary?, mode? }`. Requires an agent-linked key; the run belongs to the key's `linkedAgentId`. Resumes an existing ACTIVE/WAITING run for the `(issue, agent)` pair instead of stacking a duplicate, and stamps a STARTED event so it shows in Mission Control. Lets an agent (e.g. a local CLI session) proactively self-report work rather than only getting a run as a dispatch side-effect. Issue-anchored; drive it afterward with `recordUsage` / `setWaiting` / `complete`. Scope `WRITE_ISSUES`. | -| `recordUsage` | Update token + cost columns on an `AgentRun`. `{ runId, tokensIn?, tokensOut?, tokensCached?, costUsd? }`. Idempotent — latest call replaces (cumulative as reported by the agent). | -| `complete` | Close an active/waiting run. `{ runId, summary, producedArtifactIds?, verificationResult?, followUps?, confidence?, verdict? }`. Requires an agent-linked key matching the run. Execute enforces issue artifact/checklist gates; Research requires `confidence`; Review requires `verdict`; Discuss is reply-only. Stores `completionMeta` with the Forge run contract version. | -| `setWaiting` / `resumeWork` | Mark a run blocked on the operator, or resume it. Requires an agent-linked key matching the run. | -| `list` | List `AgentRun` rows for "my recent history" introspection. `{ agentId?, issueId?, status?, limit? = 50, before? }` → newest-first by `startedAt`. Each row includes scalars (status, currentStep, startedAt, finishedAt, lastEventAt, tokensIn, tokensOut, tokensCached, costUsd) plus `issue { id, number, title, workspace: { key } }` and `agent { id, profileKey }`. | -| `kick` | Operator-driven nudge for a stalled run. `{ runId }`. Re-fires the dispatch webhook for the underlying issue without changing assignment or `controlState`. Only kicks an `ACTIVE` run that's been quiet 5+ minutes; younger runs return `{ ok: true, kicked: false }`. Records `AGENT_RUN_KICKED`. Scope: `WRITE_ISSUES`. | +| Tool | Summary | +| --------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `open` | Open (or resume) a tracked run on an issue **you** are working — `{ issueId, summary?, mode? }`. Requires an agent-linked key; the run belongs to the key's `linkedAgentId`. Resumes an existing ACTIVE/WAITING run for the `(issue, agent)` pair instead of stacking a duplicate, and stamps a STARTED event so it shows in Mission Control. Lets an agent (e.g. a local CLI session) proactively self-report work rather than only getting a run as a dispatch side-effect. Issue-anchored; drive it afterward with `recordUsage` / `setWaiting` / `complete`. Scope `WRITE_ISSUES`. | +| `recordUsage` | Update token + cost columns on an `AgentRun`. `{ runId, tokensIn?, tokensOut?, tokensCached?, costUsd? }`. Idempotent — latest call replaces (cumulative as reported by the agent). | +| `complete` | Close an active/waiting run. `{ runId, summary, producedArtifactIds?, verificationResult?, followUps?, confidence?, verdict? }`. Requires an agent-linked key matching the run. Execute enforces issue artifact/checklist gates; Research requires `confidence`; Review requires `verdict`; Discuss is reply-only. Stores `completionMeta` with the Forge run contract version. | +| `setWaiting` / `resumeWork` | Mark a run blocked on the operator, or resume it. Requires an agent-linked key matching the run. | +| `list` | List `AgentRun` rows for "my recent history" introspection. `{ agentId?, issueId?, status?, limit? = 50, before? }` → newest-first by `startedAt`. Each row includes scalars (status, currentStep, startedAt, finishedAt, lastEventAt, tokensIn, tokensOut, tokensCached, costUsd) plus `issue { id, number, title, workspace: { key } }` and `agent { id, profileKey }`. | +| `kick` | Operator-driven nudge for a stalled run. `{ runId }`. Re-fires the dispatch webhook for the underlying issue without changing assignment or `controlState`. Only kicks an `ACTIVE` run that's been quiet 5+ minutes; younger runs return `{ ok: true, kicked: false }`. Records `AGENT_RUN_KICKED`. Scope: `WRITE_ISSUES`. | `costUsd` is taken verbatim from the agent for v1; a server-side rate table per model is a future enhancement. @@ -500,25 +500,25 @@ rate table per model is a future enhancement. Scope: `READ_ISSUES`. Reads `ActivityEvent` rows for the calling workspace. -| Tool | Summary | -|---|---| +| Tool | Summary | +| -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `recent` | `{ subjectType?, subjectId?, kinds?, limit? = 50 (max 200), before? }` → newest-first by `createdAt`. Includes `actor { id, name, image }`. When `subjectType="issue"` + `subjectId` are set, the calling key's project/label narrowing is enforced on that issue. | ### `workspace` Scope: `READ_ISSUES`. -| Tool | Summary | -|---|---| +| Tool | Summary | +| ----- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `get` | Returns dispatch-time settings: `{ id, slug, key, name, cycleLengthDays, cycleCooldownDays, timeTrackingEnabled, attachmentQuotaMb, requiredAckSeconds, autoDispatch, autoDispatchMode }`. No member list. | ### `statuses` Scope: `READ_ISSUES`. -| Tool | Summary | -|---|---| -| `list` | `{ category? }`. Returns `{ id, name, category, color, position, isDefault }[]` ordered by `position`. Optional `category` filter (`BACKLOG | TODO | IN_PROGRESS | IN_REVIEW | DONE | CANCELED`). Used by agents to discover the right `statusId` for an `issues.transition` call without inventing ids — e.g., the local `forge` daemon calls `statuses.list({ category: "IN_PROGRESS" })` on `AGENT_ASSIGNED` to flip the issue into work-in-progress before spawning Claude. | +| Tool | Summary | +| ------ | ------------------------------------------------------------------------------------------------------------------------------------------- | ---- | ----------- | --------- | ---- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `list` | `{ category? }`. Returns `{ id, name, category, color, position, isDefault }[]` ordered by `position`. Optional `category` filter (`BACKLOG | TODO | IN_PROGRESS | IN_REVIEW | DONE | CANCELED`). Used by agents to discover the right `statusId`for an`issues.transition`call without inventing ids — e.g., the local`forge`daemon calls`statuses.list({ category: "IN_PROGRESS" })`on`AGENT_ASSIGNED` to flip the issue into work-in-progress before spawning Claude. | ### `actionRequests` @@ -530,16 +530,16 @@ Accept / Decline. Created by `comments.create` (inline via the Scope: `WRITE_ISSUES` for mutations, `READ_ISSUES` for queries. -| Tool | Summary | -|---|---| -| `list` | `{ status?, assignedAgentId?, assignedUserId?, issueId?, limit? = 50 }`. Newest first. | -| `create` | Create a request. See "Polls" below for multi-vote variant. | -| `transition` | Flip status (RESOLVED / DISMISSED / SNOOZED / REJECTED). | -| `accept` | Run the bound dispatch (transition / setLabels / etc.) and resolve. Permission-gated to assignee / watcher / OWNER / ADMIN. | -| `decline` | Reject without dispatching. Same permission gate as Accept. | -| `vote` | Cast or update the caller's vote on a poll-style request. `{ id, optionKey }`. | -| `results` | Live vote tallies for a poll. `{ id }` → `{ options[], total, myVote, winningOptionKey, votingClosedAt }`. | -| `closeVoting` | Close voting on a poll. Only the requester can call this. `{ id }` → `{ id, winningOptionKey }`. | +| Tool | Summary | +| ------------- | --------------------------------------------------------------------------------------------------------------------------- | +| `list` | `{ status?, assignedAgentId?, assignedUserId?, issueId?, limit? = 50 }`. Newest first. | +| `create` | Create a request. See "Polls" below for multi-vote variant. | +| `transition` | Flip status (RESOLVED / DISMISSED / SNOOZED / REJECTED). | +| `accept` | Run the bound dispatch (transition / setLabels / etc.) and resolve. Permission-gated to assignee / watcher / OWNER / ADMIN. | +| `decline` | Reject without dispatching. Same permission gate as Accept. | +| `vote` | Cast or update the caller's vote on a poll-style request. `{ id, optionKey }`. | +| `results` | Live vote tallies for a poll. `{ id }` → `{ options[], total, myVote, winningOptionKey, votingClosedAt }`. | +| `closeVoting` | Close voting on a poll. Only the requester can call this. `{ id }` → `{ id, winningOptionKey }`. | **`create`** accepts the usual fields (`title, body, severity, kind, payload, assignedUserId, assignedAgentId, issueId, dueAt, sourceType, @@ -552,9 +552,13 @@ sourceId`) plus an optional `options[]` for poll-style requests: "kind": "FREE_FORM", "issueId": "iss_...", "options": [ - { "key": "retry", "label": "Add retry-on-failure", "description": "Cheapest, masks the underlying race." }, - { "key": "isolate", "label": "Isolate the test in its own worker" }, - { "key": "rewrite", "label": "Rewrite against a deterministic fixture" } + { + "key": "retry", + "label": "Add retry-on-failure", + "description": "Cheapest, masks the underlying race." + }, + { "key": "isolate", "label": "Isolate the test in its own worker" }, + { "key": "rewrite", "label": "Rewrite against a deterministic fixture" } ] } ``` @@ -608,12 +612,12 @@ Scope: `READ_ISSUES` for reads, `WRITE_ISSUES` for the loop mutations, #### Goals -| Tool | Summary | -|---|---| -| `goals.list` | `{ status?, includeArchived?, limit? }` → goal rows with plan counts. | -| `goals.get` | `{ id }` → goal + its plans + an `aggregate` block (`activePlanId`, `totalSteps`, `doneSteps`, `blockedSteps`). | -| `goals.create` | `{ title, description?, issueId?, crewId?, maxTotalCostUsd?, maxWallTimeMinutes? }` → `{ id }`. Emits `GOAL_CREATED`. | -| `goals.abandon` | `{ id, reason? }` → terminal `ABANDONED`; cancels any active plan attempt. | +| Tool | Summary | +| --------------- | --------------------------------------------------------------------------------------------------------------------- | +| `goals.list` | `{ status?, includeArchived?, limit? }` → goal rows with plan counts. | +| `goals.get` | `{ id }` → goal + its plans + an `aggregate` block (`activePlanId`, `totalSteps`, `doneSteps`, `blockedSteps`). | +| `goals.create` | `{ title, description?, issueId?, crewId?, maxTotalCostUsd?, maxWallTimeMinutes? }` → `{ id }`. Emits `GOAL_CREATED`. | +| `goals.abandon` | `{ id, reason? }` → terminal `ABANDONED`; cancels any active plan attempt. | ```json // goals.create @@ -623,14 +627,14 @@ Scope: `READ_ISSUES` for reads, `WRITE_ISSUES` for the loop mutations, #### Plans (loop) -| Tool | Summary | -|---|---| -| `plans.decompose` | `{ goalId, plannerAgentId?, contextSetId? }` → `{ planId, status: "PLANNING", plannerAgentId }`. Creates a DRAFT plan (`isActiveAttempt=true`, prior attempts flipped false), flips goal → PLANNING, dispatches the PLANNER (override > crew PLANNER > caller's agent). | -| `plans.addSteps` | `{ planId, steps: [{ title, body?, expectedOutput?, verification?, dependsOnStepIndexes?, assignedAgentId?, assignedRole? }] }` → `{ stepIds }`. Bulk-adds to a DRAFT plan; `dependsOnStepIndexes` are 0-based positions within the batch, resolved to real ids. | -| `plans.requestApproval` | `{ planId, assignedUserId? }` → `{ actionRequestId }`. Raises a FREE_FORM ActionRequest (`sourceType="execution-plan"`). Accepting it activates the plan. | -| `plans.activate` | `{ planId }` → `{ ok }`. DRAFT/APPROVED → RUNNING, goal → ACTIVE + `startedAt`, root steps cascade READY. Usually fired by accepting the approval ActionRequest. | -| `plans.judge` | `{ stepId, judgeAgentId? }` → `{ judgeAgentId }`. Dispatches a REVIEWER (override > crew REVIEWER) to evaluate a step in REVIEW. | -| `plans.recordVerdict` | `{ stepId, verdict: "PASS"\|"FAIL", feedback, score? }` → `{ outcome: "DONE"\|"RETRY"\|"BLOCKED", retryCount }`. PASS → DONE + cascade. FAIL → READY+retry (feedback stored) or BLOCKED + ReviewGate when retries exhausted. Emits `EXECUTION_STEP_JUDGED`. | +| Tool | Summary | +| ----------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `plans.decompose` | `{ goalId, plannerAgentId?, contextSetId? }` → `{ planId, status: "PLANNING", plannerAgentId }`. Creates a DRAFT plan (`isActiveAttempt=true`, prior attempts flipped false), flips goal → PLANNING, dispatches the PLANNER (override > crew PLANNER > caller's agent). | +| `plans.addSteps` | `{ planId, steps: [{ title, body?, expectedOutput?, verification?, dependsOnStepIndexes?, assignedAgentId?, assignedRole? }] }` → `{ stepIds }`. Bulk-adds to a DRAFT plan; `dependsOnStepIndexes` are 0-based positions within the batch, resolved to real ids. | +| `plans.requestApproval` | `{ planId, assignedUserId? }` → `{ actionRequestId }`. Raises a FREE_FORM ActionRequest (`sourceType="execution-plan"`). Accepting it activates the plan. | +| `plans.activate` | `{ planId }` → `{ ok }`. DRAFT/APPROVED → RUNNING, goal → ACTIVE + `startedAt`, root steps cascade READY. Usually fired by accepting the approval ActionRequest. | +| `plans.judge` | `{ stepId, judgeAgentId? }` → `{ judgeAgentId }`. Dispatches a REVIEWER (override > crew REVIEWER) to evaluate a step in REVIEW. | +| `plans.recordVerdict` | `{ stepId, verdict: "PASS"\|"FAIL", feedback, score? }` → `{ outcome: "DONE"\|"RETRY"\|"BLOCKED", retryCount }`. PASS → DONE + cascade. FAIL → READY+retry (feedback stored) or BLOCKED + ReviewGate when retries exhausted. Emits `EXECUTION_STEP_JUDGED`. | ```json // plans.addSteps — index-based deps @@ -650,27 +654,27 @@ Scope: `READ_ISSUES` for reads, `WRITE_ISSUES` for the loop mutations, #### Plan substrate (existing) -| Tool | Summary | -|---|---| -| `executionPlans.list` / `.get` / `.create` / `.transition` | Plan CRUD + status transitions. | -| `executionPlans.transitionStep` | `{ stepId, status, sourceRunId? }`. Marking a step DONE cascades downstream readiness; marking REVIEW triggers auto-judge when the plan opts in. | +| Tool | Summary | +| ---------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ | +| `executionPlans.list` / `.get` / `.create` / `.transition` | Plan CRUD + status transitions. | +| `executionPlans.transitionStep` | `{ stepId, status, sourceRunId? }`. Marking a step DONE cascades downstream readiness; marking REVIEW triggers auto-judge when the plan opts in. | #### Crews -| Tool | Summary | -|---|---| -| `agentCrews.list` | Existing read. Crews + member/plan counts. | -| `agentCrews.create` | `{ name, description?, maxParallel?, members?: [{ agentId, role }] }` → `{ id }`. Roles: `PLANNER`/`WORKER`/`REVIEWER`/`OBSERVER`/`OPERATOR_PROXY`. | -| `agentCrews.update` | `{ id, name?, description?, maxParallel? }`. | -| `agentCrews.addMember` | `{ crewId, agentId, role }` → `{ id }`. | -| `agentCrews.removeMember` | `{ memberId }`. | -| `agentCrews.setMemberRole` | `{ memberId, role }` → `{ id }`. | -| `agentCrews.archive` | `{ id }`. Soft-archive. | +| Tool | Summary | +| -------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- | +| `agentCrews.list` | Existing read. Crews + member/plan counts. | +| `agentCrews.create` | `{ name, description?, maxParallel?, members?: [{ agentId, role }] }` → `{ id }`. Roles: `PLANNER`/`WORKER`/`REVIEWER`/`OBSERVER`/`OPERATOR_PROXY`. | +| `agentCrews.update` | `{ id, name?, description?, maxParallel? }`. | +| `agentCrews.addMember` | `{ crewId, agentId, role }` → `{ id }`. | +| `agentCrews.removeMember` | `{ memberId }`. | +| `agentCrews.setMemberRole` | `{ memberId, role }` → `{ id }`. | +| `agentCrews.archive` | `{ id }`. Soft-archive. | #### Review gates -| Tool | Summary | -|---|---| +| Tool | Summary | +| ----------------------------------------- | ------------------------------------------------------------------------------------------------------------------ | | `reviewGates.list` / `.open` / `.resolve` | Approval checkpoints. The loop auto-opens gates on a step BLOCKED (retries exhausted) and on a plan budget breach. | ### `notification` @@ -682,8 +686,8 @@ the whole notification feed. Scope: `READ_USERS` for the query, `WRITE_USERS` for the mutation. -| Tool | Summary | -|---|---| +| Tool | Summary | +| --------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- | | `setPreference` | Upsert a preference row. `{ eventKind, enabled, delivery?, scope? = "workspace" }`. Pass `scope: "global"` to set the cross-workspace default. | ```json @@ -702,11 +706,12 @@ ships). Scope: `READ_ISSUES`. The single composite tool that saves agents 4–5 round-trips on dispatch — bundles workspace + issue (or thread) + comments -+ attachments + relations + currentRun in one call. -| Tool | Summary | -|---|---| -| `context.bundle` | `{ issueId? }` xor `{ threadId? }`. For `issueId`: returns `{ workspace, issue (full row), description, comments (last 50), attachments, relations, currentRun, artifacts, completionContract, runProtocol, externalResources }`. `externalResources` contains linked GitHub issues/PRs with their link kind and latest local snapshot. `runProtocol` includes `{ contractVersion, runId, engagementMode, modeInstruction, protocolInstruction, mayMutateIssue }` so agents can ack/output-start/complete the correct run and know whether issue mutations are allowed. For `threadId`: returns `{ workspace, thread, messages (last 50), agent (peer summary), linkedIssues (any issues mentioned in messages' contextSnapshots) }`. Addressee gating mirrors `chat.getThread` for the threadId branch; issue-narrowing applies for the issueId branch. | +- attachments + relations + currentRun in one call. + +| Tool | Summary | +| ---------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `context.bundle` | `{ issueId? }` xor `{ threadId? }`. For `issueId`: returns `{ workspace, issue (full row), description, comments (last 50), attachments, relations, currentRun, artifacts, orchestrationContext, completionContract, runProtocol, externalResources }`. `currentRun` is limited to `ACTIVE`/`WAITING` and, for an agent key, to that linked agent. `orchestrationContext` carries the Goal, Plan, current Step (when unambiguous), dependencies/dependents, sibling progress, retry feedback, worker evidence, and non-excluded ContextSet refs. `externalResources` contains linked GitHub issues/PRs with their link kind and latest local snapshot. `runProtocol` includes `{ contractVersion, runId, engagementMode, modeInstruction, protocolInstruction, mayMutateIssue }` so agents can ack/output-start/complete the correct run and know whether issue mutations are allowed. For `threadId`: returns `{ workspace, thread, messages (last 50), agent (peer summary), linkedIssues (any issues mentioned in messages' contextSnapshots) }`. Addressee gating mirrors `chat.getThread` for the threadId branch; issue-narrowing applies for the issueId branch. | ## Not on MCP @@ -738,16 +743,16 @@ JSON shape on every error response: `issues` is present only on Zod validation failures; otherwise just `error`. -| Status | Meaning | -|--------|---------------------------------------------------------------| -| 400 | Invalid input (Zod). `issues` map populated. | -| 401 | Auth failed (invalid/revoked/expired key, missing JWT). | -| 403 | Scope or narrowing rejected. | -| 404 | Subject does not exist or is not visible to this key. | -| 409 | Conflict (e.g., reassigning to the same agent). | -| 422 | Workspace precondition failed (e.g. time tracking disabled). | -| 429 | Rate limited. Includes `Retry-After` header in seconds. | -| 5xx | Server error. | +| Status | Meaning | +| ------ | ------------------------------------------------------------ | +| 400 | Invalid input (Zod). `issues` map populated. | +| 401 | Auth failed (invalid/revoked/expired key, missing JWT). | +| 403 | Scope or narrowing rejected. | +| 404 | Subject does not exist or is not visible to this key. | +| 409 | Conflict (e.g., reassigning to the same agent). | +| 422 | Workspace precondition failed (e.g. time tracking disabled). | +| 429 | Rate limited. Includes `Retry-After` header in seconds. | +| 5xx | Server error. | tRPC errors over the in-app client follow `TRPCClientError` shape — code, message, data with `httpStatus` and `path`. The MCP transports translate diff --git a/src/app/(app)/w/[slug]/issues/[id]/page.tsx b/src/app/(app)/w/[slug]/issues/[id]/page.tsx index 7fde0cbf..0541d157 100644 --- a/src/app/(app)/w/[slug]/issues/[id]/page.tsx +++ b/src/app/(app)/w/[slug]/issues/[id]/page.tsx @@ -257,9 +257,7 @@ export default function IssueDetailPage({ params }: { params: Promise<{ id: stri const top = Math.max(16, el.getBoundingClientRect().top); const available = Math.floor(window.innerHeight - top - 16); const next = Math.max(240, available); - setRailCardHeight((prev) => - prev !== null && Math.abs(prev - next) < 2 ? prev : next, - ); + setRailCardHeight((prev) => (prev !== null && Math.abs(prev - next) < 2 ? prev : next)); }); }; @@ -543,6 +541,7 @@ export default function IssueDetailPage({ params }: { params: Promise<{ id: stri kind={issue.kind} projectId={issue.projectId ?? null} parent={issue.parent ?? null} + executionSteps={issue.executionSteps} onDescriptionSave={(next) => update.mutate({ id: issue.id, description: next })} canResolveActions={(() => { if (!me?.id) return false; @@ -640,9 +639,7 @@ export default function IssueDetailPage({ params }: { params: Promise<{ id: stri - update.mutate({ id: issue.id, slaMinutes }) - } + onChange={(slaMinutes) => update.mutate({ id: issue.id, slaMinutes })} /> @@ -900,7 +897,11 @@ function ClaimHolderCard({ const primary = claimedByAgent?.name ?? userName ?? claimedById?.slice(0, 8) ?? "Unknown"; // Agent → its handle; otherwise the short id, unless that's already primary. const shortId = claimedById ? claimedById.slice(0, 8) : null; - const sub = claimedByAgent ? `@${claimedByAgent.profileKey}` : shortId && shortId !== primary ? shortId : null; + const sub = claimedByAgent + ? `@${claimedByAgent.profileKey}` + : shortId && shortId !== primary + ? shortId + : null; return (
diff --git a/src/components/issue-detail/issue-main.tsx b/src/components/issue-detail/issue-main.tsx index 27c30c5f..d455ba97 100644 --- a/src/components/issue-detail/issue-main.tsx +++ b/src/components/issue-detail/issue-main.tsx @@ -12,6 +12,10 @@ import { Button } from "@/components/ui/button"; import { MarkdownWithAttachments } from "@/components/markdown/attachment-renderer"; import { DescriptionAiAssist } from "@/components/issue-detail/description-ai-assist"; import { AgentRunStrip } from "@/components/issue-detail/agent-run-strip"; +import { + PlanStepContextCards, + type PlanStepIssueContext, +} from "@/components/issue-detail/plan-step-context-card"; import { SubIssuesPanel, ParentIssueBacklink } from "@/components/issue-detail/sub-issues-panel"; import { ActionRequestCard } from "@/components/action-requests/action-request-card"; import { @@ -54,10 +58,7 @@ function useSlashAttributes() { const { data: labels } = trpc.label.list.useQuery(undefined, { staleTime: 60_000, }); - return useMemo( - () => ({ projects: projects?.items, agents, labels }), - [projects, agents, labels], - ); + return useMemo(() => ({ projects: projects?.items, agents, labels }), [projects, agents, labels]); } type AgentRequestMode = "EXECUTE" | "RESEARCH" | "REVIEW" | "DISCUSS"; @@ -319,6 +320,7 @@ export function IssueMain({ kind, projectId, parent, + executionSteps, }: { issueId: string; description: string | null; @@ -329,10 +331,12 @@ export function IssueMain({ kind: string; projectId: string | null; parent: { id: string; number: number; title: string } | null; + executionSteps: PlanStepIssueContext[]; }) { return (
+ diff --git a/src/components/issue-detail/plan-step-context-card.tsx b/src/components/issue-detail/plan-step-context-card.tsx new file mode 100644 index 00000000..b030beb7 --- /dev/null +++ b/src/components/issue-detail/plan-step-context-card.tsx @@ -0,0 +1,348 @@ +"use client"; + +import Link from "next/link"; +import { ArrowRight, CircleCheck, ListChecks, Target, Workflow } from "lucide-react"; +import { MarkdownWithAttachments } from "@/components/markdown/attachment-renderer"; +import { STEP_STATUS_STYLE, type StepStatus } from "@/components/orchestration/types"; +import { useWorkspace } from "@/hooks/use-workspace"; +import { cn, formatIssueId } from "@/lib/utils"; + +type VerificationItem = { + id?: string; + label: string; + kind?: "manual" | "command" | "artifact"; + value?: string; + done?: boolean; +}; + +type PlanStepNeighbor = { + id: string; + title: string; + position: number; + status: StepStatus; + dependsOnStepIds: string[]; + issue: { + id: string; + number: number; + title: string; + status: { name: string; category: string; color: string }; + } | null; +}; + +export type PlanStepIssueContext = { + id: string; + title: string; + body: string | null; + position: number; + status: StepStatus; + expectedOutput: string | null; + verification: unknown; + dependsOnStepIds: string[]; + retryCount: number; + lastFeedback: string | null; + plan: { + id: string; + title: string; + description: string | null; + status: string; + goal: { + id: string; + title: string; + description: string | null; + successCriteria: string | null; + status: string; + } | null; + steps: PlanStepNeighbor[]; + }; +}; + +function verificationItems(value: unknown): VerificationItem[] { + if (!Array.isArray(value)) return []; + return value.flatMap((item) => { + if (!item || typeof item !== "object") return []; + const row = item as Record; + if (typeof row.label !== "string" || !row.label.trim()) return []; + return [ + { + id: typeof row.id === "string" ? row.id : undefined, + label: row.label, + kind: + row.kind === "manual" || row.kind === "command" || row.kind === "artifact" + ? row.kind + : undefined, + value: typeof row.value === "string" ? row.value : undefined, + done: typeof row.done === "boolean" ? row.done : undefined, + }, + ]; + }); +} + +function StepStatusPill({ status }: { status: StepStatus }) { + const style = STEP_STATUS_STYLE[status] ?? STEP_STATUS_STYLE.TODO; + return ( + + + {style.label} + + ); +} + +function StepLink({ step, planId }: { step: PlanStepNeighbor; planId: string }) { + const ws = useWorkspace(); + const content = ( + <> + + {step.issue ? formatIssueId(ws.key, step.issue.number) : `step ${step.position + 1}`} + + {step.issue?.title ?? step.title} + + + ); + const className = + "flex min-w-0 items-center gap-2 rounded-md border border-border bg-background/40 px-2 py-1.5 text-meta transition hover:border-ember/40 hover:text-ember"; + return step.issue ? ( + + {content} + + ) : ( + + {content} + + ); +} + +/** + * Full provenance + completion contract for an Issue materialized from an + * ExecutionStep. This intentionally lives in the main reading column: the + * small rail backlink is useful navigation, but it cannot carry enough + * context for someone (or an agent) opening the Issue in isolation. + */ +export function PlanStepContextCards({ contexts }: { contexts: PlanStepIssueContext[] }) { + if (contexts.length === 0) return null; + return ( +
+ {contexts.map((context) => ( + + ))} +
+ ); +} + +function PlanStepContextCard({ context }: { context: PlanStepIssueContext }) { + const ws = useWorkspace(); + const { plan } = context; + const total = plan.steps.length; + const done = plan.steps.filter((step) => step.status === "DONE").length; + const progress = total > 0 ? Math.round((done / total) * 100) : 0; + const byId = new Map(plan.steps.map((step) => [step.id, step])); + const dependencies = context.dependsOnStepIds.flatMap((id) => { + const step = byId.get(id); + return step ? [step] : []; + }); + const dependents = plan.steps.filter((step) => step.dependsOnStepIds.includes(context.id)); + const checks = verificationItems(context.verification); + + return ( +
+
+
+ {plan.goal ? ( + <> + + + {plan.goal.title} + + + + ) : null} + + + {plan.title} + +
+
+

+ Step {context.position + 1} of {total}: {context.title} +

+ + {context.retryCount > 0 ? ( + + retry {context.retryCount} + + ) : null} +
+
+ +
+ {plan.goal ? ( +
+ {plan.goal.description ? ( +
+

+ Goal context +

+

+ {plan.goal.description} +

+
+ ) : null} + {plan.goal.successCriteria ? ( +
+

+ Success criteria +

+

+ {plan.goal.successCriteria} +

+
+ ) : null} +
+ ) : null} + + {plan.description ? ( +
+

+ Plan context +

+

+ {plan.description} +

+
+ ) : null} + +
+ {context.body ? ( +
+

+ Step instructions +

+ +
+ ) : null} +
+ {context.expectedOutput ? ( +
+

+ Expected output +

+ +
+ ) : null} + {checks.length > 0 ? ( +
+

+ Verification +

+
    + {checks.map((check, index) => ( +
  • + + + {check.label} + {check.value ? ( + + {check.value} + + ) : null} + +
  • + ))} +
+
+ ) : null} + {!context.expectedOutput && checks.length === 0 ? ( +

+ No completion contract was provided. +

+ ) : null} +
+
+ + {context.lastFeedback ? ( +
+

+ Latest review feedback +

+

{context.lastFeedback}

+
+ ) : null} + + {dependencies.length > 0 || dependents.length > 0 ? ( +
+
+

+ Depends on +

+ {dependencies.length > 0 ? ( +
+ {dependencies.map((step) => ( + + ))} +
+ ) : ( +

No prerequisites

+ )} +
+
+

+ Unlocks +

+ {dependents.length > 0 ? ( +
+ {dependents.map((step) => ( + + ))} +
+ ) : ( +

No downstream steps

+ )} +
+
+ ) : null} + +
+
+ Plan progress + + {done}/{total} steps + +
+
+ +
+
+ {plan.steps.map((step) => ( + + ))} +
+
+
+
+ ); +} diff --git a/src/components/relations/issue-relations-graph.tsx b/src/components/relations/issue-relations-graph.tsx index cdb0df36..667515a3 100644 --- a/src/components/relations/issue-relations-graph.tsx +++ b/src/components/relations/issue-relations-graph.tsx @@ -7,7 +7,8 @@ import { useWorkspace } from "@/hooks/use-workspace"; /** * Dependency DAG for the issue Relations tab — the focus issue's place in - * its blocks/blocked-by chain and parent/child sub-issue tree. + * its blocks/blocked-by chain, parent/child sub-issue tree, and the + * materialized edges of its execution-plan DAG. * * Layout is the same layered / longest-path topological sort the plan * `DagView` uses: a node's column = the longest directed path of @@ -15,7 +16,8 @@ import { useWorkspace } from "@/hooks/use-workspace"; * to the left, the focus issue sits mid-graph, and blocked work / children * flow right. Within a column, order by issue number for stability. Edges * are SVG cubic-beziers (source right → target left); `child` edges render - * dashed to read distinctly from solid `blocks` edges. Any edge touching + * dashed to read distinctly from solid `blocks` edges; plan dependencies + * use a longer dash. Any edge touching * the focus node gets the marching `.dag-edge-flow` ember treatment so * "you are here" and the live dependency path read at a glance. * @@ -38,7 +40,12 @@ type GraphNode = { statusColor: string; isCurrent: boolean; }; -type GraphEdge = { id: string; from: string; to: string; kind: "blocks" | "child" }; +type GraphEdge = { + id: string; + from: string; + to: string; + kind: "blocks" | "child" | "plan-dependency"; +}; type Positioned = GraphNode & { x: number; y: number }; @@ -87,7 +94,12 @@ export function IssueRelationsGraph({ issueId }: { issueId: string }) { const nodes = (data?.nodes ?? []) as GraphNode[]; const edges = (data?.edges ?? []) as GraphEdge[]; if (nodes.length === 0) { - return { positioned: [] as Positioned[], width: 0, height: 0, drawn: [] as Array }; + return { + positioned: [] as Positioned[], + width: 0, + height: 0, + drawn: [] as Array, + }; } const layerOf = computeLayers(nodes, edges); const byLayer = new Map(); @@ -137,13 +149,15 @@ export function IssueRelationsGraph({ issueId }: { issueId: string }) { }, [data]); if (isLoading) { - return
Loading graph…
; + return ( +
Loading graph…
+ ); } if (!data || data.nodes.length <= 1) { return ( -
- No linked issues to map yet. Add a blocker, sub-issue, or related link to - see this issue’s place in the dependency path. +
+ No linked issues to map yet. Add a blocker, sub-issue, or plan dependency to see this + issue’s place in the dependency path.
); } @@ -151,8 +165,16 @@ export function IssueRelationsGraph({ issueId }: { issueId: string }) { return (
-
- +
+ {layout.drawn.map((e) => ( ))} @@ -205,7 +229,12 @@ function GraphIssueNode({ /> {issueKey} {node.priority !== "NONE" && PRIORITY_TONE[node.priority] && ( - + {node.priority.toLowerCase()} )} @@ -221,7 +250,7 @@ function GraphIssueNode({ if (node.isCurrent) return body; return ( - + {body} ); @@ -238,10 +267,32 @@ function GraphLegend({ truncated }: { truncated: boolean }) { - + sub-issue → + + + + + plan dependency → + this issue diff --git a/src/server/audit.ts b/src/server/audit.ts index ebbd3819..0018c153 100644 --- a/src/server/audit.ts +++ b/src/server/audit.ts @@ -627,22 +627,86 @@ export async function recordChange( payload: payloadOut, }); if (params.eventKind === EventKind.AGENT_ASSIGNED && params.subjectType === "issue") { + // A materialized execution-step issue is still part of the plan DAG. + // Assignment producers historically emitted only the issue/agent ids, so + // the durable inbox opened a plain issue run with executionStepId=null; + // runs.complete then had no way to hand the result back to the step. Add + // the unambiguous active step link at this single producer boundary and + // keep the step's worker assignment aligned with the issue assignment. + // (More than one active step on one issue is intentionally left alone — + // choosing one would silently attach work to the wrong plan.) + const linkedSteps = await tx.executionStep.findMany({ + where: { + workspaceId: params.workspaceId, + issueId: params.subjectId, + status: { notIn: ["DONE", "CANCELED"] }, + }, + orderBy: [{ updatedAt: "desc" }, { position: "asc" }], + take: 2, + select: { + id: true, + planId: true, + status: true, + assignedAgentId: true, + plan: { select: { status: true } }, + }, + }); + const linkedStep = linkedSteps.length === 1 ? linkedSteps[0] : null; + const rawAssignmentPayload = payloadRecord(params.payload ?? {}); + const payloadHasAgentId = Object.prototype.hasOwnProperty.call(rawAssignmentPayload, "agentId"); + const nextStepAgentId = payloadString(rawAssignmentPayload, "agentId"); + if (linkedStep) { + const stepCanRun = + linkedStep.plan.status === "RUNNING" && + (linkedStep.status === "READY" || linkedStep.status === "RUNNING"); + payloadOut = { + ...payloadRecord(payloadOut), + planId: linkedStep.planId, + planStepId: linkedStep.id, + ...(stepCanRun ? { executionStepId: linkedStep.id } : { orchestrationDeferred: true }), + } as unknown as Prisma.InputJsonValue; + if ( + payloadHasAgentId && + (nextStepAgentId !== null || rawAssignmentPayload.agentId === null) && + linkedStep.assignedAgentId !== nextStepAgentId + ) { + await tx.executionStep.update({ + where: { id: linkedStep.id }, + data: { assignedAgentId: nextStepAgentId }, + }); + await recordChange(tx, { + workspaceId: params.workspaceId, + actorId: params.actorId, + actorAgentId: params.actorAgentId ?? null, + entity: "execution-step", + entityId: linkedStep.id, + action: "sync-issue-agent-assignment", + before: { assignedAgentId: linkedStep.assignedAgentId }, + after: { assignedAgentId: nextStepAgentId, issueId: params.subjectId }, + eventKind: EventKind.ISSUE_UPDATED, + subjectType: "execution-step", + subjectId: linkedStep.id, + payload: { + issueId: params.subjectId, + assignedAgentId: nextStepAgentId, + source: "issue-assignment", + } as Prisma.InputJsonValue, + }); + } + } const assignMode = params.payload && typeof params.payload === "object" && !Array.isArray(params.payload) ? ((params.payload as Record).engagementMode as string | undefined) : undefined; - const transitionedTo = await maybeAutoTransitionOnAssign( - tx, - params.workspaceId, - params.subjectId, - assignMode, - ); + const assignmentIsDeferred = payloadRecord(payloadOut).orchestrationDeferred === true; + const transitionedTo = assignmentIsDeferred + ? null + : await maybeAutoTransitionOnAssign(tx, params.workspaceId, params.subjectId, assignMode); const snapshot = await loadIssueSnapshot(tx, params.subjectId); if (snapshot) { - const base = - params.payload && typeof params.payload === "object" - ? (params.payload as Record) - : {}; + // Build on the already-enriched payload so canonical links added above + // (notably executionStepId) survive snapshot enrichment. + const base = payloadRecord(payloadOut) as Record; payloadOut = { ...base, issueSnapshot: snapshot, @@ -753,7 +817,8 @@ export async function recordChange( const isAssigneeRouted = (params.eventKind === EventKind.AGENT_ASSIGNED || params.eventKind === EventKind.ISSUE_QUEUED) && - params.subjectType === "issue"; + params.subjectType === "issue" && + payloadRecord(payloadOut).orchestrationDeferred !== true; if (isAssigneeRouted) { const issue = await tx.issue.findUnique({ where: { id: params.subjectId }, diff --git a/src/server/routers/__tests__/apiKey.test.ts b/src/server/routers/__tests__/apiKey.test.ts index 648a70b9..90aa5dfe 100644 --- a/src/server/routers/__tests__/apiKey.test.ts +++ b/src/server/routers/__tests__/apiKey.test.ts @@ -200,6 +200,68 @@ describe("apiKey (access) router — narrowing", () => { ).rejects.toThrow(/scope/i); }); + it("enforces initiative-only narrowing for issue reads and list filters", async () => { + const { fixture, ctx } = await setup(); + const prisma = getPrisma(); + const allowedInitiative = await prisma.initiative.create({ + data: { + workspaceId: fixture.workspace.id, + name: "Allowed initiative", + slug: `allowed-${Date.now().toString(36)}`, + createdById: fixture.user.id, + }, + }); + const otherInitiative = await prisma.initiative.create({ + data: { + workspaceId: fixture.workspace.id, + name: "Other initiative", + slug: `other-${Date.now().toString(36)}`, + createdById: fixture.user.id, + }, + }); + const allowedProject = await prisma.project.create({ + data: { + workspaceId: fixture.workspace.id, + key: `A${Date.now().toString(36)}`.slice(0, 10), + name: "Allowed project", + initiativeId: allowedInitiative.id, + createdById: fixture.user.id, + }, + }); + const otherProject = await prisma.project.create({ + data: { + workspaceId: fixture.workspace.id, + key: `B${Date.now().toString(36)}`.slice(0, 10), + name: "Other project", + initiativeId: otherInitiative.id, + createdById: fixture.user.id, + }, + }); + const allowedIssue = await createIssue(fixture, { projectId: allowedProject.id }); + const otherIssue = await createIssue(fixture, { projectId: otherProject.id }); + const apiKey: ApiKeyContext = { + keyId: "initiative-key", + workspaceId: fixture.workspace.id, + userId: null, + pluginId: null, + scopes: ["READ_ISSUES"], + projectIds: [], + labelIds: [], + initiativeIds: [allowedInitiative.id], + linkedAgentId: null, + }; + + await expect( + assertKeyScope({ apiKey, db: ctx.db }, { entity: "issue", id: allowedIssue.id }), + ).resolves.toBeUndefined(); + await expect( + assertKeyScope({ apiKey, db: ctx.db }, { entity: "issue", id: otherIssue.id }), + ).rejects.toThrow(/scope/i); + expect(buildKeyScopeWhere({ apiKey }, "issue")).toEqual({ + project: { initiativeId: { in: [allowedInitiative.id] } }, + }); + }); + it("issue.list honors narrowing when apiKey is on ctx", async () => { const { fixture, ctx } = await setup(); const prisma = getPrisma(); diff --git a/src/server/routers/__tests__/execution-plan.test.ts b/src/server/routers/__tests__/execution-plan.test.ts index a661d473..1fcb026b 100644 --- a/src/server/routers/__tests__/execution-plan.test.ts +++ b/src/server/routers/__tests__/execution-plan.test.ts @@ -1,5 +1,10 @@ import { afterAll, afterEach, describe, expect, it } from "vitest"; -import { AgentRunStatus, ExecutionPlanStatus, ExecutionStepStatus } from "@prisma/client"; +import { + AgentRunStatus, + EventKind, + ExecutionPlanStatus, + ExecutionStepStatus, +} from "@prisma/client"; import { executionPlanRouter } from "@/server/routers/execution-plan"; import { buildContext, @@ -238,6 +243,71 @@ describe("executionPlanRouter", () => { const got = await caller.get({ id: plan.id }); expect(got.contextSet?.id).toBe(set.id); }); + + it("refuses to archive, delete, or remove steps from a live plan", async () => { + const { caller } = await setup(); + const plan = await caller.create({ + title: "Live guarded plan", + steps: [{ title: "Live root" }], + }); + const draft = await caller.get({ id: plan.id }); + await caller.activate({ id: plan.id }); + + await expect(caller.archive({ id: plan.id })).rejects.toThrow( + /Cancel or complete this plan before archiving/, + ); + await expect(caller.delete({ id: plan.id, confirm: "Live guarded plan" })).rejects.toThrow( + /Cancel or complete this plan before deleting/, + ); + await expect(caller.removeStep({ id: draft.steps[0].id })).rejects.toThrow( + /only be removed while the plan is a draft/, + ); + + const after = await caller.get({ id: plan.id }); + expect(after.archivedAt).toBeNull(); + expect(after.steps).toHaveLength(1); + }); + + it("preserves draft dependency integrity when removing steps", async () => { + const { caller } = await setup(); + const plan = await caller.create({ + title: "Dependency guarded plan", + steps: [ + { title: "Root" }, + { title: "Child", dependsOnStepIndexes: [0] }, + { title: "Independent leaf" }, + ], + }); + const draft = await caller.get({ id: plan.id }); + + await expect(caller.removeStep({ id: draft.steps[0].id })).rejects.toThrow( + /Remove the dependency from "Child"/, + ); + await caller.removeStep({ id: draft.steps[2].id }); + + const after = await caller.get({ id: plan.id }); + expect(after.steps.map((step) => step.title)).toEqual(["Root", "Child"]); + }); + + it("archives a settled plan with an observable change", async () => { + const { fixture, caller } = await setup(); + const plan = await caller.create({ title: "Archivable draft" }); + + await caller.archive({ id: plan.id }); + + const row = await getPrisma().executionPlan.findUniqueOrThrow({ where: { id: plan.id } }); + expect(row.archivedAt).not.toBeNull(); + const event = await getPrisma().activityEvent.findFirst({ + where: { + workspaceId: fixture.workspace.id, + subjectType: "execution-plan", + subjectId: plan.id, + kind: EventKind.ISSUE_UPDATED, + }, + orderBy: { createdAt: "desc" }, + }); + expect(event?.payload).toMatchObject({ action: "archived" }); + }); }); describe("executionPlanRouter — step comments", () => { diff --git a/src/server/routers/__tests__/issue.test.ts b/src/server/routers/__tests__/issue.test.ts index 619196a2..c4fc090f 100644 --- a/src/server/routers/__tests__/issue.test.ts +++ b/src/server/routers/__tests__/issue.test.ts @@ -79,13 +79,24 @@ describe("issueRouter — list filtering", () => { createdById: fixture.user.id, }, }); - const projectIssue = await createIssue(fixture, { title: "project issue", projectId: project.id }); + const projectIssue = await createIssue(fixture, { + title: "project issue", + projectId: project.id, + }); const unfiledIssue = await createIssue(fixture, { title: "unfiled issue" }); - const projectRows = await caller.list({ projectIds: [project.id], includeDone: false, limit: 50 }); + const projectRows = await caller.list({ + projectIds: [project.id], + includeDone: false, + limit: 50, + }); expect(projectRows.items.map((row) => row.id)).toEqual([projectIssue.id]); - const noProjectRows = await caller.list({ withoutProject: true, includeDone: false, limit: 50 }); + const noProjectRows = await caller.list({ + withoutProject: true, + includeDone: false, + limit: 50, + }); expect(noProjectRows.items.map((row) => row.id)).toContain(unfiledIssue.id); expect(noProjectRows.items.map((row) => row.id)).not.toContain(projectIssue.id); }); @@ -145,6 +156,71 @@ describe("issueRouter — blocker-aware claim + narrowing + unblocked flag", () expect(superseded.agentRuns[0].status).toBe(AgentRunStatus.COMPLETED); }); + it("byId keeps goal, plan, contract, and DAG context on a materialized step issue", async () => { + const { caller, fixture } = await setup(); + const prisma = getPrisma(); + const dependencyIssue = await createIssue(fixture, { title: "Dependency issue" }); + const issue = await createIssue(fixture, { title: "Materialized issue" }); + const goal = await prisma.goal.create({ + data: { + workspaceId: fixture.workspace.id, + title: "Ship a coherent workflow", + description: "Make isolated work understandable.", + successCriteria: "Every issue retains its orchestration context.", + createdById: fixture.user.id, + }, + }); + const plan = await prisma.executionPlan.create({ + data: { + workspaceId: fixture.workspace.id, + goalId: goal.id, + title: "Context propagation", + description: "Carry context through every handoff.", + status: "RUNNING", + createdById: fixture.user.id, + }, + }); + const dependency = await prisma.executionStep.create({ + data: { + workspaceId: fixture.workspace.id, + planId: plan.id, + issueId: dependencyIssue.id, + title: "Define the context", + position: 0, + status: "DONE", + }, + }); + await prisma.executionStep.create({ + data: { + workspaceId: fixture.workspace.id, + planId: plan.id, + issueId: issue.id, + title: "Render the context", + body: "Show the user why this issue exists.", + position: 1, + status: "READY", + expectedOutput: "A visible context card.", + verification: [{ id: "visible", label: "Goal and plan are visible", kind: "manual" }], + dependsOnStepIds: [dependency.id], + }, + }); + + const detail = await caller.byId({ id: issue.id }); + expect(detail.executionSteps).toHaveLength(1); + const context = detail.executionSteps[0]; + expect(context.body).toContain("why this issue exists"); + expect(context.expectedOutput).toBe("A visible context card."); + expect(context.dependsOnStepIds).toEqual([dependency.id]); + expect(context.plan.goal).toMatchObject({ + id: goal.id, + title: "Ship a coherent workflow", + successCriteria: "Every issue retains its orchestration context.", + }); + expect(context.plan.steps).toHaveLength(2); + expect(context.plan.steps[0].issue?.id).toBe(dependencyIssue.id); + expect(context.plan.steps[1].issue?.id).toBe(issue.id); + }); + it("queue exposes `unblocked` (true when nothing blocks the issue)", async () => { const { caller, fixture } = await setup(); const prisma = getPrisma(); @@ -254,9 +330,7 @@ describe("issueRouter — blocker-aware claim + narrowing + unblocked flag", () expect("claimedAt" in claimed).toBe(true); // Specific id, out of scope: FORBIDDEN. - await expect(caller.claim({ issueId: outScope.id })).rejects.toThrow( - /scope/i, - ); + await expect(caller.claim({ issueId: outScope.id })).rejects.toThrow(/scope/i); }); }); @@ -298,9 +372,7 @@ describe("issueRouter — bulkSetLabels / bulkAssign / bulkAssignAgent", () => { const redRows = rows.filter((r) => r.labelId === red.id); const blueRows = rows.filter((r) => r.labelId === blue.id); expect(redRows).toHaveLength(0); - expect(blueRows.map((r) => r.issueId).sort()).toEqual( - [a.id, b.id, c.id].sort(), - ); + expect(blueRows.map((r) => r.issueId).sort()).toEqual([a.id, b.id, c.id].sort()); // Audit + activity events per issue — invariant from CLAUDE.md. const audits = await prisma.auditLog.findMany({ @@ -411,9 +483,9 @@ describe("issueRouter — bulkSetLabels / bulkAssign / bulkAssignAgent", () => { data: { email: `outsider-${Date.now()}@example.com` }, }); const a = await createIssue(fixture); - await expect( - caller.bulkAssign({ issueIds: [a.id], claimedById: outsider.id }), - ).rejects.toThrow(/member/i); + await expect(caller.bulkAssign({ issueIds: [a.id], claimedById: outsider.id })).rejects.toThrow( + /member/i, + ); await prisma.user.delete({ where: { id: outsider.id } }).catch(() => {}); }); diff --git a/src/server/routers/__tests__/relation.test.ts b/src/server/routers/__tests__/relation.test.ts index 9b423bb7..a6733706 100644 --- a/src/server/routers/__tests__/relation.test.ts +++ b/src/server/routers/__tests__/relation.test.ts @@ -174,4 +174,74 @@ describe("relationRouter", () => { expect.objectContaining({ from: focus.id, to: child.id, kind: "child" }), ); }); + + it("graphForIssue() derives materialized plan dependencies without storing issue relations", async () => { + const { caller, fixture } = await setup(); + const prisma = getPrisma(); + const prerequisite = await createIssue(fixture, { title: "Prerequisite" }); + const focus = await createIssue(fixture, { title: "Focus step" }); + const downstream = await createIssue(fixture, { title: "Downstream" }); + const plan = await prisma.executionPlan.create({ + data: { + workspaceId: fixture.workspace.id, + title: "Materialized DAG", + status: "RUNNING", + createdById: fixture.user.id, + }, + }); + const first = await prisma.executionStep.create({ + data: { + workspaceId: fixture.workspace.id, + planId: plan.id, + issueId: prerequisite.id, + title: "First", + position: 0, + status: "DONE", + }, + }); + const second = await prisma.executionStep.create({ + data: { + workspaceId: fixture.workspace.id, + planId: plan.id, + issueId: focus.id, + title: "Second", + position: 1, + status: "RUNNING", + dependsOnStepIds: [first.id], + }, + }); + await prisma.executionStep.create({ + data: { + workspaceId: fixture.workspace.id, + planId: plan.id, + issueId: downstream.id, + title: "Third", + position: 2, + status: "TODO", + dependsOnStepIds: [second.id], + }, + }); + + const graph = await caller.graphForIssue({ issueId: focus.id, depth: 1 }); + expect(graph.nodes.map((node) => node.id).sort()).toEqual( + [prerequisite.id, focus.id, downstream.id].sort(), + ); + expect(graph.edges).toContainEqual( + expect.objectContaining({ + from: prerequisite.id, + to: focus.id, + kind: "plan-dependency", + }), + ); + expect(graph.edges).toContainEqual( + expect.objectContaining({ + from: focus.id, + to: downstream.id, + kind: "plan-dependency", + }), + ); + expect(await prisma.issueRelation.count({ where: { workspaceId: fixture.workspace.id } })).toBe( + 0, + ); + }); }); diff --git a/src/server/routers/execution-plan.ts b/src/server/routers/execution-plan.ts index f114872e..8d7f8282 100644 --- a/src/server/routers/execution-plan.ts +++ b/src/server/routers/execution-plan.ts @@ -297,15 +297,25 @@ export const executionPlanRouter = router({ }), ) .mutation(async ({ ctx, input }) => { + const activate = input.status === ExecutionPlanStatus.RUNNING; await updateExecutionPlan(ctx.db, { workspaceId: ctx.workspaceId, actorId: ctx.session?.user?.id ?? null, planId: input.id, title: input.title, description: input.description === undefined ? undefined : input.description, - status: input.status, + status: activate ? undefined : input.status, contextSetId: input.contextSetId === undefined ? undefined : input.contextSetId, }); + if (activate) { + await ctx.db.$transaction((tx) => + activatePlan(tx, { + workspaceId: ctx.workspaceId, + actorId: ctx.session?.user?.id ?? null, + planId: input.id, + }), + ); + } return { ok: true }; }), @@ -419,14 +429,42 @@ export const executionPlanRouter = router({ .mutation(async ({ ctx, input }) => { const plan = await ctx.db.executionPlan.findFirst({ where: { id: input.id, workspaceId: ctx.workspaceId }, - select: { id: true }, + select: { id: true, title: true, status: true, archivedAt: true }, }); if (!plan) { throw new TRPCError({ code: "NOT_FOUND", message: "Execution plan not found." }); } - await ctx.db.executionPlan.update({ - where: { id: input.id }, - data: { archivedAt: new Date() }, + if ( + plan.status === ExecutionPlanStatus.APPROVED || + plan.status === ExecutionPlanStatus.RUNNING || + plan.status === ExecutionPlanStatus.BLOCKED + ) { + throw new TRPCError({ + code: "CONFLICT", + message: "Cancel or complete this plan before archiving it.", + }); + } + if (plan.archivedAt) return { ok: true }; + const archivedAt = new Date(); + await ctx.db.$transaction(async (tx) => { + await tx.executionPlan.update({ + where: { id: input.id }, + data: { archivedAt }, + }); + await recordChange(tx, { + workspaceId: ctx.workspaceId, + actorId: ctx.session?.user?.id ?? null, + actorAgentId: ctx.apiKey?.linkedAgentId ?? null, + entity: "execution-plan", + entityId: plan.id, + action: "archive", + before: { archivedAt: plan.archivedAt, status: plan.status }, + after: { archivedAt, status: plan.status }, + eventKind: EventKind.ISSUE_UPDATED, + subjectType: "execution-plan", + subjectId: plan.id, + payload: { action: "archived", planTitle: plan.title }, + }); }); return { ok: true }; }), @@ -591,6 +629,16 @@ export const executionPlanRouter = router({ message: "Confirmation text does not match the plan title.", }); } + if ( + plan.status === ExecutionPlanStatus.APPROVED || + plan.status === ExecutionPlanStatus.RUNNING || + plan.status === ExecutionPlanStatus.BLOCKED + ) { + throw new TRPCError({ + code: "CONFLICT", + message: "Cancel or complete this plan before deleting it.", + }); + } await ctx.db.$transaction(async (tx) => { await recordChange(tx, { workspaceId: ctx.workspaceId, @@ -677,15 +725,61 @@ export const executionPlanRouter = router({ .mutation(async ({ ctx, input }) => { const step = await ctx.db.executionStep.findFirst({ where: { id: input.id, workspaceId: ctx.workspaceId }, - select: { id: true, planId: true }, + select: { id: true, title: true, planId: true, plan: { select: { status: true } } }, }); if (!step) { throw new TRPCError({ code: "NOT_FOUND", message: "Execution step not found." }); } - await ctx.db.executionStep.delete({ where: { id: input.id } }); - await ctx.db.executionPlan.update({ - where: { id: step.planId }, - data: { updatedAt: new Date() }, + if (step.plan.status !== ExecutionPlanStatus.DRAFT) { + throw new TRPCError({ + code: "CONFLICT", + message: "Steps can only be removed while the plan is a draft.", + }); + } + const [dependent, liveRun] = await Promise.all([ + ctx.db.executionStep.findFirst({ + where: { planId: step.planId, dependsOnStepIds: { has: step.id } }, + select: { id: true, title: true }, + }), + ctx.db.agentRun.findFirst({ + where: { + executionStepId: step.id, + status: { in: ["ACTIVE", "WAITING"] }, + }, + select: { id: true }, + }), + ]); + if (dependent) { + throw new TRPCError({ + code: "CONFLICT", + message: `Remove the dependency from "${dependent.title}" before deleting this step.`, + }); + } + if (liveRun) { + throw new TRPCError({ + code: "CONFLICT", + message: "Stop the active run before deleting this step.", + }); + } + await ctx.db.$transaction(async (tx) => { + await recordChange(tx, { + workspaceId: ctx.workspaceId, + actorId: ctx.session?.user?.id ?? null, + actorAgentId: ctx.apiKey?.linkedAgentId ?? null, + entity: "execution-step", + entityId: step.id, + action: "delete", + before: { title: step.title, planId: step.planId }, + eventKind: EventKind.ISSUE_UPDATED, + subjectType: "execution-step", + subjectId: step.id, + payload: { action: "deleted", planId: step.planId, stepTitle: step.title }, + }); + await tx.executionStep.delete({ where: { id: input.id } }); + await tx.executionPlan.update({ + where: { id: step.planId }, + data: { updatedAt: new Date() }, + }); }); return { ok: true }; }), diff --git a/src/server/routers/issue.ts b/src/server/routers/issue.ts index 1a2aae65..f57f9683 100644 --- a/src/server/routers/issue.ts +++ b/src/server/routers/issue.ts @@ -1,14 +1,20 @@ import { z } from "zod"; import { TRPCError } from "@trpc/server"; -import { AgentRunStatus, EngagementMode, EventKind, Prisma, Priority, RelationKind, StatusCategory, WorkItemKind } from "@prisma/client"; +import { + AgentRunStatus, + EngagementMode, + EventKind, + Prisma, + Priority, + RelationKind, + StatusCategory, + WorkItemKind, +} from "@prisma/client"; import type { PrismaClient } from "@prisma/client"; import { router, workspaceProcedure } from "@/server/trpc"; import { recordChange } from "@/server/audit"; import { assertKeyScope, buildKeyScopeWhere } from "@/server/services/api-key-auth"; -import { - maybeAutoDispatch, - recordManualDispatchReason, -} from "@/server/services/dispatcher"; +import { maybeAutoDispatch, recordManualDispatchReason } from "@/server/services/dispatcher"; import { maybeApplyAgentTemplate } from "@/server/services/agent-template"; import { triageIssue } from "@/server/services/ai-triage"; import { createIssueWithSideEffects } from "@/server/services/issue-create"; @@ -117,9 +123,7 @@ async function applySlashCommandsToIssue(opts: { // auto-transition gate (maybeAutoTransitionOnAssign) sees the real // mode — otherwise a slash /assign to a non-EXECUTE agent silently // auto-starts the issue. - const { resolveEngagementMode } = await import( - "@/server/services/engagement-mode" - ); + const { resolveEngagementMode } = await import("@/server/services/engagement-mode"); const ws = await tx.workspace.findUniqueOrThrow({ where: { id: opts.workspaceId }, select: { @@ -335,9 +339,7 @@ const filterSchema = z.object({ .refine((s) => { const [y, m, d] = s.split("-").map(Number); const dt = new Date(Date.UTC(y, m - 1, d)); - return ( - dt.getUTCFullYear() === y && dt.getUTCMonth() === m - 1 && dt.getUTCDate() === d - ); + return dt.getUTCFullYear() === y && dt.getUTCMonth() === m - 1 && dt.getUTCDate() === d; }, "dueOn must be a real calendar date") .optional(), @@ -487,9 +489,7 @@ async function buildIssueListWhere( ...(input.assigneeIds?.length ? { assignees: { some: { userId: { in: input.assigneeIds } } } } : {}), - ...(input.labelIds?.length - ? { labels: { some: { labelId: { in: input.labelIds } } } } - : {}), + ...(input.labelIds?.length ? { labels: { some: { labelId: { in: input.labelIds } } } } : {}), ...(input.priority ? { priority: input.priority } : {}), ...(input.priorities?.length ? { priority: { in: input.priorities } } : {}), ...(input.kinds?.length ? { kind: { in: input.kinds } } : {}), @@ -506,9 +506,7 @@ async function buildIssueListWhere( : input.assignedAgentId ? { assignedAgentId: input.assignedAgentId } : {}), - ...(input.updatedSince - ? { updatedAt: { gte: updatedSinceToDate(input.updatedSince) } } - : {}), + ...(input.updatedSince ? { updatedAt: { gte: updatedSinceToDate(input.updatedSince) } } : {}), ...(input.excludeSnoozed ? { OR: [{ snoozedUntil: null }, { snoozedUntil: { lte: new Date() } }] } : {}), @@ -704,15 +702,57 @@ export const issueRouter = router({ orderBy: { number: "asc" }, }, parent: { select: { id: true, number: true, title: true } }, - // Plan-step provenance (AXI-56): when this issue was materialized - // from an ExecutionStep, surface the originating step + plan so the - // issue can deep-link back to its plan. + // Plan-step provenance and execution context (AXI-56). A + // materialized step is still a standalone Issue, but its detail + // view must retain the goal, completion contract, and neighboring + // DAG so a human or agent never has to infer why the issue exists. executionSteps: { select: { id: true, title: true, + body: true, position: true, - plan: { select: { id: true, title: true } }, + status: true, + expectedOutput: true, + verification: true, + dependsOnStepIds: true, + retryCount: true, + lastFeedback: true, + plan: { + select: { + id: true, + title: true, + description: true, + status: true, + goal: { + select: { + id: true, + title: true, + description: true, + successCriteria: true, + status: true, + }, + }, + steps: { + orderBy: { position: "asc" }, + select: { + id: true, + title: true, + position: true, + status: true, + dependsOnStepIds: true, + issue: { + select: { + id: true, + number: true, + title: true, + status: { select: { name: true, category: true, color: true } }, + }, + }, + }, + }, + }, + }, }, }, }, @@ -843,9 +883,7 @@ export const issueRouter = router({ workspaceId: ctx.workspaceId, OR: [ { subjectType: "issue", subjectId: input.issueId }, - ...(runIds.length - ? [{ subjectType: "agent-run", subjectId: { in: runIds } }] - : []), + ...(runIds.length ? [{ subjectType: "agent-run", subjectId: { in: runIds } }] : []), ...(actionRequestIds.length ? [{ subjectType: "action-request", subjectId: { in: actionRequestIds } }] : []), @@ -1228,8 +1266,7 @@ export const issueRouter = router({ // Manual assignment / unassignment — stamp a `dispatchReason` // when an agent is being set, clear it on unassign. let manualReason: Record | null = null; - let agentRow: { profileKey: string; engagementMode: EngagementMode | null } | null = - null; + let agentRow: { profileKey: string; engagementMode: EngagementMode | null } | null = null; if (nextAgentId) { agentRow = await tx.agent.findFirstOrThrow({ where: { id: nextAgentId, workspaceId: ctx.workspaceId }, @@ -1256,9 +1293,7 @@ export const issueRouter = router({ let engagementMode: EngagementMode | undefined; let engagementSource: string | undefined; if (nextAgentId && agentRow) { - const { resolveEngagementMode } = await import( - "@/server/services/engagement-mode" - ); + const { resolveEngagementMode } = await import("@/server/services/engagement-mode"); const ws = await tx.workspace.findUniqueOrThrow({ where: { id: ctx.workspaceId }, select: { @@ -1821,8 +1856,7 @@ export const issueRouter = router({ // Resolve the bulk-set agent once for dispatchReason + engagement // mode stamping (only when assigning, not on clear). - let bulkAgent: { profileKey: string; engagementMode: EngagementMode | null } | null = - null; + let bulkAgent: { profileKey: string; engagementMode: EngagementMode | null } | null = null; if (input.assignedAgentId) { const a = await tx.agent.findUnique({ where: { id: input.assignedAgentId }, @@ -1856,9 +1890,7 @@ export const issueRouter = router({ let engagementMode: EngagementMode | undefined; let engagementSource: string | undefined; if (input.assignedAgentId && bulkAgent) { - const { resolveEngagementMode } = await import( - "@/server/services/engagement-mode" - ); + const { resolveEngagementMode } = await import("@/server/services/engagement-mode"); const ws = await tx.workspace.findUniqueOrThrow({ where: { id: ctx.workspaceId }, select: { @@ -1883,7 +1915,8 @@ export const issueRouter = router({ for (let i = 0; i < validIds.length; i += CHUNK) { const chunk = issues.slice(i, i + CHUNK); for (const row of chunk) { - const assignmentChanged = (row.assignedAgentId ?? null) !== (input.assignedAgentId ?? null); + const assignmentChanged = + (row.assignedAgentId ?? null) !== (input.assignedAgentId ?? null); await setIssueAgentWakeTarget(tx, { workspaceId: ctx.workspaceId, issueId: row.id, @@ -2336,9 +2369,7 @@ export const issueRouter = router({ action: input.until ? "snooze" : "unsnooze", before: { snoozedUntil: row.snoozedUntil }, after: { snoozedUntil: input.until }, - eventKind: input.until - ? EventKind.ISSUE_SNOOZED - : EventKind.ISSUE_UNSNOOZED, + eventKind: input.until ? EventKind.ISSUE_SNOOZED : EventKind.ISSUE_UNSNOOZED, subjectType: "issue", subjectId: row.id, payload: input.until @@ -2388,13 +2419,9 @@ export const issueRouter = router({ }); const daysSince = Math.max( 1, - Math.round( - (Date.now() - issue.updatedAt.getTime()) / (24 * 60 * 60 * 1000), - ), + Math.round((Date.now() - issue.updatedAt.getTime()) / (24 * 60 * 60 * 1000)), ); - const mention = issue.assignedAgent - ? `@${issue.assignedAgent.profileKey} ` - : ""; + const mention = issue.assignedAgent ? `@${issue.assignedAgent.profileKey} ` : ""; const body = input.body ?? `${mention}Gentle nudge — this issue has been quiet for ${daysSince} day${daysSince === 1 ? "" : "s"}.`; @@ -2937,9 +2964,7 @@ export const issueRouter = router({ issue: r.issue!, })); items.sort( - (a, b) => - new Date(b.issue.updatedAt).getTime() - - new Date(a.issue.updatedAt).getTime(), + (a, b) => new Date(b.issue.updatedAt).getTime() - new Date(a.issue.updatedAt).getTime(), ); return { items }; }), @@ -2967,56 +2992,54 @@ export const issueRouter = router({ * construction on the client. Empty array when the user watches * nothing. */ - unreadIds: workspaceProcedure - .input(z.object({}).optional()) - .query(async ({ ctx }) => { - const watches = await ctx.db.issueWatcher.findMany({ - where: { - workspaceId: ctx.workspaceId, - userId: ctx.session.user.id, - }, - select: { - issueId: true, - issue: { select: { id: true, updatedAt: true, deletedAt: true } }, - }, - }); - if (watches.length === 0) return { ids: [] as string[] }; - - const watchedIds = watches - .filter((w) => w.issue && w.issue.deletedAt === null) - .map((w) => w.issueId); - if (watchedIds.length === 0) return { ids: [] as string[] }; + unreadIds: workspaceProcedure.input(z.object({}).optional()).query(async ({ ctx }) => { + const watches = await ctx.db.issueWatcher.findMany({ + where: { + workspaceId: ctx.workspaceId, + userId: ctx.session.user.id, + }, + select: { + issueId: true, + issue: { select: { id: true, updatedAt: true, deletedAt: true } }, + }, + }); + if (watches.length === 0) return { ids: [] as string[] }; + + const watchedIds = watches + .filter((w) => w.issue && w.issue.deletedAt === null) + .map((w) => w.issueId); + if (watchedIds.length === 0) return { ids: [] as string[] }; + + // Pull the user's `RecentItem.visitedAt` for each watched issue + // in one query. Missing rows = never-viewed = always unread. + const recents = await ctx.db.recentItem.findMany({ + where: { + userId: ctx.session.user.id, + workspaceId: ctx.workspaceId, + targetType: "ISSUE", + targetId: { in: watchedIds }, + }, + select: { targetId: true, visitedAt: true }, + }); + const visitedAtByIssue = new Map(); + for (const r of recents) { + visitedAtByIssue.set(r.targetId, r.visitedAt); + } - // Pull the user's `RecentItem.visitedAt` for each watched issue - // in one query. Missing rows = never-viewed = always unread. - const recents = await ctx.db.recentItem.findMany({ - where: { - userId: ctx.session.user.id, - workspaceId: ctx.workspaceId, - targetType: "ISSUE", - targetId: { in: watchedIds }, - }, - select: { targetId: true, visitedAt: true }, - }); - const visitedAtByIssue = new Map(); - for (const r of recents) { - visitedAtByIssue.set(r.targetId, r.visitedAt); + const ids: string[] = []; + for (const w of watches) { + if (!w.issue || w.issue.deletedAt !== null) continue; + const visitedAt = visitedAtByIssue.get(w.issueId); + if (!visitedAt) { + ids.push(w.issueId); + continue; } - - const ids: string[] = []; - for (const w of watches) { - if (!w.issue || w.issue.deletedAt !== null) continue; - const visitedAt = visitedAtByIssue.get(w.issueId); - if (!visitedAt) { - ids.push(w.issueId); - continue; - } - if (w.issue.updatedAt.getTime() > visitedAt.getTime()) { - ids.push(w.issueId); - } + if (w.issue.updatedAt.getTime() > visitedAt.getTime()) { + ids.push(w.issueId); } - return { ids }; - }), + } + return { ids }; + }), }); // -- Helpers ---------------------------------------------------------------- diff --git a/src/server/routers/relation.ts b/src/server/routers/relation.ts index bea5376a..24f41f67 100644 --- a/src/server/routers/relation.ts +++ b/src/server/routers/relation.ts @@ -231,7 +231,13 @@ export const relationRouter = router({ Array<{ relationId: string; kind: RelationKind; - target: { id: string; number: number; title: string; statusId: string; statusCategory: string }; + target: { + id: string; + number: number; + title: string; + statusId: string; + statusCategory: string; + }; }> > = { [RelationKind.BLOCKS]: [], @@ -267,114 +273,164 @@ export const relationRouter = router({ * direction, regardless of which side we started from. * - **child** — directed `parent → child` via `Issue.parentId`, * surfacing the sub-issue hierarchy alongside dependencies. + * - **plan-dependency** — derived from + * `ExecutionStep.dependsOnStepIds` when both steps have materialized + * issues. These edges are intentionally not copied into + * `IssueRelation`; the plan DAG remains the single source of truth. * * Returns flat `nodes` (with `isCurrent` flagging the focus issue) and * directed `edges`; the client lays them out. Node count is capped at * `GRAPH_MAX_NODES` — once hit, expansion stops and any dangling edges * to undiscovered nodes are dropped so the graph stays consistent. */ - graphForIssue: workspaceProcedure - .input(graphForIssueInput) - .query(async ({ ctx, input }) => { - const root = await ctx.db.issue.findFirst({ - where: { id: input.issueId, workspaceId: ctx.workspaceId, deletedAt: null }, - select: { id: true }, - }); - if (!root) throw new TRPCError({ code: "NOT_FOUND" }); - - type Edge = { id: string; from: string; to: string; kind: "blocks" | "child" }; - const nodeIds = new Set([root.id]); - const edges = new Map(); - const addEdge = (from: string, to: string, kind: Edge["kind"]) => { - const id = `${from}->${to}:${kind}`; - if (!edges.has(id)) edges.set(id, { id, from, to, kind }); + graphForIssue: workspaceProcedure.input(graphForIssueInput).query(async ({ ctx, input }) => { + const root = await ctx.db.issue.findFirst({ + where: { id: input.issueId, workspaceId: ctx.workspaceId, deletedAt: null }, + select: { id: true }, + }); + if (!root) throw new TRPCError({ code: "NOT_FOUND" }); + + type Edge = { + id: string; + from: string; + to: string; + kind: "blocks" | "child" | "plan-dependency"; + }; + const nodeIds = new Set([root.id]); + const edges = new Map(); + const addEdge = (from: string, to: string, kind: Edge["kind"]) => { + const id = `${from}->${to}:${kind}`; + if (!edges.has(id)) edges.set(id, { id, from, to, kind }); + }; + + let frontier = [root.id]; + for (let hop = 0; hop < input.depth && frontier.length > 0; hop++) { + if (nodeIds.size >= GRAPH_MAX_NODES) break; + const discovered = new Set(); + const note = (id: string) => { + if (!nodeIds.has(id)) discovered.add(id); }; - let frontier = [root.id]; - for (let hop = 0; hop < input.depth && frontier.length > 0; hop++) { - if (nodeIds.size >= GRAPH_MAX_NODES) break; - const discovered = new Set(); - const note = (id: string) => { - if (!nodeIds.has(id)) discovered.add(id); - }; + const [blocks, frontierIssues, children, frontierSteps] = await Promise.all([ + // Blocking edges touching the frontier, in either direction. + ctx.db.issueRelation.findMany({ + where: { + workspaceId: ctx.workspaceId, + kind: RelationKind.BLOCKS, + OR: [{ fromIssueId: { in: frontier } }, { toIssueId: { in: frontier } }], + }, + select: { fromIssueId: true, toIssueId: true }, + }), + // The frontier issues' own parents (child → up to parent). + ctx.db.issue.findMany({ + where: { id: { in: frontier }, workspaceId: ctx.workspaceId, deletedAt: null }, + select: { id: true, parentId: true }, + }), + // The frontier issues' children (parent → down to child). + ctx.db.issue.findMany({ + where: { parentId: { in: frontier }, workspaceId: ctx.workspaceId, deletedAt: null }, + select: { id: true, parentId: true }, + }), + // A frontier issue can be the materialized form of a plan step. + // Fetch the step identity first; its plan scopes the derived DAG + // lookup below and prevents cross-workspace/cross-plan edges. + ctx.db.executionStep.findMany({ + where: { + workspaceId: ctx.workspaceId, + issueId: { in: frontier }, + }, + select: { id: true, issueId: true, planId: true }, + }), + ]); - const [blocks, frontierIssues, children] = await Promise.all([ - // Blocking edges touching the frontier, in either direction. - ctx.db.issueRelation.findMany({ - where: { - workspaceId: ctx.workspaceId, - kind: RelationKind.BLOCKS, - OR: [{ fromIssueId: { in: frontier } }, { toIssueId: { in: frontier } }], - }, - select: { fromIssueId: true, toIssueId: true }, - }), - // The frontier issues' own parents (child → up to parent). - ctx.db.issue.findMany({ - where: { id: { in: frontier }, workspaceId: ctx.workspaceId, deletedAt: null }, - select: { id: true, parentId: true }, - }), - // The frontier issues' children (parent → down to child). - ctx.db.issue.findMany({ - where: { parentId: { in: frontier }, workspaceId: ctx.workspaceId, deletedAt: null }, - select: { id: true, parentId: true }, - }), - ]); - - for (const r of blocks) { - addEdge(r.fromIssueId, r.toIssueId, "blocks"); - note(r.fromIssueId); - note(r.toIssueId); + for (const r of blocks) { + addEdge(r.fromIssueId, r.toIssueId, "blocks"); + note(r.fromIssueId); + note(r.toIssueId); + } + for (const i of frontierIssues) { + if (i.parentId) { + addEdge(i.parentId, i.id, "child"); + note(i.parentId); } - for (const i of frontierIssues) { - if (i.parentId) { - addEdge(i.parentId, i.id, "child"); - note(i.parentId); - } + } + for (const c of children) { + if (c.parentId) { + addEdge(c.parentId, c.id, "child"); + note(c.id); } - for (const c of children) { - if (c.parentId) { - addEdge(c.parentId, c.id, "child"); - note(c.id); + } + + const planIds = [...new Set(frontierSteps.map((step) => step.planId))]; + if (planIds.length > 0) { + const planSteps = await ctx.db.executionStep.findMany({ + where: { + workspaceId: ctx.workspaceId, + planId: { in: planIds }, + issueId: { not: null }, + issue: { is: { deletedAt: null } }, + }, + select: { id: true, issueId: true, dependsOnStepIds: true }, + }); + const byStepId = new Map(planSteps.map((step) => [step.id, step])); + const frontierIssueIds = new Set(frontier); + + for (const dependent of planSteps) { + if (!dependent.issueId) continue; + for (const dependencyStepId of dependent.dependsOnStepIds) { + const dependency = byStepId.get(dependencyStepId); + if (!dependency?.issueId || dependency.issueId === dependent.issueId) continue; + // At this hop, only add edges that touch the frontier. Other + // edges will be discovered when their node enters the next + // frontier, preserving the requested depth semantics. + if ( + !frontierIssueIds.has(dependency.issueId) && + !frontierIssueIds.has(dependent.issueId) + ) { + continue; + } + addEdge(dependency.issueId, dependent.issueId, "plan-dependency"); + note(dependency.issueId); + note(dependent.issueId); } } + } - // Admit newly discovered nodes up to the cap; they seed next hop. - const next: string[] = []; - for (const id of discovered) { - if (nodeIds.size >= GRAPH_MAX_NODES) break; - nodeIds.add(id); - next.push(id); - } - frontier = next; + // Admit newly discovered nodes up to the cap; they seed next hop. + const next: string[] = []; + for (const id of discovered) { + if (nodeIds.size >= GRAPH_MAX_NODES) break; + nodeIds.add(id); + next.push(id); } + frontier = next; + } - const issues = await ctx.db.issue.findMany({ - where: { id: { in: [...nodeIds] }, workspaceId: ctx.workspaceId }, - select: { - id: true, - number: true, - title: true, - priority: true, - status: { select: { category: true, color: true } }, - }, - }); + const issues = await ctx.db.issue.findMany({ + where: { id: { in: [...nodeIds] }, workspaceId: ctx.workspaceId, deletedAt: null }, + select: { + id: true, + number: true, + title: true, + priority: true, + status: { select: { category: true, color: true } }, + }, + }); - const nodes = issues.map((i) => ({ - id: i.id, - number: i.number, - title: i.title, - priority: i.priority, - statusCategory: i.status.category, - statusColor: i.status.color, - isCurrent: i.id === root.id, - })); - - // Drop edges to nodes we never materialized (cap-truncated frontier). - const present = new Set(nodes.map((n) => n.id)); - const edgeList = [...edges.values()].filter( - (e) => present.has(e.from) && present.has(e.to), - ); - - return { nodes, edges: edgeList, truncated: nodeIds.size >= GRAPH_MAX_NODES }; - }), + const nodes = issues.map((i) => ({ + id: i.id, + number: i.number, + title: i.title, + priority: i.priority, + statusCategory: i.status.category, + statusColor: i.status.color, + isCurrent: i.id === root.id, + })); + + // Drop edges to nodes we never materialized (cap-truncated frontier). + const present = new Set(nodes.map((n) => n.id)); + const edgeList = [...edges.values()].filter((e) => present.has(e.from) && present.has(e.to)); + + return { nodes, edges: edgeList, truncated: nodeIds.size >= GRAPH_MAX_NODES }; + }), }); diff --git a/src/server/services/__tests__/agent-dispatch-inbox.test.ts b/src/server/services/__tests__/agent-dispatch-inbox.test.ts index 70ebd63b..14e020c3 100644 --- a/src/server/services/__tests__/agent-dispatch-inbox.test.ts +++ b/src/server/services/__tests__/agent-dispatch-inbox.test.ts @@ -100,6 +100,118 @@ describe("agent-dispatch-inbox — ensureCanonicalFromEvent", () => { expect(run!.assignmentEventId).toBe(run!.triggerEventId); }); + it("binds an ordinary materialized-issue assignment back to its single active step", async () => { + const fixture = await createWorkspaceFixture({ keyPrefix: "INS" }); + fixtures.push(fixture); + const prisma = getPrisma(); + const agent = await createAgent(fixture.workspace.id, "ins-a1"); + const issue = await createIssue(fixture); + const plan = await prisma.executionPlan.create({ + data: { workspaceId: fixture.workspace.id, title: "Materialized", status: "RUNNING" }, + }); + const step = await prisma.executionStep.create({ + data: { + workspaceId: fixture.workspace.id, + planId: plan.id, + issueId: issue.id, + title: "Implement", + position: 0, + status: "READY", + }, + }); + await prisma.issue.update({ where: { id: issue.id }, data: { assignedAgentId: agent.id } }); + + await prisma.$transaction((tx) => + recordChange(tx, { + workspaceId: fixture.workspace.id, + actorId: fixture.user.id, + entity: "Issue", + entityId: issue.id, + action: "assign", + eventKind: EventKind.AGENT_ASSIGNED, + subjectType: "issue", + subjectId: issue.id, + payload: { agentId: agent.id }, + }), + ); + + const run = await prisma.agentRun.findFirstOrThrow({ + where: { workspaceId: fixture.workspace.id, issueId: issue.id, agentId: agent.id }, + }); + expect(run.executionStepId).toBe(step.id); + expect(await prisma.executionStep.findUniqueOrThrow({ where: { id: step.id } })).toMatchObject({ + assignedAgentId: agent.id, + }); + const inbox = await listInbox(prisma, { + workspaceId: fixture.workspace.id, + agentId: agent.id, + filter: "active", + limit: 10, + }); + expect(inbox.find((item) => item.kind === "run")).toMatchObject({ + executionStepId: step.id, + orchestrationContext: { + plan: { id: plan.id }, + step: { id: step.id }, + }, + }); + }); + + it("schedules a blocked materialized step without opening work before readiness", async () => { + const fixture = await createWorkspaceFixture({ keyPrefix: "IND" }); + fixtures.push(fixture); + const prisma = getPrisma(); + const agent = await createAgent(fixture.workspace.id, "ind-a1"); + const issue = await createIssue(fixture); + const plan = await prisma.executionPlan.create({ + data: { workspaceId: fixture.workspace.id, title: "Dependencies", status: "RUNNING" }, + }); + const blocker = await prisma.executionStep.create({ + data: { + workspaceId: fixture.workspace.id, + planId: plan.id, + title: "Blocker", + position: 0, + status: "READY", + }, + }); + const child = await prisma.executionStep.create({ + data: { + workspaceId: fixture.workspace.id, + planId: plan.id, + issueId: issue.id, + title: "Blocked child", + position: 1, + status: "TODO", + dependsOnStepIds: [blocker.id], + }, + }); + await prisma.issue.update({ where: { id: issue.id }, data: { assignedAgentId: agent.id } }); + + await prisma.$transaction((tx) => + recordChange(tx, { + workspaceId: fixture.workspace.id, + actorId: fixture.user.id, + entity: "Issue", + entityId: issue.id, + action: "assign", + eventKind: EventKind.AGENT_ASSIGNED, + subjectType: "issue", + subjectId: issue.id, + payload: { agentId: agent.id }, + }), + ); + + expect(await prisma.executionStep.findUniqueOrThrow({ where: { id: child.id } })).toMatchObject( + { status: "TODO", assignedAgentId: agent.id }, + ); + expect( + await prisma.agentRun.count({ + where: { workspaceId: fixture.workspace.id, issueId: issue.id, agentId: agent.id }, + }), + ).toBe(0); + }); + it("creates one run per agent for COMMENT_CREATED mentions", async () => { const fixture = await createWorkspaceFixture({ keyPrefix: "INM" }); fixtures.push(fixture); @@ -494,6 +606,53 @@ describe("agent-dispatch-inbox — markOutputStarted", () => { expect(second.alreadyStarted).toBe(true); expect(second.outputStartedAt.getTime()).toBe(first.outputStartedAt.getTime()); }); + + it("moves the linked execution step from READY to RUNNING", async () => { + const fixture = await createWorkspaceFixture({ keyPrefix: "OSR" }); + fixtures.push(fixture); + const prisma = getPrisma(); + const agent = await createAgent(fixture.workspace.id, "osr-a1"); + const issue = await createIssue(fixture); + const plan = await prisma.executionPlan.create({ + data: { workspaceId: fixture.workspace.id, title: "Start work", status: "RUNNING" }, + }); + const step = await prisma.executionStep.create({ + data: { + workspaceId: fixture.workspace.id, + planId: plan.id, + issueId: issue.id, + title: "Execute", + position: 0, + status: "READY", + }, + }); + const run = await prisma.agentRun.create({ + data: { + workspaceId: fixture.workspace.id, + issueId: issue.id, + agentId: agent.id, + executionStepId: step.id, + status: "ACTIVE", + }, + }); + + await prisma.$transaction((tx) => + markOutputStarted(tx, { + workspaceId: fixture.workspace.id, + agentId: agent.id, + target: { runId: run.id }, + }), + ); + + expect(await prisma.executionStep.findUniqueOrThrow({ where: { id: step.id } })).toMatchObject({ + status: "RUNNING", + }); + expect( + await prisma.auditLog.count({ + where: { workspaceId: fixture.workspace.id, entityId: step.id, action: "start" }, + }), + ).toBe(1); + }); }); describe("agent-dispatch-inbox — state derivation", () => { diff --git a/src/server/services/__tests__/mcp.test.ts b/src/server/services/__tests__/mcp.test.ts index 854fe276..e6e9e751 100644 --- a/src/server/services/__tests__/mcp.test.ts +++ b/src/server/services/__tests__/mcp.test.ts @@ -1809,6 +1809,52 @@ describe("mcp — awareness tools (Stream BA)", () => { expect(Array.isArray(full.labels)).toBe(true); }); + it("issues.get currentRun ignores terminal runs and scopes to the linked agent", async () => { + const f = await createWorkspaceFixture({ keyPrefix: "BCR" }); + fixtures.push(f); + const prisma = getPrisma(); + const issue = await createIssue(f, { title: "current run semantics" }); + const linked = await prisma.agent.create({ + data: { workspaceId: f.workspace.id, profileKey: "bcr-linked", name: "Linked" }, + }); + const other = await prisma.agent.create({ + data: { workspaceId: f.workspace.id, profileKey: "bcr-other", name: "Other" }, + }); + await prisma.agentRun.create({ + data: { + workspaceId: f.workspace.id, + issueId: issue.id, + agentId: linked.id, + status: AgentRunStatus.STALLED, + finishedAt: new Date(), + }, + }); + const otherRun = await prisma.agentRun.create({ + data: { + workspaceId: f.workspace.id, + issueId: issue.id, + agentId: other.id, + status: AgentRunStatus.WAITING, + }, + }); + + const { ctx: linkedCtx } = buildMcpCtx(f, { linkedAgentId: linked.id }); + const linkedView = (await call( + "issues.get", + { id: issue.id, include: { currentRun: true } }, + linkedCtx, + )) as { currentRun: { id: string } | null }; + expect(linkedView.currentRun).toBeNull(); + + const { ctx: operatorCtx } = buildMcpCtx(f); + const operatorView = (await call( + "issues.get", + { id: issue.id, include: { currentRun: true } }, + operatorCtx, + )) as { currentRun: { id: string } | null }; + expect(operatorView.currentRun?.id).toBe(otherRun.id); + }); + it("runtimes.list returns workspace runtimes; ADMIN required", async () => { const f = await createWorkspaceFixture({ keyPrefix: "BAR" }); fixtures.push(f); @@ -2188,6 +2234,98 @@ describe("mcp — awareness tools (Stream BA)", () => { expect(bundle.runProtocol.mayMutateIssue).toBe(false); }); + it("agent.context.bundle ignores stalled and other-agent runs for the linked agent", async () => { + const f = await createWorkspaceFixture({ keyPrefix: "BAC" }); + fixtures.push(f); + const prisma = getPrisma(); + const linkedAgent = await prisma.agent.create({ + data: { workspaceId: f.workspace.id, profileKey: "bac-linked", name: "Linked" }, + }); + const otherAgent = await prisma.agent.create({ + data: { workspaceId: f.workspace.id, profileKey: "bac-other", name: "Other" }, + }); + const issue = await createIssue(f, { title: "canonical current run" }); + await prisma.agentRun.create({ + data: { + workspaceId: f.workspace.id, + issueId: issue.id, + agentId: linkedAgent.id, + status: AgentRunStatus.STALLED, + finishedAt: new Date(), + }, + }); + await prisma.agentRun.create({ + data: { + workspaceId: f.workspace.id, + issueId: issue.id, + agentId: otherAgent.id, + status: AgentRunStatus.ACTIVE, + }, + }); + const { ctx } = buildMcpCtx(f, { linkedAgentId: linkedAgent.id }); + + const bundle = (await call("agent.context.bundle", { issueId: issue.id }, ctx)) as { + currentRun: { id: string } | null; + runProtocol: { runId: string | null }; + }; + expect(bundle.currentRun).toBeNull(); + expect(bundle.runProtocol.runId).toBeNull(); + }); + + it("agent.context.bundle includes the run's goal and plan-step context", async () => { + const f = await createWorkspaceFixture({ keyPrefix: "BAO" }); + fixtures.push(f); + const prisma = getPrisma(); + const agent = await prisma.agent.create({ + data: { workspaceId: f.workspace.id, profileKey: "bao-agent", name: "Plan worker" }, + }); + const issue = await createIssue(f, { title: "materialized plan step" }); + const goal = await prisma.goal.create({ + data: { workspaceId: f.workspace.id, title: "Deliver renderer", status: "ACTIVE" }, + }); + const plan = await prisma.executionPlan.create({ + data: { + workspaceId: f.workspace.id, + goalId: goal.id, + title: "Renderer plan", + status: "RUNNING", + }, + }); + const step = await prisma.executionStep.create({ + data: { + workspaceId: f.workspace.id, + planId: plan.id, + issueId: issue.id, + title: "Implement renderer", + position: 0, + status: "RUNNING", + }, + }); + await prisma.agentRun.create({ + data: { + workspaceId: f.workspace.id, + issueId: issue.id, + agentId: agent.id, + executionStepId: step.id, + status: AgentRunStatus.ACTIVE, + }, + }); + const { ctx } = buildMcpCtx(f, { linkedAgentId: agent.id }); + + const bundle = (await call("agent.context.bundle", { issueId: issue.id }, ctx)) as { + orchestrationContext: { + goal: { id: string } | null; + plan: { id: string }; + step: { id: string }; + } | null; + }; + expect(bundle.orchestrationContext).toMatchObject({ + goal: { id: goal.id }, + plan: { id: plan.id }, + step: { id: step.id }, + }); + }); + it("agent.context.bundle threadId branch enforces addressee", async () => { const f = await createWorkspaceFixture({ keyPrefix: "BAB2" }); fixtures.push(f); diff --git a/src/server/services/__tests__/orchestration-context.test.ts b/src/server/services/__tests__/orchestration-context.test.ts new file mode 100644 index 00000000..49199f1c --- /dev/null +++ b/src/server/services/__tests__/orchestration-context.test.ts @@ -0,0 +1,222 @@ +import { afterAll, afterEach, describe, expect, it } from "vitest"; +import { + formatOrchestrationContextForPrompt, + loadIssueOrchestrationContext, +} from "@/server/services/orchestration-context"; +import { + createIssue, + createWorkspaceFixture, + disconnectPrisma, + getPrisma, + type TestFixture, +} from "@/server/routers/__tests__/helpers"; + +const fixtures: TestFixture[] = []; + +afterEach(async () => { + while (fixtures.length) await fixtures.pop()!.cleanup(); +}); + +afterAll(async () => { + await disconnectPrisma(); +}); + +describe("orchestration context", () => { + it("hydrates goal, DAG, completion contract, retry feedback, and source-run evidence", async () => { + const fixture = await createWorkspaceFixture({ keyPrefix: "OCT" }); + fixtures.push(fixture); + const prisma = getPrisma(); + const dependencyIssue = await createIssue(fixture, { title: "Build parser" }); + const reviewIssue = await createIssue(fixture, { title: "Review parser" }); + const agent = await prisma.agent.create({ + data: { + workspaceId: fixture.workspace.id, + profileKey: "oct-worker", + name: "Context worker", + }, + }); + const goal = await prisma.goal.create({ + data: { + workspaceId: fixture.workspace.id, + title: "Ship rich rendering", + description: "Render issue bodies safely and consistently.", + successCriteria: "Every issue surface renders the same document model.", + status: "ACTIVE", + }, + }); + const contextSet = await prisma.contextSet.create({ + data: { + workspaceId: fixture.workspace.id, + name: "Rendering references", + items: { + create: [ + { + workspaceId: fixture.workspace.id, + targetType: "issue", + targetId: dependencyIssue.id, + note: "Reuse the parser contract", + }, + { + workspaceId: fixture.workspace.id, + targetType: "issue", + targetId: "excluded-secret-target", + includeMode: "EXCLUDE", + note: "excluded-secret-note", + position: 1, + }, + ], + }, + }, + }); + const plan = await prisma.executionPlan.create({ + data: { + workspaceId: fixture.workspace.id, + goalId: goal.id, + contextSetId: contextSet.id, + title: "Rich rendering rollout", + description: "Implement, review, and ship the renderer.", + status: "RUNNING", + maxStepRetries: 3, + }, + }); + const dependency = await prisma.executionStep.create({ + data: { + workspaceId: fixture.workspace.id, + planId: plan.id, + issueId: dependencyIssue.id, + title: "Build renderer", + position: 0, + status: "DONE", + }, + }); + const review = await prisma.executionStep.create({ + data: { + workspaceId: fixture.workspace.id, + planId: plan.id, + issueId: reviewIssue.id, + title: "Review renderer", + body: "Check the implementation against the shared rendering contract.", + position: 1, + status: "REVIEW", + dependsOnStepIds: [dependency.id], + expectedOutput: "A review verdict with actionable evidence.", + verification: ["Run renderer tests", "Inspect malformed markdown"], + retryCount: 1, + lastFeedback: "The first pass missed malformed link handling.", + }, + }); + const sourceRun = await prisma.agentRun.create({ + data: { + workspaceId: fixture.workspace.id, + issueId: reviewIssue.id, + agentId: agent.id, + executionStepId: review.id, + status: "COMPLETED", + summary: "Implemented the parser and added malformed-link coverage.", + producedArtifactIds: ["artifact-renderer-report"], + verificationResult: [{ item: "renderer tests", done: true }], + completionMeta: { contractVersion: "2026-test" }, + completedAt: new Date(), + finishedAt: new Date(), + }, + }); + await prisma.executionStep.update({ + where: { id: review.id }, + data: { sourceRunId: sourceRun.id }, + }); + + const context = await loadIssueOrchestrationContext(prisma, { + workspaceId: fixture.workspace.id, + issueId: reviewIssue.id, + executionStepId: review.id, + }); + + expect(context).toMatchObject({ + goal: { id: goal.id, title: "Ship rich rendering" }, + plan: { id: plan.id, progress: { done: 1, total: 2 } }, + step: { + id: review.id, + retryCount: 1, + lastFeedback: "The first pass missed malformed link handling.", + completionContract: { + expectedOutput: "A review verdict with actionable evidence.", + }, + sourceRun: { + id: sourceRun.id, + producedArtifactIds: ["artifact-renderer-report"], + }, + }, + dependencies: [{ id: dependency.id }], + contextSet: { id: contextSet.id, items: [{ targetId: dependencyIssue.id }] }, + }); + const prompt = formatOrchestrationContextForPrompt(context); + expect(prompt).toContain("Goal: Ship rich rendering"); + expect(prompt).toContain("Retry feedback to address"); + expect(prompt).toContain("artifact-renderer-report"); + expect(prompt).toContain("Worker verification evidence"); + expect(prompt).toContain(`${fixture.workspace.key}-${dependencyIssue.number}`); + expect(context?.contextSet?.items).not.toEqual( + expect.arrayContaining([expect.objectContaining({ targetId: "excluded-secret-target" })]), + ); + expect(prompt).not.toContain("excluded-secret-target"); + expect(prompt).not.toContain("excluded-secret-note"); + }); + + it("returns plan-level anchor context without guessing among its steps", async () => { + const fixture = await createWorkspaceFixture({ keyPrefix: "OCA" }); + fixtures.push(fixture); + const prisma = getPrisma(); + const anchor = await createIssue(fixture, { title: "Plan anchor" }); + const goal = await prisma.goal.create({ + data: { workspaceId: fixture.workspace.id, title: "Anchor goal", status: "ACTIVE" }, + }); + const plan = await prisma.executionPlan.create({ + data: { + workspaceId: fixture.workspace.id, + issueId: anchor.id, + goalId: goal.id, + title: "Anchor plan", + status: "RUNNING", + }, + }); + const first = await prisma.executionStep.create({ + data: { + workspaceId: fixture.workspace.id, + planId: plan.id, + title: "First", + position: 0, + status: "READY", + }, + }); + await prisma.executionStep.create({ + data: { + workspaceId: fixture.workspace.id, + planId: plan.id, + title: "Second", + position: 1, + status: "TODO", + dependsOnStepIds: [first.id], + }, + }); + + const anchorContext = await loadIssueOrchestrationContext(prisma, { + workspaceId: fixture.workspace.id, + issueId: anchor.id, + }); + expect(anchorContext).toMatchObject({ + goal: { id: goal.id }, + plan: { id: plan.id }, + step: null, + }); + expect(formatOrchestrationContextForPrompt(anchorContext)).toContain( + "without assuming a specific step assignment", + ); + + const exactContext = await loadIssueOrchestrationContext(prisma, { + workspaceId: fixture.workspace.id, + issueId: anchor.id, + executionStepId: first.id, + }); + expect(exactContext?.step?.id).toBe(first.id); + }); +}); diff --git a/src/server/services/__tests__/orchestration.test.ts b/src/server/services/__tests__/orchestration.test.ts index 7690f7d0..811e21dd 100644 --- a/src/server/services/__tests__/orchestration.test.ts +++ b/src/server/services/__tests__/orchestration.test.ts @@ -4,12 +4,14 @@ import { AgentRunStatus, AgentProvider, EngagementMode, + EventKind, ExecutionPlanStatus, ExecutionStepStatus, GoalStatus, ReviewGateStatus, RuntimeKind, } from "@prisma/client"; +import { recordChange } from "@/server/audit"; import { abandonGoal, activatePlan, @@ -20,6 +22,7 @@ import { decomposeGoal, dispatchJudge, getGoal, + maybeCompleteGoal, recordVerdict, requestPlanApproval, sweepOrchestrationBudget, @@ -285,6 +288,119 @@ describe("orchestration: decompose + addSteps", () => { expect(fresh.isActiveAttempt).toBe(true); }); + it("opens a durable run for a runtime planner when the goal has an issue anchor", async () => { + const { fixture, prisma } = await setup(); + const issue = await createIssue(fixture, { title: "Goal planning anchor" }); + const runtime = await prisma.runtime.create({ + data: { + workspaceId: fixture.workspace.id, + ownerId: fixture.user.id, + name: "Codex planner runtime", + kind: RuntimeKind.LOCAL_DAEMON, + adapterKey: "mock-runs", + providersAvailable: [AgentProvider.HERMES], + }, + }); + const planner = await prisma.agent.create({ + data: { + workspaceId: fixture.workspace.id, + profileKey: "runtime-planner", + name: "Runtime Planner", + provider: AgentProvider.HERMES, + runEngine: "RUNS", + runtimeId: runtime.id, + }, + }); + const goal = await createGoal(prisma, { + workspaceId: fixture.workspace.id, + actorId: fixture.user.id, + title: "Plan through Codex", + description: "Build a safe rollout plan.", + successCriteria: "Every rollout stage has a rollback check.", + issueId: issue.id, + }); + + const result = await decomposeGoal(prisma, { + workspaceId: fixture.workspace.id, + actorId: fixture.user.id, + goalId: goal.id, + plannerAgentId: planner.id, + }); + + expect(result.dispatchable).toBe(true); + const run = await prisma.agentRun.findFirstOrThrow({ + where: { workspaceId: fixture.workspace.id, agentId: planner.id }, + }); + expect(run).toMatchObject({ + issueId: issue.id, + executionStepId: null, + engagementMode: "DISCUSS", + triggerKind: EventKind.ISSUE_UPDATED, + status: AgentRunStatus.ACTIVE, + }); + const event = await prisma.activityEvent.findUniqueOrThrow({ + where: { id: run.triggerEventId! }, + }); + expect(event.subjectId).toBe(result.planId); + expect(event.payload).toMatchObject({ + goalId: goal.id, + goalSuccessCriteria: "Every rollout stage has a rollback check.", + }); + expect( + await prisma.webhookDelivery.count({ + where: { eventId: event.id }, + }), + ).toBe(0); + }); + + it("does not claim or queue runtime planner dispatch without a goal issue anchor", async () => { + const { fixture, prisma } = await setup(); + const runtime = await prisma.runtime.create({ + data: { + workspaceId: fixture.workspace.id, + ownerId: fixture.user.id, + name: "Unanchored planner runtime", + kind: RuntimeKind.LOCAL_DAEMON, + adapterKey: "mock-runs", + providersAvailable: [AgentProvider.HERMES], + }, + }); + const planner = await prisma.agent.create({ + data: { + workspaceId: fixture.workspace.id, + profileKey: "unanchored-planner", + name: "Unanchored Planner", + provider: AgentProvider.HERMES, + runEngine: "RUNS", + runtimeId: runtime.id, + }, + }); + const goal = await createGoal(prisma, { + workspaceId: fixture.workspace.id, + actorId: fixture.user.id, + title: "No issue anchor", + }); + + const result = await decomposeGoal(prisma, { + workspaceId: fixture.workspace.id, + actorId: fixture.user.id, + goalId: goal.id, + plannerAgentId: planner.id, + }); + + expect(result.dispatchable).toBe(false); + expect( + await prisma.agentRun.count({ + where: { workspaceId: fixture.workspace.id, agentId: planner.id }, + }), + ).toBe(0); + expect( + await prisma.webhookDelivery.count({ + where: { event: { subjectId: result.planId } }, + }), + ).toBe(0); + }); + it("addSteps resolves index-based deps to real ids", async () => { const { fixture, prisma } = await setup(); const goal = await createGoal(prisma, { @@ -417,6 +533,10 @@ describe("orchestration: judge loop", () => { planId, }), ); + await prisma.executionStep.update({ + where: { id: stepIds[0] }, + data: { status: ExecutionStepStatus.REVIEW }, + }); return { fixture, prisma, @@ -525,6 +645,58 @@ describe("orchestration: judge loop", () => { expect(goal.achievedAt).not.toBeNull(); }); + it("rejects a verdict before the step reaches review", async () => { + const { fixture, prisma, stepId } = await buildRunningPlanWithStep(); + await prisma.executionStep.update({ + where: { id: stepId }, + data: { status: ExecutionStepStatus.TODO }, + }); + + await expect( + recordVerdict(prisma, { + workspaceId: fixture.workspace.id, + actorId: fixture.user.id, + stepId, + verdict: "PASS", + feedback: "Skip straight to done", + }), + ).rejects.toThrow(/only be recorded while it is in review/); + + const step = await prisma.executionStep.findUniqueOrThrow({ where: { id: stepId } }); + expect(step.status).toBe(ExecutionStepStatus.TODO); + }); + + it("does not achieve a goal when any step was canceled", async () => { + const { fixture, prisma, planId, goalId, stepId } = await buildRunningPlanWithStep(); + await prisma.executionStep.update({ + where: { id: stepId }, + data: { status: ExecutionStepStatus.DONE }, + }); + await prisma.executionStep.create({ + data: { + workspaceId: fixture.workspace.id, + planId, + title: "Canceled deliverable", + position: 1, + status: ExecutionStepStatus.CANCELED, + }, + }); + + await prisma.$transaction((tx) => + maybeCompleteGoal(tx, { + workspaceId: fixture.workspace.id, + planId, + actorId: fixture.user.id, + }), + ); + + const plan = await prisma.executionPlan.findUniqueOrThrow({ where: { id: planId } }); + const goal = await prisma.goal.findUniqueOrThrow({ where: { id: goalId } }); + expect(plan.status).toBe(ExecutionPlanStatus.RUNNING); + expect(goal.status).toBe(GoalStatus.ACTIVE); + expect(goal.achievedAt).toBeNull(); + }); + it("judge FAIL with retries left re-readies the step and bumps retryCount", async () => { const { fixture, prisma, stepId } = await buildRunningPlanWithStep({ maxStepRetries: 2 }); const res = await recordVerdict(prisma, { @@ -787,6 +959,131 @@ describe("orchestration: materialize step as issue (AXI-56)", () => { }); expect(again).toEqual({ issueId: res.issueId, created: false }); }); + + it("carries an unambiguous step through ordinary issue assignment", async () => { + const { fixture, prisma } = await setup(); + const worker = await makeAgent(fixture.workspace.id, "reassigned-worker"); + const { id: planId } = await createExecutionPlan(prisma, { + workspaceId: fixture.workspace.id, + actorId: fixture.user.id, + title: "Assignment-linked plan", + steps: [{ title: "Assigned through issue" }], + }); + const step = await prisma.executionStep.findFirstOrThrow({ where: { planId } }); + const { issueId } = await materializeStepAsIssue(prisma, { + workspaceId: fixture.workspace.id, + actorId: fixture.user.id, + stepId: step.id, + }); + await prisma.executionPlan.update({ + where: { id: planId }, + data: { status: ExecutionPlanStatus.RUNNING, startedAt: new Date() }, + }); + await prisma.executionStep.update({ + where: { id: step.id }, + data: { status: ExecutionStepStatus.READY }, + }); + await prisma.issue.update({ + where: { id: issueId }, + data: { assignedAgentId: worker.id }, + }); + + await prisma.$transaction((tx) => + recordChange(tx, { + workspaceId: fixture.workspace.id, + actorId: fixture.user.id, + entity: "Issue", + entityId: issueId, + action: "assign-agent", + eventKind: EventKind.AGENT_ASSIGNED, + subjectType: "issue", + subjectId: issueId, + payload: { agentId: worker.id }, + }), + ); + + const linked = await prisma.executionStep.findUniqueOrThrow({ where: { id: step.id } }); + expect(linked.assignedAgentId).toBe(worker.id); + const event = await prisma.activityEvent.findFirstOrThrow({ + where: { + workspaceId: fixture.workspace.id, + kind: EventKind.AGENT_ASSIGNED, + subjectId: issueId, + }, + orderBy: { createdAt: "desc" }, + }); + expect(event.payload).toMatchObject({ + agentId: worker.id, + executionStepId: step.id, + }); + }); + + it("schedules a blocked child assignee without dispatching work early", async () => { + const { fixture, prisma } = await setup(); + const worker = await makeAgent(fixture.workspace.id, "scheduled-worker"); + const { id: planId } = await createExecutionPlan(prisma, { + workspaceId: fixture.workspace.id, + actorId: fixture.user.id, + title: "Dependency-gated assignment", + steps: [{ title: "Root" }, { title: "Blocked child", dependsOnStepIndexes: [0] }], + }); + const steps = await prisma.executionStep.findMany({ + where: { planId }, + orderBy: { position: "asc" }, + }); + const child = steps[1]; + const { issueId } = await materializeStepAsIssue(prisma, { + workspaceId: fixture.workspace.id, + actorId: fixture.user.id, + stepId: child.id, + }); + await prisma.issue.update({ + where: { id: issueId }, + data: { assignedAgentId: worker.id }, + }); + + await prisma.$transaction((tx) => + recordChange(tx, { + workspaceId: fixture.workspace.id, + actorId: fixture.user.id, + entity: "Issue", + entityId: issueId, + action: "assign-agent", + eventKind: EventKind.AGENT_ASSIGNED, + subjectType: "issue", + subjectId: issueId, + payload: { agentId: worker.id }, + }), + ); + + const event = await prisma.activityEvent.findFirstOrThrow({ + where: { + workspaceId: fixture.workspace.id, + kind: EventKind.AGENT_ASSIGNED, + subjectId: issueId, + }, + orderBy: { createdAt: "desc" }, + }); + expect(event.payload).toMatchObject({ + planId, + planStepId: child.id, + orchestrationDeferred: true, + }); + expect((event.payload as { executionStepId?: string }).executionStepId).toBeUndefined(); + expect( + await prisma.agentRun.count({ + where: { workspaceId: fixture.workspace.id, issueId }, + }), + ).toBe(0); + expect( + await prisma.webhookDelivery.count({ + where: { eventId: event.id }, + }), + ).toBe(0); + const linked = await prisma.executionStep.findUniqueOrThrow({ where: { id: child.id } }); + expect(linked.status).toBe(ExecutionStepStatus.TODO); + expect(linked.assignedAgentId).toBe(worker.id); + }); }); describe("orchestration: Goal loop opens observable runs (AXI-57)", () => { @@ -1165,28 +1462,35 @@ describe("orchestration: audit phase-1 safety guards", () => { it("P1.1 — a CANCELED plan does not re-open work via a late retry verdict", async () => { const { fixture, prisma, planId, rootId } = await build2StepRunning(); + await prisma.executionStep.update({ + where: { id: rootId }, + data: { status: ExecutionStepStatus.REVIEW }, + }); // Cancel the plan (as abandonGoal would), while root is still in flight. await prisma.executionPlan.update({ where: { id: planId }, data: { status: ExecutionPlanStatus.CANCELED }, }); - // A late FAIL verdict arrives on the still-READY root. It should record - // (root is not settled) but the RETRY re-dispatch must be a no-op on a - // CANCELED plan — the step lands TODO and is NOT re-readied. - const res = await recordVerdict(prisma, { - workspaceId: fixture.workspace.id, - actorId: fixture.user.id, - stepId: rootId, - verdict: "FAIL", - feedback: "late", - }); - expect(res.outcome).toBe("RETRY"); + // A late reviewer verdict cannot mutate a plan after cancellation. + await expect( + recordVerdict(prisma, { + workspaceId: fixture.workspace.id, + actorId: fixture.user.id, + stepId: rootId, + verdict: "FAIL", + feedback: "late", + }), + ).rejects.toThrow(/active running plan attempt/); const root = await prisma.executionStep.findUniqueOrThrow({ where: { id: rootId } }); - expect(root.status).toBe(ExecutionStepStatus.TODO); // not re-READY + expect(root.status).toBe(ExecutionStepStatus.REVIEW); }); it("P1.2 — a stale verdict on a settled (DONE) step is rejected, keeping it DONE", async () => { const { fixture, prisma, rootId } = await build2StepRunning(); + await prisma.executionStep.update({ + where: { id: rootId }, + data: { status: ExecutionStepStatus.REVIEW }, + }); // First verdict completes the step. const first = await recordVerdict(prisma, { workspaceId: fixture.workspace.id, @@ -1205,7 +1509,7 @@ describe("orchestration: audit phase-1 safety guards", () => { verdict: "FAIL", feedback: "stale", }), - ).rejects.toThrow(/settled/); + ).rejects.toThrow(/only be recorded while it is in review/); const step = await prisma.executionStep.findUniqueOrThrow({ where: { id: rootId } }); expect(step.status).toBe(ExecutionStepStatus.DONE); expect(step.retryCount).toBe(0); diff --git a/src/server/services/__tests__/run-dispatcher.test.ts b/src/server/services/__tests__/run-dispatcher.test.ts index 5a3922f1..c8e82f34 100644 --- a/src/server/services/__tests__/run-dispatcher.test.ts +++ b/src/server/services/__tests__/run-dispatcher.test.ts @@ -1,7 +1,10 @@ import { describe, it, expect, afterAll, afterEach } from "vitest"; import { AgentProvider, EngagementMode, EventKind, RuntimeKind, RunEngine } from "@prisma/client"; import { recordChange } from "@/server/audit"; -import { ingestRunsDispatch } from "@/server/services/dispatch/run-dispatcher"; +import { + dispatchTriggerContext, + ingestRunsDispatch, +} from "@/server/services/dispatch/run-dispatcher"; import { createWorkspaceFixture, createIssue, @@ -24,6 +27,15 @@ afterAll(async () => { }); describe("runs dispatcher", () => { + it("preserves the decompose prompt for runtime-backed planners", () => { + const context = dispatchTriggerContext(EventKind.ISSUE_UPDATED, { + action: "decompose", + prompt: "Call plans.addSteps with a complete dependency graph.", + }); + expect(context).toContain("Planning assignment"); + expect(context).toContain("plans.addSteps"); + }); + it("starts the provider run when the durable inbox already precreated AgentRun", async () => { const previousE2E = process.env.FORGE_E2E; process.env.FORGE_E2E = "1"; @@ -112,6 +124,74 @@ describe("runs dispatcher", () => { } }); + it("does not bypass plan readiness for a deferred assignment event", async () => { + const previousE2E = process.env.FORGE_E2E; + process.env.FORGE_E2E = "1"; + + try { + const fixture = await createWorkspaceFixture({ keyPrefix: "RDD" }); + fixtures.push(fixture); + const prisma = getPrisma(); + const runtime = await prisma.runtime.create({ + data: { + workspaceId: fixture.workspace.id, + ownerId: fixture.user.id, + name: "mock deferred runs", + kind: RuntimeKind.LOCAL_DAEMON, + adapterKey: "mock-runs", + providersAvailable: [AgentProvider.HERMES], + }, + select: { id: true }, + }); + const agent = await prisma.agent.create({ + data: { + workspaceId: fixture.workspace.id, + name: "scheduled runner", + profileKey: "scheduled-runner", + provider: AgentProvider.HERMES, + runEngine: RunEngine.RUNS, + runtimeId: runtime.id, + status: "ONLINE", + }, + select: { id: true }, + }); + const issue = await createIssue(fixture, { title: "wait for plan dependency" }); + await prisma.issue.update({ + where: { id: issue.id }, + data: { assignedAgentId: agent.id }, + }); + const assignment = await prisma.activityEvent.create({ + data: { + workspaceId: fixture.workspace.id, + kind: EventKind.AGENT_ASSIGNED, + actorId: fixture.user.id, + subjectType: "issue", + subjectId: issue.id, + payload: { + agentId: agent.id, + planId: "plan-not-ready", + planStepId: "step-not-ready", + orchestrationDeferred: true, + }, + }, + }); + + await ingestRunsDispatch(); + + expect( + await prisma.agentRun.count({ + where: { assignmentEventId: assignment.id }, + }), + ).toBe(0); + } finally { + if (previousE2E === undefined) { + delete process.env.FORGE_E2E; + } else { + process.env.FORGE_E2E = previousE2E; + } + } + }); + it("starts the provider run for comment-created AgentRuns without legacy webhook dispatch", async () => { const previousE2E = process.env.FORGE_E2E; process.env.FORGE_E2E = "1"; @@ -457,9 +537,7 @@ describe("runs dispatcher", () => { // run overlay — posted as an agent-authored comment carrying the output. const comments = await prisma.comment.findMany({ where: { issueId: issue.id } }); expect( - comments.some( - (c) => c.authoringAgentId === agent.id && /run stalled/i.test(c.body), - ), + comments.some((c) => c.authoringAgentId === agent.id && /run stalled/i.test(c.body)), ).toBe(true); } finally { if (previousE2E === undefined) { diff --git a/src/server/services/agent-dispatch-inbox.ts b/src/server/services/agent-dispatch-inbox.ts index 7e67d76d..c1116cbc 100644 --- a/src/server/services/agent-dispatch-inbox.ts +++ b/src/server/services/agent-dispatch-inbox.ts @@ -12,6 +12,12 @@ import { resolveRunEngineWithSource } from "@/server/services/dispatch/registry" import { buildRuntimePolicySnapshot } from "@/lib/runtime-enforcement"; import { publish } from "@/server/realtime"; import { nanoid } from "nanoid"; +import { + loadIssueOrchestrationContexts, + toInboxOrchestrationContext, + type InboxOrchestrationContext, +} from "@/server/services/orchestration-context"; +import { markExecutionStepRunning } from "@/server/services/execution-step-runtime"; /** * Durable agent dispatch inbox. @@ -268,6 +274,55 @@ async function ensureIssueRuns( const issueAssignmentMode = (await latestAssignmentModeForIssue(tx, params, issue?.assignedAgentId ?? null)) ?? dispatchReasonEngagementMode(issue?.dispatchReason ?? null); + const payloadExecutionStepId = + typeof asRecord(params.payload)?.executionStepId === "string" + ? (asRecord(params.payload)?.executionStepId as string) + : null; + // Ordinary issue assignment events omit executionStepId. Recover it only + // for an unambiguous, nonterminal materialized step; plan-anchor issues may + // own many steps and must never be guessed. + const linkedSteps = await tx.executionStep.findMany({ + where: payloadExecutionStepId + ? { + id: payloadExecutionStepId, + workspaceId: params.workspaceId, + OR: [{ issueId: params.subjectId }, { plan: { issueId: params.subjectId } }], + } + : { + workspaceId: params.workspaceId, + issueId: params.subjectId, + status: { in: ["TODO", "READY", "RUNNING", "BLOCKED", "REVIEW"] }, + }, + orderBy: [{ updatedAt: "desc" }, { id: "asc" }], + take: 2, + select: { id: true, status: true, plan: { select: { status: true } } }, + }); + const inferredStep = !payloadExecutionStepId && linkedSteps.length === 1 ? linkedSteps[0]! : null; + const executionStepId = + payloadExecutionStepId && linkedSteps[0]?.id === payloadExecutionStepId + ? payloadExecutionStepId + : inferredStep && + inferredStep.plan.status === "RUNNING" && + (inferredStep.status === "READY" || inferredStep.status === "RUNNING") + ? inferredStep.id + : null; + if (isAssigned && !payloadExecutionStepId && issue?.assignedAgentId && linkedSteps.length > 0) { + // Assigning a materialized issue schedules its plan step, even before the + // dependency cascade makes it runnable. + await tx.executionStep.updateMany({ + where: { + workspaceId: params.workspaceId, + issueId: params.subjectId, + status: { in: ["TODO", "READY", "RUNNING", "BLOCKED", "REVIEW"] }, + }, + data: { assignedAgentId: issue.assignedAgentId }, + }); + } + if (isAssigned && !payloadExecutionStepId && linkedSteps.length > 0 && !executionStepId) { + // A blocked/TODO/review step is scheduled, not dispatched. Its canonical + // run opens later when cascadeReadiness emits EXECUTION_STEP_READY. + return EMPTY_ENSURE; + } const runIds: string[] = []; const agents = await tx.agent.findMany({ where: { workspaceId: params.workspaceId, id: { in: [...agentIds] } }, @@ -372,10 +427,7 @@ async function ensureIssueRuns( runEngine: engine?.engine ?? null, runEngineSource: engine?.source ?? null, runtimePolicy: runtimePolicy as Prisma.InputJsonValue | null, - executionStepId: - typeof asRecord(params.payload)?.executionStepId === "string" - ? (asRecord(params.payload)?.executionStepId as string) - : null, + executionStepId, }); // Stamp the latest trigger on the run so the inbox can distinguish // "Victor was just re-mentioned in AXI-31" from "Victor is the @@ -388,6 +440,7 @@ async function ensureIssueRuns( triggerEventId: params.eventId, triggerKind: params.eventKind, ...(isAssigned ? { assignmentEventId: params.eventId } : {}), + ...(executionStepId ? { executionStepId } : {}), }, }); runIds.push(run.id); @@ -688,7 +741,7 @@ export async function markOutputStarted( if ("runId" in params.target) { const run = await tx.agentRun.findFirst({ where: { id: params.target.runId, workspaceId: params.workspaceId }, - select: { id: true, agentId: true, outputStartedAt: true }, + select: { id: true, agentId: true, outputStartedAt: true, executionStepId: true }, }); if (!run) throw new InboxNotFoundError("run not found"); if (run.agentId !== params.agentId) { @@ -702,6 +755,12 @@ export async function markOutputStarted( where: { id: run.id }, data: { outputStartedAt: now, lastEventAt: now }, }); + await markExecutionStepRunning(tx, { + workspaceId: params.workspaceId, + executionStepId: run.executionStepId, + runId: run.id, + actorAgentId: params.agentId, + }); return { outputStartedAt: now, alreadyStarted: false }; } @@ -747,6 +806,7 @@ export interface InboxRunItem { workspaceId: string; agentId: string; issueId: string; + executionStepId: string | null; status: AgentRunStatus; triggerEventId: string | null; triggerKind: string | null; @@ -766,6 +826,7 @@ export interface InboxRunItem { statusId: string; projectId: string | null; }; + orchestrationContext: InboxOrchestrationContext | null; recommendedAction: RecommendedAction; } @@ -855,6 +916,7 @@ export async function listInbox( workspaceId: true, agentId: true, issueId: true, + executionStepId: true, status: true, triggerEventId: true, triggerKind: true, @@ -880,7 +942,15 @@ export async function listInbox( listChatInbox(client, params, limit, staleMs), ]); - const runItems: InboxRunItem[] = runs.map((r) => { + const orchestrationContexts = await loadIssueOrchestrationContexts(client, { + workspaceId: params.workspaceId, + targets: runs.map((run) => ({ + issueId: run.issueId, + executionStepId: run.executionStepId, + })), + }); + + const runItems: InboxRunItem[] = runs.map((r, index) => { const state = deriveRunDispatchState({ acknowledgedAt: r.acknowledgedAt, outputStartedAt: r.outputStartedAt, @@ -896,6 +966,7 @@ export async function listInbox( workspaceId: r.workspaceId, agentId: r.agentId, issueId: r.issueId, + executionStepId: r.executionStepId, status: r.status, triggerEventId: r.triggerEventId, triggerKind: r.triggerKind, @@ -915,6 +986,7 @@ export async function listInbox( statusId: r.issue.statusId, projectId: r.issue.projectId, }, + orchestrationContext: toInboxOrchestrationContext(orchestrationContexts[index] ?? null), recommendedAction: recommendedActionFor(state), }; }); diff --git a/src/server/services/api-key-auth.ts b/src/server/services/api-key-auth.ts index 99fbf121..0f59dc6e 100644 --- a/src/server/services/api-key-auth.ts +++ b/src/server/services/api-key-auth.ts @@ -60,12 +60,13 @@ export async function authenticateApiKey( throw new ApiKeyError("Plugin not approved.", 403); for (const s of required) { - if (!key.scopes.includes(s)) - throw new ApiKeyError(`Missing required scope: ${s}`, 403); + if (!key.scopes.includes(s)) throw new ApiKeyError(`Missing required scope: ${s}`, 403); } // Non-blocking last-used update. Batch in production via a queue. - void db.apiKey.update({ where: { id: key.id }, data: { lastUsedAt: new Date() } }).catch(() => {}); + void db.apiKey + .update({ where: { id: key.id }, data: { lastUsedAt: new Date() } }) + .catch(() => {}); return { keyId: key.id, @@ -85,10 +86,11 @@ export async function authenticateApiKey( * resource id. No-op when the call was session-authed (no apiKey on ctx) * or when the key hasn't narrowed that entity type. * - * For `issue` narrowing, we check both project + label lists — an issue - * qualifies if its `projectId` is in `projectIds` OR any of its labels is - * in `labelIds`. This matches the "agent only sees their lane" intuition - * without forcing label *and* project narrowing together. + * For `issue` narrowing, we check project + label + initiative lists — an + * issue qualifies if its project is directly allowed, its project belongs to + * an allowed initiative, OR any label is allowed. This matches the "agent + * only sees their lane" intuition without forcing every narrowing dimension + * to match together. */ export async function assertKeyScope( ctx: { apiKey?: ApiKeyContext | null; db: typeof db }, @@ -120,11 +122,13 @@ export async function assertKeyScope( case "issue": { const hasProject = key.projectIds.length > 0; const hasLabel = key.labelIds.length > 0; - if (!hasProject && !hasLabel) return; + const hasInitiative = key.initiativeIds.length > 0; + if (!hasProject && !hasLabel && !hasInitiative) return; const issue = await ctx.db.issue.findUnique({ where: { id: opts.id }, select: { projectId: true, + project: { select: { initiativeId: true } }, labels: { select: { labelId: true } }, }, }); @@ -132,12 +136,13 @@ export async function assertKeyScope( throw new TRPCError({ code: "NOT_FOUND" }); } const projectOk = - hasProject && issue.projectId - ? key.projectIds.includes(issue.projectId) + hasProject && issue.projectId ? key.projectIds.includes(issue.projectId) : false; + const labelOk = hasLabel && issue.labels.some((l) => key.labelIds.includes(l.labelId)); + const initiativeOk = + hasInitiative && issue.project?.initiativeId + ? key.initiativeIds.includes(issue.project.initiativeId) : false; - const labelOk = - hasLabel && issue.labels.some((l) => key.labelIds.includes(l.labelId)); - if (!projectOk && !labelOk) { + if (!projectOk && !labelOk && !initiativeOk) { throw new TRPCError({ code: "FORBIDDEN", message: "API key scope does not include this resource.", @@ -176,6 +181,9 @@ export function buildKeyScopeWhere( if (key.labelIds.length) { clauses.push({ labels: { some: { labelId: { in: key.labelIds } } } }); } + if (key.initiativeIds.length) { + clauses.push({ project: { initiativeId: { in: key.initiativeIds } } }); + } if (!clauses.length) return {}; return clauses.length === 1 ? clauses[0] : { OR: clauses }; } diff --git a/src/server/services/dispatch/run-dispatcher.ts b/src/server/services/dispatch/run-dispatcher.ts index 9c7b173a..a413b972 100644 --- a/src/server/services/dispatch/run-dispatcher.ts +++ b/src/server/services/dispatch/run-dispatcher.ts @@ -18,6 +18,11 @@ import { enforceRunBudget, RUN_BUDGET_WORKSPACE_SELECT, } from "@/server/services/run-budget"; +import { + formatOrchestrationContextForPrompt, + loadIssueOrchestrationContext, +} from "@/server/services/orchestration-context"; +import { markExecutionStepRunning } from "@/server/services/execution-step-runtime"; /** * Dispatch-via-runs ingestion (worker-hosted, poll-based). @@ -100,6 +105,31 @@ function issueMessage( ); } +/** Additional event-specific instructions that are not part of the issue row. */ +export function dispatchTriggerContext( + kind: string | null | undefined, + payload: Record | null | undefined, +): string { + if (kind === "COMMENT_CREATED" && typeof payload?.body === "string") { + return `\n\nOperator comment${typeof payload.executionStepId === "string" ? " on this plan step" : ""}:\n${payload.body}`; + } + if ( + kind === "EXECUTION_STEP_READY" && + payload?.phase === "judge" && + typeof payload.prompt === "string" + ) { + return `\n\nReview assignment:\n${payload.prompt}`; + } + if ( + kind === "ISSUE_UPDATED" && + payload?.action === "decompose" && + typeof payload.prompt === "string" + ) { + return `\n\nPlanning assignment:\n${payload.prompt}`; + } + return ""; +} + /** Issue keys are derived (`-`), not a column. */ function issueKey(workspaceKey: string, number: number): string { return `${workspaceKey}-${number}`; @@ -178,13 +208,29 @@ async function startNewRuns(): Promise { for (const evt of events) { if (started >= START_BATCH) break; if (!evt.subjectId) continue; + const evtPayload = + evt.payload && typeof evt.payload === "object" && !Array.isArray(evt.payload) + ? (evt.payload as Record) + : {}; + // Assignment can schedule the intended worker before a plan step becomes + // READY. audit.recordChange marks those events deferred and deliberately + // skips canonical inbox/webhook dispatch. Respect the same gate here: this + // scanner reads ActivityEvent directly, so otherwise it would bypass the + // readiness boundary and start the provider from the raw assignment row. + if (evtPayload.orchestrationDeferred === true) continue; // Dedup: one provider run per assignment event. The durable inbox creates // a canonical AgentRun in the same transaction as AGENT_ASSIGNED, before // this worker dials the provider; that precreated row must be reused, not // treated as already-dispatched. const already = await db.agentRun.findFirst({ where: { assignmentEventId: evt.id }, - select: { id: true, status: true, externalRunId: true, runtimePolicy: true }, + select: { + id: true, + status: true, + externalRunId: true, + runtimePolicy: true, + executionStepId: true, + }, }); if (already?.externalRunId || (already && TERMINAL_RUN_STATUSES.has(already.status))) { continue; @@ -244,10 +290,6 @@ async function startNewRuns(): Promise { // Engagement mode (AXI-53): the assignment payload carries the resolved // mode; inject its instruction block so the agent knows whether to execute, // research, review, or just discuss. Default EXECUTE when absent (legacy). - const evtPayload = - evt.payload && typeof evt.payload === "object" && !Array.isArray(evt.payload) - ? (evt.payload as Record) - : {}; const engagementMode = (evtPayload.engagementMode as "EXECUTE" | "RESEARCH" | "REVIEW" | "DISCUSS" | undefined) ?? "EXECUTE"; @@ -267,6 +309,11 @@ async function startNewRuns(): Promise { }); try { + const orchestrationContext = await loadIssueOrchestrationContext(db, { + workspaceId: evt.workspaceId, + issueId: issue.id, + executionStepId: already?.executionStepId, + }); const { externalRunId } = await connector.startRun({ message: issueMessage( @@ -281,7 +328,9 @@ async function startNewRuns(): Promise { }, instruction, already?.id ?? null, - ) + runtimePolicyGrantContext(runtimePolicy), + ) + + formatOrchestrationContextForPrompt(orchestrationContext) + + runtimePolicyGrantContext(runtimePolicy), instructions: instruction, engagementMode, contractVersion: FORGE_RUN_CONTRACT_VERSION, @@ -294,6 +343,7 @@ async function startNewRuns(): Promise { agentId: agent.id, assignmentEventId: evt.id, currentStep: "starting run", + executionStepId: orchestrationContext?.step?.id ?? already?.executionStepId ?? null, engagementMode, }); await tx.agentRun.update({ @@ -302,8 +352,17 @@ async function startNewRuns(): Promise { externalRunId, acknowledgedAt: new Date(), runtimePolicy: runtimePolicy as unknown as Prisma.InputJsonValue, + ...(orchestrationContext?.step?.id + ? { executionStepId: orchestrationContext.step.id } + : {}), }, }); + await markExecutionStepRunning(tx, { + workspaceId: evt.workspaceId, + executionStepId: orchestrationContext?.step?.id ?? already?.executionStepId, + runId: run.id, + actorAgentId: agent.id, + }); await appendRunEvent(tx, { runId: run.id, workspaceId: evt.workspaceId, @@ -369,6 +428,7 @@ async function startUnbackedAgentRuns(limit: number): Promise { triggerKind: true, triggerEventId: true, runtimePolicy: true, + executionStepId: true, }, }); @@ -440,14 +500,12 @@ async function startUnbackedAgentRuns(limit: number): Promise { try { const triggerPayload = triggerEvent?.payload as Record | null; - const operatorContext = - triggerEvent?.kind === "COMMENT_CREATED" && typeof triggerPayload?.body === "string" - ? `\n\nOperator comment${typeof triggerPayload.executionStepId === "string" ? " on this plan step" : ""}:\n${triggerPayload.body}` - : triggerEvent?.kind === "EXECUTION_STEP_READY" && - triggerPayload?.phase === "judge" && - typeof triggerPayload.prompt === "string" - ? `\n\nReview assignment:\n${triggerPayload.prompt}` - : ""; + const orchestrationContext = await loadIssueOrchestrationContext(db, { + workspaceId: run.workspaceId, + issueId: issue.id, + executionStepId: run.executionStepId, + }); + const operatorContext = dispatchTriggerContext(triggerEvent?.kind, triggerPayload); const { externalRunId } = await connector.startRun({ message: issueMessage( @@ -460,6 +518,7 @@ async function startUnbackedAgentRuns(limit: number): Promise { instruction, run.id, ) + + formatOrchestrationContextForPrompt(orchestrationContext) + operatorContext + runtimePolicyGrantContext(runtimePolicy), instructions: instruction, @@ -475,8 +534,17 @@ async function startUnbackedAgentRuns(limit: number): Promise { acknowledgedAt: new Date(), currentStep: "starting run", runtimePolicy: runtimePolicy as unknown as Prisma.InputJsonValue, + ...(orchestrationContext?.step?.id + ? { executionStepId: orchestrationContext.step.id } + : {}), }, }); + await markExecutionStepRunning(tx, { + workspaceId: run.workspaceId, + executionStepId: orchestrationContext?.step?.id ?? run.executionStepId, + runId: run.id, + actorAgentId: run.agentId, + }); await appendRunEvent(tx, { runId: run.id, workspaceId: run.workspaceId, @@ -552,6 +620,7 @@ async function resumeWaitingRuns(limit: number): Promise { lastEventAt: true, runtimePolicy: true, completionMeta: true, + executionStepId: true, issue: { select: { id: true, @@ -637,6 +706,11 @@ async function resumeWaitingRuns(limit: number): Promise { const replyBlock = replies .map((r) => `${r.authoringAgent?.name ?? r.author?.name ?? "Operator"}: ${r.body}`) .join("\n\n"); + const orchestrationContext = await loadIssueOrchestrationContext(db, { + workspaceId: run.workspaceId, + issueId: run.issueId, + executionStepId: run.executionStepId, + }); const message = issueMessage( { @@ -648,6 +722,7 @@ async function resumeWaitingRuns(limit: number): Promise { instruction, run.id, ) + + formatOrchestrationContextForPrompt(orchestrationContext) + `\n\nYou paused this run earlier` + (waitingReason ? ` because: ${waitingReason}` : "") + `.\nThe operator has replied:\n\n${replyBlock}\n\n` + @@ -673,6 +748,9 @@ async function resumeWaitingRuns(limit: number): Promise { status: AgentRunStatus.ACTIVE, externalRunId, currentStep: "resuming after reply", + ...(orchestrationContext?.step?.id + ? { executionStepId: orchestrationContext.step.id } + : {}), lastEventAt: new Date(), awaitingApprovalAt: null, controlState: "NONE", diff --git a/src/server/services/execution-plan-service.ts b/src/server/services/execution-plan-service.ts index 14ab2c9e..6cea9a13 100644 --- a/src/server/services/execution-plan-service.ts +++ b/src/server/services/execution-plan-service.ts @@ -103,6 +103,17 @@ export async function createExecutionPlan( db: PrismaClient, input: CreateExecutionPlanInput, ): Promise<{ id: string }> { + if ( + input.status !== undefined && + input.status !== ExecutionPlanStatus.DRAFT && + input.status !== ExecutionPlanStatus.APPROVED + ) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: + "New plans must start as DRAFT or APPROVED; use the activation lifecycle to run them.", + }); + } // Cross-tenant guards on optional refs. if (input.issueId) { const found = await db.issue.findFirst({ @@ -268,6 +279,38 @@ export async function updateExecutionPlan( if (!existing) { throw new TRPCError({ code: "NOT_FOUND", message: "Execution plan not found." }); } + if (params.contextSetId) { + const contextSet = await db.contextSet.findFirst({ + where: { + id: params.contextSetId, + workspaceId: params.workspaceId, + archivedAt: null, + }, + select: { id: true }, + }); + if (!contextSet) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "Context set not found in this workspace.", + }); + } + } + if ( + params.status && + params.status !== existing.status && + !( + (existing.status === ExecutionPlanStatus.DRAFT && + params.status === ExecutionPlanStatus.APPROVED) || + (existing.status === ExecutionPlanStatus.APPROVED && + params.status === ExecutionPlanStatus.DRAFT) + ) + ) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: + "Use the plan activation or orchestration lifecycle for running and terminal states.", + }); + } await db.$transaction(async (tx) => { await tx.executionPlan.update({ where: { id: params.planId }, diff --git a/src/server/services/execution-step-runtime.ts b/src/server/services/execution-step-runtime.ts new file mode 100644 index 00000000..8dfdb551 --- /dev/null +++ b/src/server/services/execution-step-runtime.ts @@ -0,0 +1,55 @@ +import "server-only"; +import { EventKind, ExecutionStepStatus } from "@prisma/client"; +import type { Prisma, PrismaClient } from "@prisma/client"; + +/** + * Move a plan step into RUNNING only when its worker has actually started. + * The READY guard makes this safe for reviewer runs and idempotent retries. + */ +export async function markExecutionStepRunning( + tx: PrismaClient | Prisma.TransactionClient, + params: { + workspaceId: string; + executionStepId: string | null | undefined; + runId: string; + actorAgentId?: string | null; + }, +): Promise { + if (!params.executionStepId) return false; + const step = await tx.executionStep.findFirst({ + where: { + id: params.executionStepId, + workspaceId: params.workspaceId, + status: ExecutionStepStatus.READY, + }, + select: { id: true, planId: true }, + }); + if (!step) return false; + const updated = await tx.executionStep.updateMany({ + where: { + id: step.id, + workspaceId: params.workspaceId, + status: ExecutionStepStatus.READY, + }, + data: { status: ExecutionStepStatus.RUNNING }, + }); + if (updated.count === 0) return false; + // Dynamic import avoids audit -> inbox -> lifecycle -> audit initialization + // cycle; this helper is invoked after event ingestion modules are loaded. + const { recordChange } = await import("@/server/audit"); + await recordChange(tx, { + workspaceId: params.workspaceId, + actorId: null, + actorAgentId: params.actorAgentId ?? null, + entity: "ExecutionStep", + entityId: step.id, + action: "start", + before: { status: ExecutionStepStatus.READY }, + after: { status: ExecutionStepStatus.RUNNING }, + eventKind: EventKind.AGENT_RUN_STEP, + subjectType: "execution-step", + subjectId: step.id, + payload: { planId: step.planId, runId: params.runId, status: ExecutionStepStatus.RUNNING }, + }); + return true; +} diff --git a/src/server/services/mcp.ts b/src/server/services/mcp.ts index d94eef88..c28fe725 100644 --- a/src/server/services/mcp.ts +++ b/src/server/services/mcp.ts @@ -1471,7 +1471,8 @@ export const mcpTools = { where: { workspaceId: ctx.workspaceId, issueId: input.id, - status: { notIn: ["COMPLETED", "ABANDONED"] }, + status: { in: ["ACTIVE", "WAITING"] }, + ...(ctx.apiKey?.linkedAgentId ? { agentId: ctx.apiKey.linkedAgentId } : {}), }, orderBy: { startedAt: "desc" }, }); @@ -7807,7 +7808,8 @@ export const mcpTools = { where: { workspaceId: ctx.workspaceId, issueId, - status: { notIn: ["COMPLETED", "ABANDONED"] }, + status: { in: ["ACTIVE", "WAITING"] }, + ...(ctx.apiKey?.linkedAgentId ? { agentId: ctx.apiKey.linkedAgentId } : {}), }, orderBy: { startedAt: "desc" }, }), @@ -7849,6 +7851,13 @@ export const mcpTools = { verificationChecklist: issue.verificationChecklist, artifactRequired: issue.artifactRequired, }; + const { loadIssueOrchestrationContext } = + await import("@/server/services/orchestration-context"); + const orchestrationContext = await loadIssueOrchestrationContext(db, { + workspaceId: ctx.workspaceId, + issueId, + executionStepId: currentRun?.executionStepId, + }); const comments = sortCommentsChronologically(rawComments); const currentMode = (currentRun?.engagementMode ?? "EXECUTE") as EngagementMode; const runProtocol = { @@ -7874,6 +7883,7 @@ export const mcpTools = { currentRun, artifacts, externalResources, + orchestrationContext, completionContract, runProtocol, }; @@ -8548,13 +8558,24 @@ export const mcpTools = { }, ctx: McpContext, ) { - const { updateExecutionPlan } = await import("@/server/services/execution-plan-service"); - await updateExecutionPlan(db, { - workspaceId: ctx.workspaceId, - actorId: ctx.userId ?? null, - planId: input.id, - status: input.status as never, - }); + if (input.status === "RUNNING") { + const { activatePlan } = await import("@/server/services/orchestration-service"); + await db.$transaction((tx) => + activatePlan(tx, { + workspaceId: ctx.workspaceId, + actorId: ctx.userId ?? null, + planId: input.id, + }), + ); + } else { + const { updateExecutionPlan } = await import("@/server/services/execution-plan-service"); + await updateExecutionPlan(db, { + workspaceId: ctx.workspaceId, + actorId: ctx.userId ?? null, + planId: input.id, + status: input.status as never, + }); + } return { ok: true }; }, }, diff --git a/src/server/services/orchestration-context.ts b/src/server/services/orchestration-context.ts new file mode 100644 index 00000000..7fcc1f1b --- /dev/null +++ b/src/server/services/orchestration-context.ts @@ -0,0 +1,558 @@ +import "server-only"; +import type { Prisma, PrismaClient } from "@prisma/client"; + +type DbClient = PrismaClient | Prisma.TransactionClient; + +const MAX_TARGETS = 100; +const MAX_FALLBACK_STEPS = 500; +const MAX_CONTEXT_ITEMS = 50; +const MAX_PLAN_STEPS = 100; + +export interface OrchestrationIssueRef { + id: string; + key: string; + title: string; + status: { id: string; name: string; category: string }; +} + +export interface OrchestrationRunEvidence { + id: string; + summary: string | null; + producedArtifactIds: string[]; + verificationResult: Prisma.JsonValue | null; + completionMeta: Prisma.JsonValue | null; + completedAt: Date | null; +} + +export interface OrchestrationStepSummary { + id: string; + title: string; + position: number; + status: string; + dependsOnStepIds: string[]; + issue: OrchestrationIssueRef | null; + sourceRun: OrchestrationRunEvidence | null; +} + +export interface IssueOrchestrationContext { + goal: { + id: string; + title: string; + description: string | null; + successCriteria: string | null; + status: string; + } | null; + plan: { + id: string; + title: string; + description: string | null; + status: string; + isActiveAttempt: boolean; + maxStepRetries: number; + progress: { done: number; total: number; visible: number; truncated: boolean }; + }; + step: + | (OrchestrationStepSummary & { + body: string | null; + expectedOutput: string | null; + verification: Prisma.JsonValue | null; + retryCount: number; + lastFeedback: string | null; + judgeVerdict: Prisma.JsonValue | null; + completionContract: { + expectedOutput: string | null; + verification: Prisma.JsonValue | null; + artifactRequired: boolean; + }; + }) + | null; + dependencies: OrchestrationStepSummary[]; + dependents: OrchestrationStepSummary[]; + planSteps: OrchestrationStepSummary[]; + contextSet: { + id: string; + name: string; + description: string | null; + items: Array<{ + id: string; + targetType: string; + targetId: string; + includeMode: string; + note: string | null; + position: number; + }>; + truncated: boolean; + } | null; +} + +export interface OrchestrationContextTarget { + issueId: string; + executionStepId?: string | null; +} + +export interface InboxOrchestrationContext { + goal: { id: string; title: string; status: string } | null; + plan: { + id: string; + title: string; + status: string; + progress: IssueOrchestrationContext["plan"]["progress"]; + }; + step: { + id: string; + title: string; + position: number; + status: string; + retryCount: number; + lastFeedback: string | null; + } | null; + dependencies: OrchestrationStepSummary[]; + dependents: OrchestrationStepSummary[]; +} + +const PLAN_CONTEXT_SELECT = { + id: true, + title: true, + description: true, + status: true, + issueId: true, + isActiveAttempt: true, + maxStepRetries: true, + goal: { + select: { + id: true, + title: true, + description: true, + successCriteria: true, + status: true, + }, + }, + workspace: { select: { key: true } }, + contextSet: { + select: { + id: true, + name: true, + description: true, + items: { + where: { includeMode: { not: "EXCLUDE" } }, + orderBy: [{ position: "asc" as const }, { id: "asc" as const }], + take: MAX_CONTEXT_ITEMS + 1, + select: { + id: true, + targetType: true, + targetId: true, + includeMode: true, + note: true, + position: true, + }, + }, + }, + }, + steps: { + orderBy: [{ position: "asc" as const }, { id: "asc" as const }], + take: MAX_PLAN_STEPS, + select: { + id: true, + title: true, + body: true, + position: true, + status: true, + expectedOutput: true, + verification: true, + sourceRunId: true, + dependsOnStepIds: true, + judgeVerdict: true, + retryCount: true, + lastFeedback: true, + issueId: true, + issue: { + select: { + id: true, + number: true, + title: true, + expectedOutput: true, + verificationChecklist: true, + artifactRequired: true, + status: { select: { id: true, name: true, category: true } }, + }, + }, + }, + }, + _count: { select: { steps: true } }, +} satisfies Prisma.ExecutionPlanSelect; + +type SelectedPlan = Prisma.ExecutionPlanGetPayload<{ select: typeof PLAN_CONTEXT_SELECT }>; +type SelectedPlanStep = SelectedPlan["steps"][number]; + +/** + * Load orchestration context for issue-routed work units without N+1 queries. + * Results align with `targets`. An AgentRun's executionStepId wins; the issue + * link is only a fallback for legacy/direct runs that predate that pointer. + */ +export async function loadIssueOrchestrationContexts( + client: DbClient, + params: { workspaceId: string; targets: readonly OrchestrationContextTarget[] }, +): Promise> { + if (params.targets.length === 0) return []; + const targets = params.targets.slice(0, MAX_TARGETS); + const exactIds = Array.from( + new Set(targets.flatMap((target) => (target.executionStepId ? [target.executionStepId] : []))), + ); + + const exactCandidates = exactIds.length + ? await client.executionStep.findMany({ + where: { workspaceId: params.workspaceId, id: { in: exactIds } }, + select: { + id: true, + issueId: true, + planId: true, + updatedAt: true, + plan: { + select: { issueId: true, isActiveAttempt: true, status: true, updatedAt: true }, + }, + }, + }) + : []; + const exactById = new Map(exactCandidates.map((candidate) => [candidate.id, candidate])); + + const fallbackIssueIds = Array.from( + new Set( + targets + .filter((target) => { + if (!target.executionStepId) return true; + const exact = exactById.get(target.executionStepId); + return ( + !exact || (exact.issueId !== target.issueId && exact.plan.issueId !== target.issueId) + ); + }) + .map((target) => target.issueId), + ), + ); + const fallbackCandidates = fallbackIssueIds.length + ? await client.executionStep.findMany({ + where: { + workspaceId: params.workspaceId, + issueId: { in: fallbackIssueIds }, + status: { in: ["TODO", "READY", "RUNNING", "BLOCKED", "REVIEW"] }, + }, + orderBy: { updatedAt: "desc" }, + take: MAX_FALLBACK_STEPS, + select: { + id: true, + issueId: true, + planId: true, + updatedAt: true, + plan: { + select: { issueId: true, isActiveAttempt: true, status: true, updatedAt: true }, + }, + }, + }) + : []; + + const selectedStepIds = targets.map((target) => { + const exact = target.executionStepId ? exactById.get(target.executionStepId) : null; + if (exact && (exact.issueId === target.issueId || exact.plan.issueId === target.issueId)) { + return exact.id; + } + const matches = fallbackCandidates.filter((candidate) => candidate.issueId === target.issueId); + return matches.length === 1 ? matches[0]!.id : null; + }); + + const candidateById = new Map( + [...exactCandidates, ...fallbackCandidates].map((candidate) => [candidate.id, candidate]), + ); + const anchorIssueIds = Array.from( + new Set(targets.flatMap((target, index) => (selectedStepIds[index] ? [] : [target.issueId]))), + ); + const anchorCandidates = anchorIssueIds.length + ? await client.executionPlan.findMany({ + where: { + workspaceId: params.workspaceId, + issueId: { in: anchorIssueIds }, + archivedAt: null, + }, + orderBy: [{ updatedAt: "desc" }, { id: "asc" }], + select: { + id: true, + issueId: true, + isActiveAttempt: true, + status: true, + updatedAt: true, + }, + }) + : []; + const selectedPlanIds = targets.map((target, index) => { + const stepId = selectedStepIds[index]; + if (stepId) return candidateById.get(stepId)?.planId ?? null; + const matches = anchorCandidates.filter((plan) => plan.issueId === target.issueId); + matches.sort((a, b) => { + const activeDiff = Number(b.isActiveAttempt) - Number(a.isActiveAttempt); + if (activeDiff) return activeDiff; + const runningDiff = Number(b.status === "RUNNING") - Number(a.status === "RUNNING"); + if (runningDiff) return runningDiff; + return b.updatedAt.getTime() - a.updatedAt.getTime(); + }); + return matches[0]?.id ?? null; + }); + const planIds = Array.from(new Set(selectedPlanIds.filter((id): id is string => Boolean(id)))); + const plans = planIds.length + ? await client.executionPlan.findMany({ + where: { workspaceId: params.workspaceId, id: { in: planIds } }, + select: PLAN_CONTEXT_SELECT, + }) + : []; + const planById = new Map(plans.map((plan) => [plan.id, plan])); + const sourceRunIds = Array.from( + new Set( + plans.flatMap((plan) => + plan.steps.flatMap((step) => (step.sourceRunId ? [step.sourceRunId] : [])), + ), + ), + ); + const sourceRuns = sourceRunIds.length + ? await client.agentRun.findMany({ + where: { workspaceId: params.workspaceId, id: { in: sourceRunIds } }, + select: { + id: true, + summary: true, + producedArtifactIds: true, + verificationResult: true, + completionMeta: true, + completedAt: true, + }, + }) + : []; + const sourceRunById = new Map(sourceRuns.map((run) => [run.id, run])); + + const results = selectedPlanIds.map((planId, index) => { + const plan = planId ? planById.get(planId) : null; + if (!plan) return null; + return normalizeContext(plan, selectedStepIds[index] ?? null, sourceRunById); + }); + // Preserve alignment even when a caller accidentally exceeds the documented + // inbox maximum; overflow targets intentionally receive no context. + return [...results, ...params.targets.slice(MAX_TARGETS).map(() => null)]; +} + +export async function loadIssueOrchestrationContext( + client: DbClient, + params: { workspaceId: string; issueId: string; executionStepId?: string | null }, +): Promise { + const [context] = await loadIssueOrchestrationContexts(client, { + workspaceId: params.workspaceId, + targets: [{ issueId: params.issueId, executionStepId: params.executionStepId }], + }); + return context ?? null; +} + +function normalizeContext( + plan: SelectedPlan, + stepId: string | null, + sourceRunById: ReadonlyMap, +): IssueOrchestrationContext | null { + const current = stepId ? plan.steps.find((step) => step.id === stepId) : null; + if (stepId && !current) return null; + const toSummary = (step: SelectedPlanStep): OrchestrationStepSummary => ({ + id: step.id, + title: step.title, + position: step.position, + status: step.status, + dependsOnStepIds: step.dependsOnStepIds, + issue: step.issue + ? { + id: step.issue.id, + key: `${plan.workspace.key}-${step.issue.number}`, + title: step.issue.title, + status: { + id: step.issue.status.id, + name: step.issue.status.name, + category: step.issue.status.category, + }, + } + : null, + sourceRun: step.sourceRunId ? (sourceRunById.get(step.sourceRunId) ?? null) : null, + }); + const planSteps = plan.steps.map(toSummary); + const byId = new Map(planSteps.map((step) => [step.id, step])); + const dependencies = current + ? current.dependsOnStepIds.flatMap((id) => { + const dependency = byId.get(id); + return dependency ? [dependency] : []; + }) + : []; + const dependents = current + ? planSteps.filter((step) => step.dependsOnStepIds.includes(current.id)) + : []; + const stepSummary = current ? toSummary(current) : null; + const contextItems = plan.contextSet?.items.slice(0, MAX_CONTEXT_ITEMS) ?? []; + + return { + goal: plan.goal, + plan: { + id: plan.id, + title: plan.title, + description: plan.description, + status: plan.status, + isActiveAttempt: plan.isActiveAttempt, + maxStepRetries: plan.maxStepRetries, + progress: { + done: plan.steps.filter((step) => step.status === "DONE").length, + total: plan._count.steps, + visible: plan.steps.length, + truncated: plan._count.steps > plan.steps.length, + }, + }, + step: + current && stepSummary + ? { + ...stepSummary, + body: current.body, + expectedOutput: current.expectedOutput, + verification: current.verification, + retryCount: current.retryCount, + lastFeedback: current.lastFeedback, + judgeVerdict: current.judgeVerdict, + completionContract: { + expectedOutput: current.expectedOutput ?? current.issue?.expectedOutput ?? null, + verification: current.verification ?? current.issue?.verificationChecklist ?? null, + artifactRequired: current.issue?.artifactRequired ?? false, + }, + } + : null, + dependencies, + dependents, + planSteps, + contextSet: plan.contextSet + ? { + id: plan.contextSet.id, + name: plan.contextSet.name, + description: plan.contextSet.description, + items: contextItems, + truncated: plan.contextSet.items.length > contextItems.length, + } + : null, + }; +} + +export function toInboxOrchestrationContext( + context: IssueOrchestrationContext | null, +): InboxOrchestrationContext | null { + if (!context) return null; + return { + goal: context.goal + ? { id: context.goal.id, title: context.goal.title, status: context.goal.status } + : null, + plan: { + id: context.plan.id, + title: context.plan.title, + status: context.plan.status, + progress: context.plan.progress, + }, + step: context.step + ? { + id: context.step.id, + title: context.step.title, + position: context.step.position, + status: context.step.status, + retryCount: context.step.retryCount, + lastFeedback: context.step.lastFeedback, + } + : null, + dependencies: context.dependencies, + dependents: context.dependents, + }; +} + +function compactText(value: string | null | undefined, max = 2_000): string | null { + const text = value?.trim(); + if (!text) return null; + return text.length > max ? `${text.slice(0, max)}\n[truncated]` : text; +} + +function jsonLine(value: Prisma.JsonValue | null): string | null { + if (value === null) return null; + const raw = JSON.stringify(value); + return compactText(raw, 2_000); +} + +function stepLine(step: OrchestrationStepSummary): string { + const issue = step.issue ? ` · ${step.issue.key}: ${step.issue.title}` : ""; + const output = compactText(step.sourceRun?.summary, 400); + return `- [${step.status}] ${step.position + 1}. ${step.title}${issue}${output ? ` · outcome: ${output}` : ""}`; +} + +/** Compact prompt block shared by every provider-backed runtime turn. */ +export function formatOrchestrationContextForPrompt( + context: IssueOrchestrationContext | null, +): string { + if (!context) return ""; + const sections: string[] = [ + context.step + ? "Orchestration context — this issue is one step of a larger plan; work toward the goal and preserve dependency boundaries." + : "Orchestration context — this issue anchors a larger plan; work toward its goal without assuming a specific step assignment.", + ]; + if (context.goal) { + sections.push(`Goal: ${context.goal.title} [${context.goal.status}]`); + const goalDescription = compactText(context.goal.description, 1_200); + if (goalDescription) sections.push(`Goal context:\n${goalDescription}`); + const success = compactText(context.goal.successCriteria, 1_200); + if (success) sections.push(`Goal success criteria:\n${success}`); + } + sections.push( + `Plan: ${context.plan.title} [${context.plan.status}] · ${context.plan.progress.done}/${context.plan.progress.total} steps done`, + ); + if (context.step) { + sections.push( + `Current step: ${context.step.position + 1}. ${context.step.title} [${context.step.status}] · retry ${context.step.retryCount}/${context.plan.maxStepRetries}`, + ); + const body = compactText(context.step.body); + if (body) sections.push(`Step instructions:\n${body}`); + const expectedOutput = compactText(context.step.completionContract.expectedOutput); + if (expectedOutput) sections.push(`Expected output:\n${expectedOutput}`); + const verification = jsonLine(context.step.completionContract.verification); + if (verification) sections.push(`Verification:\n${verification}`); + if (context.step.completionContract.artifactRequired) { + sections.push("Completion requires a durable artifact linked to the issue/run."); + } + const feedback = compactText(context.step.lastFeedback, 2_500); + if (feedback) sections.push(`Retry feedback to address:\n${feedback}`); + } + if (context.step?.sourceRun) { + const evidence = context.step.sourceRun; + const evidenceLines = [ + `Worker source run: ${evidence.id}`, + compactText(evidence.summary, 1_500) + ? `Worker summary: ${compactText(evidence.summary, 1_500)}` + : null, + evidence.producedArtifactIds.length + ? `Produced artifacts: ${evidence.producedArtifactIds.join(", ")}` + : null, + jsonLine(evidence.verificationResult) + ? `Worker verification evidence: ${jsonLine(evidence.verificationResult)}` + : null, + ].filter((line): line is string => Boolean(line)); + sections.push(evidenceLines.join("\n")); + } + if (context.dependencies.length) { + sections.push(`Prerequisite steps:\n${context.dependencies.map(stepLine).join("\n")}`); + } + if (context.dependents.length) { + sections.push( + `Downstream steps relying on this work:\n${context.dependents.map(stepLine).join("\n")}`, + ); + } + sections.push(`Plan map:\n${context.planSteps.map(stepLine).join("\n")}`); + if (context.contextSet) { + const refs = context.contextSet.items + .slice(0, 12) + .map( + (item) => + `- ${item.includeMode} ${item.targetType}:${item.targetId}${item.note ? ` — ${item.note}` : ""}`, + ) + .join("\n"); + sections.push( + `Shared context set: ${context.contextSet.name} (${context.contextSet.id})${refs ? `\n${refs}` : ""}`, + ); + } + return `\n\n${sections.join("\n\n")}`; +} diff --git a/src/server/services/orchestration-service.ts b/src/server/services/orchestration-service.ts index 1ac176f7..bceee988 100644 --- a/src/server/services/orchestration-service.ts +++ b/src/server/services/orchestration-service.ts @@ -1112,6 +1112,7 @@ export async function decomposeGoal( id: true, title: true, description: true, + successCriteria: true, status: true, crewId: true, issueId: true, @@ -1171,6 +1172,20 @@ export async function decomposeGoal( runEngine: RunEngine | null; hasWebhook: boolean; } | null = null; + let plannerDispatchAgent: + | ({ + id: string; + name: string; + profileKey: string; + status: AgentStatus; + } & { + webhookUrl: string | null; + runtimeId: string | null; + provider: AgentProvider; + runEngine: RunEngine | null; + runtime: AgentRuntimeRef; + }) + | null = null; let dispatchable = false; if (plannerAgentId) { const agent = await db.agent.findFirst({ @@ -1180,12 +1195,11 @@ export async function decomposeGoal( name: true, profileKey: true, status: true, - runEngine: true, - webhookUrl: true, - runtimeId: true, + ...ORCH_WORKER_SELECT, }, }); if (agent) { + plannerDispatchAgent = agent; const hasWebhook = !!agent.webhookUrl; planner = { id: agent.id, @@ -1195,9 +1209,13 @@ export async function decomposeGoal( runEngine: agent.runEngine, hasWebhook, }; - // RUNS agents are reached via a Runtime (their dispatch webhook is - // suppressed); everyone else needs a webhookUrl. - dispatchable = agent.runEngine === RunEngine.RUNS ? !!agent.runtimeId : hasWebhook; + // A RUNS planner needs both a Runtime and an Issue-backed Goal because + // AgentRun.issueId is required. Without the anchor issue, claiming the + // planner is dispatchable only queues an agent:dispatch webhook that is + // guaranteed to dead-letter for a runtime-only agent and strands the + // goal in PLANNING. Webhook planners do not need an issue anchor. + dispatchable = + agent.runEngine === RunEngine.RUNS ? Boolean(agent.runtimeId && goal.issueId) : hasWebhook; } } @@ -1256,6 +1274,7 @@ export async function decomposeGoal( goalId: goal.id, goalTitle: goal.title, goalDescription: goal.description, + goalSuccessCriteria: goal.successCriteria, plannerAgentId, prompt: `You are the PLANNER for goal "${goal.title}". Decompose it into ` + @@ -1277,12 +1296,31 @@ export async function decomposeGoal( after: { goalId: goal.id, plannerAgentId }, }, }); - if (plannerAgentId) { - await queueAgentDispatch(tx, { - workspaceId: params.workspaceId, - agentId: plannerAgentId, - eventId: event.id, - }); + if (plannerAgentId && plannerDispatchAgent && dispatchable) { + if (plannerDispatchAgent.runEngine === RunEngine.RUNS && goal.issueId) { + // Runtime-backed planners participate in the same durable AgentRun / + // inbox path as workers and reviewers. DISCUSS avoids treating plan + // creation as execution of the linked issue while the trigger prompt + // above supplies the concrete plans.addSteps contract. + await openOrTouchRun(tx, { + workspaceId: params.workspaceId, + issueId: goal.issueId, + agentId: plannerAgentId, + actorId: params.actorId, + actorAgentId: params.actorAgentId ?? null, + assignmentEventId: event.id, + triggerEventId: event.id, + triggerKind: EventKind.ISSUE_UPDATED, + currentStep: `Planning: ${goal.title}`, + ...orchestrationRunStamp(plannerDispatchAgent, EngagementMode.DISCUSS), + }); + } else { + await queueAgentDispatch(tx, { + workspaceId: params.workspaceId, + agentId: plannerAgentId, + eventId: event.id, + }); + } } return { planId: plan.id }; }); @@ -2309,28 +2347,55 @@ export async function recordVerdictTx( status: true, retryCount: true, title: true, - plan: { select: { id: true, maxStepRetries: true, crewId: true, goalId: true } }, + plan: { + select: { + id: true, + status: true, + isActiveAttempt: true, + maxStepRetries: true, + crewId: true, + goalId: true, + }, + }, }, }); if (!step) { throw new TRPCError({ code: "NOT_FOUND", message: "Execution step not found." }); } - // A verdict may only land on an in-flight step (READY / RUNNING / REVIEW). - // Reject it on an already-settled step (DONE / BLOCKED / CANCELED): a stale - // or duplicated reviewer webhook posting FAIL on a DONE step would otherwise - // move it back to TODO, bump retryCount, and re-dispatch a worker — - // un-completing finished work and desyncing downstream steps. Also stops an - // auto-judge double-fire from double-recording. - if ( - step.status === ExecutionStepStatus.DONE || - step.status === ExecutionStepStatus.BLOCKED || - step.status === ExecutionStepStatus.CANCELED - ) { + // A verdict is a review decision, not a generic step transition. Accepting + // PASS on TODO/READY/RUNNING lets any writer bypass dependencies and claim + // goal success without worker evidence. Settled states must not be reopened + // by a duplicate verdict either, so REVIEW is the only valid source state. + if (step.status !== ExecutionStepStatus.REVIEW) { + throw new TRPCError({ + code: "CONFLICT", + message: `Step is ${step.status} — verdicts can only be recorded while it is in review.`, + }); + } + if (step.plan.status !== ExecutionPlanStatus.RUNNING || !step.plan.isActiveAttempt) { throw new TRPCError({ code: "CONFLICT", - message: `Step is ${step.status} — a verdict can't be recorded on a settled step.`, + message: "Verdicts can only be recorded on the active running plan attempt.", }); } + if (params.actorAgentId) { + const authorizedReviewRun = await tx.agentRun.findFirst({ + where: { + workspaceId: params.workspaceId, + executionStepId: step.id, + agentId: params.actorAgentId, + engagementMode: EngagementMode.REVIEW, + status: { in: [AgentRunStatus.ACTIVE, AgentRunStatus.WAITING] }, + }, + select: { id: true }, + }); + if (!authorizedReviewRun) { + throw new TRPCError({ + code: "FORBIDDEN", + message: "Only the active reviewer assigned to this step can record its verdict.", + }); + } + } const verdictJson: JudgeVerdictJson = { verdict: params.verdict, @@ -2679,7 +2744,10 @@ export async function maybeCompleteGoal( const remaining = await tx.executionStep.count({ where: { planId: params.planId, - status: { notIn: [ExecutionStepStatus.DONE, ExecutionStepStatus.CANCELED] }, + // CANCELED is not success. Without optional-step semantics, allowing a + // canceled deliverable to satisfy completion can mark the Goal ACHIEVED + // even though required work was explicitly skipped. + status: { not: ExecutionStepStatus.DONE }, }, }); if (remaining > 0) return; From f75c11b8075936eddc0140b78a747487f6e24fd6 Mon Sep 17 00:00:00 2001 From: Bailey Dixon Date: Mon, 13 Jul 2026 10:37:06 -0400 Subject: [PATCH 2/3] feat: harden plan execution and issue lifecycle --- DEVLOG.md | 42 ++ docs/concepts/orchestration.md | 31 +- docs/concepts/primitives.md | 32 +- docs/guide/issues.md | 135 +++--- docs/reference/mcp.md | 42 +- docs/reference/trpc.md | 242 +++++----- .../migration.sql | 6 + .../migration.sql | 2 + prisma/schema.prisma | 80 ++-- src/app/(app)/w/[slug]/issues/[id]/page.tsx | 122 ++++- src/app/(app)/w/[slug]/issues/page.tsx | 69 ++- src/components/command-palette.tsx | 50 +- src/components/issue-board.tsx | 23 +- src/components/issue-detail/issue-main.tsx | 2 +- .../issue-detail/plan-step-context-card.tsx | 11 +- .../issue-detail/sub-issues-panel.tsx | 40 +- src/components/issue-list.tsx | 286 ++++++----- src/components/quick-create.tsx | 448 ++++++++---------- src/server/audit.ts | 42 ++ .../__tests__/action-request-accept.test.ts | 37 +- .../routers/__tests__/execution-plan.test.ts | 113 ++++- .../__tests__/issue-archive-surfaces.test.ts | 105 ++++ src/server/routers/__tests__/issue.test.ts | 204 ++++++++ src/server/routers/comment.ts | 84 +++- src/server/routers/issue.ts | 244 +++++++--- src/server/routers/label.ts | 42 +- src/server/routers/relation.ts | 11 + .../__tests__/agent-dispatch-inbox.test.ts | 39 +- .../execution-step-issue-sync.test.ts | 298 ++++++++++++ src/server/services/__tests__/mcp.test.ts | 117 ++++- .../__tests__/orchestration-context.test.ts | 69 +++ .../services/__tests__/orchestration.test.ts | 376 ++++++++++++++- .../services/__tests__/run-dispatcher.test.ts | 19 + src/server/services/action-request-service.ts | 243 ++++------ src/server/services/agent-dispatch-inbox.ts | 29 +- src/server/services/agent-run.ts | 41 +- .../services/dispatch/run-dispatcher.ts | 28 +- src/server/services/execution-plan-service.ts | 241 ++++++---- .../services/execution-step-integrity.ts | 241 ++++++++++ .../services/execution-step-issue-service.ts | 20 +- .../services/execution-step-issue-sync.ts | 266 +++++++++++ src/server/services/execution-step-runtime.ts | 8 + src/server/services/issue-archive.ts | 183 +++++++ src/server/services/mcp.ts | 58 ++- src/server/services/orchestration-context.ts | 81 ++++ src/server/services/orchestration-service.ts | 395 +++++++++++---- tests/unit/quick-create-modes.test.ts | 30 +- 47 files changed, 4170 insertions(+), 1157 deletions(-) create mode 100644 prisma/migrations/0097_agent_run_orchestration_context_snapshot/migration.sql create mode 100644 prisma/migrations/0098_issue_archive_list_index/migration.sql create mode 100644 src/server/routers/__tests__/issue-archive-surfaces.test.ts create mode 100644 src/server/services/__tests__/execution-step-issue-sync.test.ts create mode 100644 src/server/services/execution-step-integrity.ts create mode 100644 src/server/services/execution-step-issue-sync.ts create mode 100644 src/server/services/issue-archive.ts diff --git a/DEVLOG.md b/DEVLOG.md index 6a47c73a..968be2b4 100644 --- a/DEVLOG.md +++ b/DEVLOG.md @@ -2,6 +2,48 @@ > Append-only session log. Read at session start. Update at session end. +## 2026-07-13 — Plan context integrity + reversible issue archive + +Closed the execution-context gaps that let materialized plan issues look like +standalone tasks, then carried the same integrity pass through issue creation, +handling, and archive/restore. + +- **Durable plan context.** Materialized issue detail now shows Goal → Plan → + Step provenance, instructions, output/verification contracts, dependencies, + dependents, and sibling progress. Agent runs capture a versioned immutable + orchestration-context snapshot at dispatch; inbox and MCP context prefer that + snapshot so later plan edits cannot rewrite a run's instructions. +- **Orchestration invariants.** Step dependencies, assignees, roles, and source + runs are workspace/plan validated; structural edits are draft-only; role + resolution never guesses between multiple crew members. Crew `maxParallel` + is enforced across all of a crew's plans under row locks, with fair refill. + Materialized Issue and ExecutionStep terminal lifecycle now synchronizes in + both directions, including reaper/abandon paths. +- **Reversible issue archive.** Added `issue.archive`, `restore`, and + `bulkRestore`, an archived-only Issues view, archived detail tombstones, and + single/bulk restore. Archive atomically clears queue/claim/snooze state, + abandons live runs, cancels one unambiguous active materialized step, and + records audit/activity; restore returns visibility without resurrecting old + work. MCP, agent inbox, relations, comments, labels, and active mutations now + respect the archive boundary. Added migrations `0097` (run context snapshot) + and `0098` (archive-list index). +- **Issue-flow cleanup.** Board/status quick-add preserves its originating + status; command-palette create always opens issue mode; MCP create accepts + status/labels and enforces narrowed-key creation lanes; bulk copy says + “Select loaded”; list/count invalidation stays coherent. Reversible archive + confirmations no longer use destructive type-to-confirm friction. Comment + deletion is now tenant-scoped and label assignment validates workspace ids. +- **Usability evidence.** The flow pass was grounded in the live deployment + behavior already inspected plus current UI/contracts and existing Forge + components/tokens. No screenshot-based browser audit was claimed because an + in-app browser surface was unavailable; Playwright CLI was intentionally not + substituted without an explicit browser choice. + +Verification: Prisma validate/format, `pnpm lint` (existing repository warnings +only), `pnpm typecheck`, the full Vitest suite plus focused archive/MCP tests, +`git diff --check`, and a fresh docs + Next.js production build. Playwright was +not run for the browser-audit reason above. Not deployed. + ## 2026-07-11 — Recoverable agent review handoff Closed the Plan state-machine gap where a step could say “Needs review” after diff --git a/docs/concepts/orchestration.md b/docs/concepts/orchestration.md index 046180db..e6469a58 100644 --- a/docs/concepts/orchestration.md +++ b/docs/concepts/orchestration.md @@ -90,7 +90,18 @@ issue column; its Relations graph derives dependency edges from `dependsOnStepIds` without duplicating `IssueRelation` rows. Agent runtime prompts, `agent.inbox.list`, and `agent.context.bundle` use the same structured orchestration context, including retry feedback and completed dependency -evidence. +evidence. That bounded context is snapshotted onto the `AgentRun` when the run +opens; later Goal, Plan, Step, or ContextSet edits therefore do not rewrite the +instructions a historical run received. Legacy rows without a snapshot fall +back to live hydration. + +The ExecutionStep owns in-flight lifecycle state. Its `READY`, `RUNNING`, +`REVIEW`, `DONE`, and `CANCELED` transitions project onto a materialized Issue +using the workspace's configured/fallback status rows. Moving an Issue around +non-terminal board columns cannot bypass DAG readiness or manufacture review +evidence. An operator's explicit `DONE` or `CANCELED` Issue transition is +authoritative in the other direction: Forge updates the one unambiguous linked +step, cascading dependents on Done or blocking the plan on Cancel. ## The loop @@ -166,6 +177,13 @@ When a step reaches `DONE` (via `plans.recordVerdict` PASS **or** a manual `READY`, which resolves a worker and dispatches it. This cascade is transactional with the status change. +For crew-owned plans, admission to `READY` also reserves a crew execution slot. +`AgentCrew.maxParallel` counts `READY` + `RUNNING` steps across every RUNNING +plan owned by that crew; `0` means unlimited. Admission locks the crew row so +concurrent activations cannot oversubscribe it. A slot is released at REVIEW or +a terminal transition, then Forge deterministically rechecks dependency-ready +TODO work across the crew's plans. + ### Worker dispatch payload A step entering `READY` emits `EXECUTION_STEP_READY` and dispatches the @@ -275,14 +293,17 @@ which is a single decompose-and-execute pass against one goal. The crew is the roster (who can plan / work / review); the plan run is the work. One crew runs many goals over its lifetime; each goal/plan points back at its crew via `Goal.crewId` / `ExecutionPlan.crewId`. `maxParallel` is stored -and displayed as the crew's intended concurrency ceiling; readiness dispatch -does not enforce that ceiling yet, so operators should not treat it as a hard -runtime limit. +and enforced as the crew-wide execution ceiling at readiness admission. READY +and RUNNING steps reserve slots across all plans sharing the crew; `0` is +unlimited. The loop resolves roles from crew membership: the PLANNER decomposes, a WORKER executes each READY step, a REVIEWER judges steps that enter REVIEW (when `autoJudge` is on). The same agent can hold multiple roles -on one crew. +on one crew. A planner can set `assignedRole` on `plans.addSteps`; Forge resolves +it automatically when exactly one active crew member has that role. No match is +rejected; multiple matches require an explicit `assignedAgentId` that actually +holds the role, so Forge never guesses. ### Roles diff --git a/docs/concepts/primitives.md b/docs/concepts/primitives.md index 696bb067..e8ff6a6c 100644 --- a/docs/concepts/primitives.md +++ b/docs/concepts/primitives.md @@ -66,10 +66,16 @@ The unit of work. | `projectId` | Optional project membership. | | `cycleId` | Optional sprint membership. | | `slaMinutes` | Per-issue SLA for the breach watchdog. | +| `deletedAt` | Reversible archive timestamp. Archived issues leave active/agent surfaces until restored. | The two assignment slots — `claimedById` and `assignedAgentId` — coexist intentionally. See [Agents → Overview](/agents/overview.html). +Archiving an issue is operational teardown, not a hard delete: queue and +claim state are cleared, live runs are abandoned, and one unambiguous active +materialized plan step is canceled. Restoring clears `deletedAt` but does not +restart that work automatically. + The AI Triage columns (`aiTriageStatus`, `aiSuggestedPriority`, `aiSuggestedLabelIds`, `aiSuggestedAgentId`, `aiTriageReasoning`, `aiTriagedAt`, `aiTriageDecidedAt`) also live on Issue. See @@ -121,14 +127,15 @@ deliberate. Directed, typed link between two issues. -| Field | Meaning | -| ------------- | ------------------------------------------------------------------------------------------------- | -| `fromIssueId` | The source issue. | -| `toIssueId` | The target issue. | -| `kind` | One of `BLOCKS`, `BLOCKED_BY`, `DUPLICATES`, `DUPLICATED_BY`, `RELATED`, `PARENT_OF`, `CHILD_OF`. | +| Field | Meaning | +| ------------- | ---------------------------------------------------------- | +| `fromIssueId` | The source issue. | +| `toIssueId` | The target issue. | +| `kind` | One of `BLOCKS`, `BLOCKED_BY`, `DUPLICATES`, `RELATES_TO`. | -Relations are cascade-deleted from either end — when you delete an -issue, all relations involving it disappear. Pairs of inverse kinds +Relations are cascade-deleted from either end only on hard deletion. Issue +archive preserves the rows for restore and hides archived targets from active +relation views. Pairs of inverse kinds (`BLOCKS` / `BLOCKED_BY`) are typically created as two rows so traversal is symmetric. @@ -297,7 +304,9 @@ agent starts work: `expectedOutput` (markdown spec), `artifactRequired` (must the agent attach an artifact?). On the other side, AgentRun gains `producedArtifactIds`, `verificationResult`, and `followUps` so the final response is -structured. +structured. `AgentRun.orchestrationContextSnapshot` stores the bounded Goal / +Plan / Step / DAG context captured when the run opens, keeping historical run +instructions stable after later orchestration edits. MCP `agent.context.bundle` for issues exposes both a `completionContract` block and, for plan-linked issues, an `orchestrationContext` block with the @@ -312,7 +321,10 @@ project. Steps form an ordered list with optional `dependsOnStepIds` so the runner can mark later steps READY only when prerequisites are DONE. Steps can be assigned to agents or humans and carry their own per-step `expectedOutput` / -`verification`. +`verification`. Assignee, source-run, and dependency references are validated +inside the workspace; dependencies must remain inside one plan and acyclic. +Materialized Issues mirror step lifecycle progress, while explicit operator +Done/Cancel decisions feed back into their unique linked step. Plan lifecycle: `DRAFT` → `APPROVED` → `RUNNING` → `BLOCKED` / `COMPLETED` / `CANCELED`. @@ -328,6 +340,8 @@ A crew is a group of agents bound to roles (`PLANNER`, `WORKER`, `REVIEWER`, `OBSERVER`, `OPERATOR_PROXY`). The same agent can hold multiple roles on the same crew. ExecutionPlans optionally point at a crew via `crewId`. +`maxParallel` is a hard crew-wide cap on READY + RUNNING steps across its active +plans (`0` means unlimited); readiness admission is serialized on the crew row. ReviewGate is an approval checkpoint attached to any reviewable target (artifact / execution-plan / execution-step / diff --git a/docs/guide/issues.md b/docs/guide/issues.md index 54a3f72b..19a05920 100644 --- a/docs/guide/issues.md +++ b/docs/guide/issues.md @@ -7,26 +7,26 @@ queue concept, bulk operations, and how relations link issues together. ## Fields -| Field | Type | Notes | -|---|---|---| -| `id` | uuid | Internal identifier | -| `key` | string | Public key, e.g. `WRK-42`. Workspace-prefixed and unique within the workspace | -| `kind` | enum | `ISSUE` or `TASK`. Tasks are lighter-weight; defaults are typically tighter | -| `title` | string | Required, single line | -| `description` | markdown | Optional, free-form | -| `statusId` | fk | Status row in this workspace's status set | -| `priority` | enum | `NONE` / `LOW` / `MEDIUM` / `HIGH` / `URGENT` | -| `authorId` | fk | The user who created the issue | -| `dueDate` | date | Optional | -| `estimate` | int | Optional, dimensionless points | -| `slaMinutes` | int | Optional per-issue SLA in minutes; nonzero overrides workspace default | -| `projectId` | fk | Optional grouping | -| `parentId` | fk | Optional sub-issue parent | -| `cycleId` | fk | Optional sprint membership | -| `claimedById` | fk | Human assignee (independent slot) | -| `assignedAgentId` | fk | Agent assignee (independent slot) | -| `queued` | bool | Is this issue ready for an agent to pick up? | -| `aiTriageStatus` | enum | `PENDING` / `READY` / `APPLIED` / `DISMISSED` / `ERROR` / null | +| Field | Type | Notes | +| ----------------- | -------- | ----------------------------------------------------------------------------- | +| `id` | uuid | Internal identifier | +| `key` | string | Public key, e.g. `WRK-42`. Workspace-prefixed and unique within the workspace | +| `kind` | enum | `ISSUE` or `TASK`. Tasks are lighter-weight; defaults are typically tighter | +| `title` | string | Required, single line | +| `description` | markdown | Optional, free-form | +| `statusId` | fk | Status row in this workspace's status set | +| `priority` | enum | `NONE` / `LOW` / `MEDIUM` / `HIGH` / `URGENT` | +| `authorId` | fk | The user who created the issue | +| `dueDate` | date | Optional | +| `estimate` | int | Optional, dimensionless points | +| `slaMinutes` | int | Optional per-issue SLA in minutes; nonzero overrides workspace default | +| `projectId` | fk | Optional grouping | +| `parentId` | fk | Optional sub-issue parent | +| `cycleId` | fk | Optional sprint membership | +| `claimedById` | fk | Human assignee (independent slot) | +| `assignedAgentId` | fk | Agent assignee (independent slot) | +| `queued` | bool | Is this issue ready for an agent to pick up? | +| `aiTriageStatus` | enum | `PENDING` / `READY` / `APPLIED` / `DISMISSED` / `ERROR` / null | ::: info Status is not an enum — it's a foreign key to a workspace-defined status row. @@ -41,14 +41,14 @@ can aggregate without caring about the local labels. Forge ships these status categories. Workspaces can have many statuses within each category, but the category set is fixed. -| Category | Meaning | -|---|---| -| `BACKLOG` | Not yet planned | -| `TODO` | Planned, not started | -| `IN_PROGRESS` | Actively being worked | -| `IN_REVIEW` | Submitted for review or QA | -| `DONE` | Shipped, closed positively | -| `CANCELED` | Closed without shipping | +| Category | Meaning | +| ------------- | -------------------------- | +| `BACKLOG` | Not yet planned | +| `TODO` | Planned, not started | +| `IN_PROGRESS` | Actively being worked | +| `IN_REVIEW` | Submitted for review or QA | +| `DONE` | Shipped, closed positively | +| `CANCELED` | Closed without shipping | ## The two assignment slots @@ -102,9 +102,11 @@ re-route. Two paths. -**Quick create.** Press ⇧C from anywhere. The dialog covers -title, description, status, priority, project, kind. ⌘ Enter to -submit. This is the path for ~95% of creation. +**Quick create.** Press ⇧C from anywhere. The dialog covers title, +description, priority, project, and kind. ⌘ Enter submits. Creating +from a board column or status group also preserves that originating status; +creating from the command palette always opens issue mode, even while viewing +an existing issue. This is the primary creation path. The QuickCreate input also recognises [slash commands](/guide/slash-commands.html) at the start of the line: @@ -121,14 +123,13 @@ the issue title; the matching field updates fire as `applyCommands` on `issue.create`. The same parsing works in the issue detail comment composer. -**Full-page create.** Navigate to `/w//issues/new` for the longer -form: sub-issue parent, cycle, attachments, relations, agent assignment. - -Both paths call `issues.create` under the hood. From the API: +Sub-issues are created from the parent issue's Sub-issues panel. Attachments, +relations, sprint membership, and agent assignment can be added from the issue +detail after capture. All creation paths call `issue.create` under the hood. +From the API: ```ts -await trpc.issues.create.mutate({ - workspaceId, +await trpc.issue.create.mutate({ title: "Wire onboarding email", priority: "HIGH", projectId, @@ -148,6 +149,7 @@ bulk operations that are also available on the API surface: - `issue.bulkAssignAgent` — set `assignedAgentId` across N issues - `issue.bulkArchive` — confirmation-gated soft-delete via `Issue.deletedAt` +- `issue.bulkRestore` — restore selected rows from the Archived issues view - `issue.snoozeMany` — set `snoozedUntil` across N issues Each bulk call writes a single audit envelope per issue and fans out one @@ -156,17 +158,30 @@ opaque batch. ### Selection chords -| Chord | Action | -|---|---| -| x | Toggle selection on the row your cursor is over | -| ⇧X | Select range from the last selected row to the cursor | -| esc | Clear selection | +| Chord | Action | +| -------------- | ----------------------------------------------------- | +| x | Toggle selection on the row your cursor is over | +| ⇧X | Select range from the last selected row to the cursor | +| esc | Clear selection | Selection is shared with the Inbox bulk bar — the same `` primitive renders on both surfaces. The Issues board view intentionally skips bulk-select; drag-and-drop covers the highest-frequency op and checkbox-on-cards is visually noisy. +## Archive and restore + +Issue archive is reversible. Archiving sets `Issue.deletedAt`, removes the +issue from active lists and boards, clears queue/claim state, abandons live +agent runs, and cancels an unambiguous active materialized plan step. History +and relations remain stored for a later restore. + +Use **Archive** on an issue detail or select rows and choose **Archive** in the +list bulk bar. The **Archive** control on the issues page opens the archived-only +view. Open an archived issue or select archived rows to restore them. Restore +only returns the issue to active visibility; it does not resurrect claims, +agent runs, or canceled plan steps. + ::: tip For very large bulk operations (hundreds of issues), use the MCP REST endpoints and chunk into batches of ~50 to stay under request size @@ -202,14 +217,14 @@ When `Workspace.aiEnabled` and `aiTriageOnCreate` are both on, every newly created issue runs through AI triage. The state lives on the issue as `aiTriageStatus`: -| State | Meaning | -|---|---| -| `null` | AI triage didn't run (feature off, or not yet) | -| `PENDING` | Queued for triage | -| `READY` | Suggestions generated, awaiting review | -| `APPLIED` | Suggestions accepted by a human | -| `DISMISSED` | Suggestions rejected | -| `ERROR` | Triage failed (provider error, timeout, etc) | +| State | Meaning | +| ----------- | ---------------------------------------------- | +| `null` | AI triage didn't run (feature off, or not yet) | +| `PENDING` | Queued for triage | +| `READY` | Suggestions generated, awaiting review | +| `APPLIED` | Suggestions accepted by a human | +| `DISMISSED` | Suggestions rejected | +| `ERROR` | Triage failed (provider error, timeout, etc) | Triage suggestions cover priority, labels, and project — never the title or description. See [Agents → AI triage and coach](/agents/ai-triage-and-coach.html) @@ -220,12 +235,12 @@ and SLA breaches. Issues are linked through `IssueRelation` rows: directed and typed. -| Type | Meaning | -|---|---| -| `BLOCKS` | This issue blocks the target | -| `BLOCKED_BY` | This issue is blocked by the target | +| Type | Meaning | +| ------------ | -------------------------------------------------- | +| `BLOCKS` | This issue blocks the target | +| `BLOCKED_BY` | This issue is blocked by the target | | `DUPLICATES` | This issue duplicates the target (close as dup of) | -| `RELATES_TO` | Soft link, no semantic constraint | +| `RELATES_TO` | Soft link, no semantic constraint | `BLOCKS` and `BLOCKED_BY` are inverses; creating one writes both records so both sides of the graph are queryable cheaply. @@ -262,11 +277,11 @@ seven structured slash commands. Templates expand to a templated body in-place (and, in two cases, fire a follow-up mutation). They show up in the same autocomplete dropdown as the commands, listed after them. -| Template | Expands to | Side-effect | -|---|---|---| -| `/status []` | `**Status:** Working on ` | — | -| `/blocked ` | `**Blocked:** ` | Opens a `FREE_FORM` action request titled "Unblock needed" on this issue | -| `/approve` | `**Approved** ✓` | — | +| Template | Expands to | Side-effect | +| ------------------------------- | ------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------ | +| `/status []` | `**Status:** Working on ` | — | +| `/blocked ` | `**Blocked:** ` | Opens a `FREE_FORM` action request titled "Unblock needed" on this issue | +| `/approve` | `**Approved** ✓` | — | | `/handoff @ []` | Injects `/assign @` at the top, then `**Handing off to @:** ` | Reassigns the issue to the named agent via the existing `/assign` command path | Picking a template from the dropdown replaces the line and parks your diff --git a/docs/reference/mcp.md b/docs/reference/mcp.md index 63e2253a..d9ceaccd 100644 --- a/docs/reference/mcp.md +++ b/docs/reference/mcp.md @@ -180,8 +180,14 @@ to hydrate optional sections in one round-trip: } ``` -`currentRun` is the most recent AgentRun whose status is not `COMPLETED` -or `ABANDONED` (so `ACTIVE` and `STALLED` qualify). +`currentRun` is the most recent `ACTIVE` or `WAITING` AgentRun. Agent-linked +keys only see their own run; terminal `STALLED`, `ABANDONED`, and `COMPLETED` +history never masquerades as current work. + +Archived issues are excluded from `issues.list`, `issues.get`, agent inboxes, +and `agent.context.bundle`. Archiving is an operator lifecycle action in the +web app; restoring visibility does not restart abandoned runs or canceled plan +steps. ### `comments` @@ -627,14 +633,14 @@ Scope: `READ_ISSUES` for reads, `WRITE_ISSUES` for the loop mutations, #### Plans (loop) -| Tool | Summary | -| ----------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `plans.decompose` | `{ goalId, plannerAgentId?, contextSetId? }` → `{ planId, status: "PLANNING", plannerAgentId }`. Creates a DRAFT plan (`isActiveAttempt=true`, prior attempts flipped false), flips goal → PLANNING, dispatches the PLANNER (override > crew PLANNER > caller's agent). | -| `plans.addSteps` | `{ planId, steps: [{ title, body?, expectedOutput?, verification?, dependsOnStepIndexes?, assignedAgentId?, assignedRole? }] }` → `{ stepIds }`. Bulk-adds to a DRAFT plan; `dependsOnStepIndexes` are 0-based positions within the batch, resolved to real ids. | -| `plans.requestApproval` | `{ planId, assignedUserId? }` → `{ actionRequestId }`. Raises a FREE_FORM ActionRequest (`sourceType="execution-plan"`). Accepting it activates the plan. | -| `plans.activate` | `{ planId }` → `{ ok }`. DRAFT/APPROVED → RUNNING, goal → ACTIVE + `startedAt`, root steps cascade READY. Usually fired by accepting the approval ActionRequest. | -| `plans.judge` | `{ stepId, judgeAgentId? }` → `{ judgeAgentId }`. Dispatches a REVIEWER (override > crew REVIEWER) to evaluate a step in REVIEW. | -| `plans.recordVerdict` | `{ stepId, verdict: "PASS"\|"FAIL", feedback, score? }` → `{ outcome: "DONE"\|"RETRY"\|"BLOCKED", retryCount }`. PASS → DONE + cascade. FAIL → READY+retry (feedback stored) or BLOCKED + ReviewGate when retries exhausted. Emits `EXECUTION_STEP_JUDGED`. | +| Tool | Summary | +| ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `plans.decompose` | `{ goalId, plannerAgentId?, contextSetId? }` → `{ planId, status: "PLANNING", plannerAgentId }`. Creates a DRAFT plan (`isActiveAttempt=true`, prior attempts flipped false), flips goal → PLANNING, dispatches the PLANNER (override > crew PLANNER > caller's agent). | +| `plans.addSteps` | `{ planId, steps: [{ title, body?, expectedOutput?, verification?, dependsOnStepIndexes?, assignedAgentId?, assignedRole? }] }` → `{ stepIds }`. Bulk-adds to a DRAFT plan; dependency indexes must be in-range, non-self, and acyclic. `assignedRole` auto-resolves a unique crew member; multiple matches require an explicit matching `assignedAgentId`, and no match is rejected. | +| `plans.requestApproval` | `{ planId, assignedUserId? }` → `{ actionRequestId }`. Raises a FREE_FORM ActionRequest (`sourceType="execution-plan"`). Accepting it activates the plan. | +| `plans.activate` | `{ planId }` → `{ ok }`. DRAFT/APPROVED → RUNNING, goal → ACTIVE + `startedAt`, root steps cascade READY. Usually fired by accepting the approval ActionRequest. | +| `plans.judge` | `{ stepId, judgeAgentId? }` → `{ judgeAgentId }`. Dispatches a REVIEWER (override > crew REVIEWER) to evaluate a step in REVIEW. | +| `plans.recordVerdict` | `{ stepId, verdict: "PASS"\|"FAIL", feedback, score? }` → `{ outcome: "DONE"\|"RETRY"\|"BLOCKED", retryCount }`. PASS → DONE + cascade. FAIL → READY+retry (feedback stored) or BLOCKED + ReviewGate when retries exhausted. Emits `EXECUTION_STEP_JUDGED`. | ```json // plans.addSteps — index-based deps @@ -654,10 +660,10 @@ Scope: `READ_ISSUES` for reads, `WRITE_ISSUES` for the loop mutations, #### Plan substrate (existing) -| Tool | Summary | -| ---------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ | -| `executionPlans.list` / `.get` / `.create` / `.transition` | Plan CRUD + status transitions. | -| `executionPlans.transitionStep` | `{ stepId, status, sourceRunId? }`. Marking a step DONE cascades downstream readiness; marking REVIEW triggers auto-judge when the plan opts in. | +| Tool | Summary | +| ---------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `executionPlans.list` / `.get` / `.create` / `.transition` | Plan CRUD + status transitions. | +| `executionPlans.transitionStep` | `{ stepId, status, sourceRunId? }`. References are workspace/plan validated. READY/RUNNING are orchestration-owned and cannot be forced through this generic mutation. Marking DONE cascades downstream readiness; REVIEW triggers auto-judge when enabled. | #### Crews @@ -665,7 +671,7 @@ Scope: `READ_ISSUES` for reads, `WRITE_ISSUES` for the loop mutations, | -------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- | | `agentCrews.list` | Existing read. Crews + member/plan counts. | | `agentCrews.create` | `{ name, description?, maxParallel?, members?: [{ agentId, role }] }` → `{ id }`. Roles: `PLANNER`/`WORKER`/`REVIEWER`/`OBSERVER`/`OPERATOR_PROXY`. | -| `agentCrews.update` | `{ id, name?, description?, maxParallel? }`. | +| `agentCrews.update` | `{ id, name?, description?, maxParallel? }`. The cap is enforced across READY + RUNNING steps on every plan sharing the crew; `0` is unlimited. | | `agentCrews.addMember` | `{ crewId, agentId, role }` → `{ id }`. | | `agentCrews.removeMember` | `{ memberId }`. | | `agentCrews.setMemberRole` | `{ memberId, role }` → `{ id }`. | @@ -709,9 +715,9 @@ round-trips on dispatch — bundles workspace + issue (or thread) + comments - attachments + relations + currentRun in one call. -| Tool | Summary | -| ---------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `context.bundle` | `{ issueId? }` xor `{ threadId? }`. For `issueId`: returns `{ workspace, issue (full row), description, comments (last 50), attachments, relations, currentRun, artifacts, orchestrationContext, completionContract, runProtocol, externalResources }`. `currentRun` is limited to `ACTIVE`/`WAITING` and, for an agent key, to that linked agent. `orchestrationContext` carries the Goal, Plan, current Step (when unambiguous), dependencies/dependents, sibling progress, retry feedback, worker evidence, and non-excluded ContextSet refs. `externalResources` contains linked GitHub issues/PRs with their link kind and latest local snapshot. `runProtocol` includes `{ contractVersion, runId, engagementMode, modeInstruction, protocolInstruction, mayMutateIssue }` so agents can ack/output-start/complete the correct run and know whether issue mutations are allowed. For `threadId`: returns `{ workspace, thread, messages (last 50), agent (peer summary), linkedIssues (any issues mentioned in messages' contextSnapshots) }`. Addressee gating mirrors `chat.getThread` for the threadId branch; issue-narrowing applies for the issueId branch. | +| Tool | Summary | +| ---------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `context.bundle` | `{ issueId? }` xor `{ threadId? }`. For `issueId`: returns `{ workspace, issue (full row), description, comments (last 50), attachments, relations, currentRun, artifacts, orchestrationContext, completionContract, runProtocol, externalResources }`. `currentRun` is limited to `ACTIVE`/`WAITING` and, for an agent key, to that linked agent. `orchestrationContext` carries the Goal, Plan, current Step (when unambiguous), dependencies/dependents, sibling progress, retry feedback, worker evidence, and non-excluded ContextSet refs. For a current run it prefers the immutable dispatch snapshot; legacy/no-run contexts use live hydration. `externalResources` contains linked GitHub issues/PRs with their link kind and latest local snapshot. `runProtocol` includes `{ contractVersion, runId, engagementMode, modeInstruction, protocolInstruction, mayMutateIssue }` so agents can ack/output-start/complete the correct run and know whether issue mutations are allowed. For `threadId`: returns `{ workspace, thread, messages (last 50), agent (peer summary), linkedIssues (any issues mentioned in messages' contextSnapshots) }`. Addressee gating mirrors `chat.getThread` for the threadId branch; issue-narrowing applies for the issueId branch. | ## Not on MCP diff --git a/docs/reference/trpc.md b/docs/reference/trpc.md index 03f4d50a..bce8086f 100644 --- a/docs/reference/trpc.md +++ b/docs/reference/trpc.md @@ -25,40 +25,40 @@ error shapes all flow from this router definition. ## Router catalog -| Router | Procedures | -|-------------------|-----------------------------------------------------------------------------------------------------------------| -| `workspace` | `list`, `current`, `create`, `listMembers`, `addMember`, `setMemberRole`, `removeMember` (admin) | -| `project` | `list`, `byId`, `create`, `update`, `archive` | -| `issue` | `list`, `byId`, `create`, `update`, `assign`, `softDelete`, `snooze`, `unsnooze`, `snoozeMany`, `nudge`, `bulkTransition`, `bulkAddLabel`, `bulkRemoveLabel`, `bulkAssign`, `bulkAssignAgent`, `bulkArchive`, `applyCommands`, `watch`, `unwatch`, `watchers`, `watching` | -| `comment` | `create`, `update`, `softDelete` | -| `analytics` | `summary`, `statusDistribution`, `throughput`, `cycleTime`, `slaBreaches`, `dispatch.summary`, `dispatch.timeseries` | -| `plugin` | `list`, `byId`, `register`, `restoreBackup`, `exportBackup`, `approve`, `suspend`, `issueApiKey`, `revokeApiKey`, `remove`, `rotateSecret` | -| `status` | `list`, `create`, `reorder` | -| `template` | `list`, `byId`, `create`, `update`, `delete` (issue templates) | -| `projectTemplate` | `list`, `create`, `update`, `delete` | -| `agent` | `list`, `byId`, `byProfileKey`, `create`, `update`, `archive`, `delete`, `testWebhook`, `heartbeat`, `pipeline`, `timeline`, `uptime`, `webhookHealth` | -| `agentRun` | `activeForIssue`, `events`, `activeAll`, `recentTerminal`, `heatmap`, `eventsInRange`, `recentEventCounts`, `coachDiagnosis`, `runsInRange`, `eta`, `abandon`, `redispatch`, `nudge` | +| Router | Procedures | +| ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `workspace` | `list`, `current`, `create`, `listMembers`, `addMember`, `setMemberRole`, `removeMember` (admin) | +| `project` | `list`, `byId`, `create`, `update`, `archive` | +| `issue` | `list`, `count`, `byId`, `create`, `update`, `assign`, `archive`, `restore`, `softDelete` (compat alias), `snooze`, `unsnooze`, `snoozeMany`, `nudge`, `bulkTransition`, `bulkAddLabel`, `bulkRemoveLabel`, `bulkAssign`, `bulkAssignAgent`, `bulkArchive`, `bulkRestore`, `applyCommands`, `watch`, `unwatch`, `watchers`, `watching` | +| `comment` | `create`, `update`, `softDelete` | +| `analytics` | `summary`, `statusDistribution`, `throughput`, `cycleTime`, `slaBreaches`, `dispatch.summary`, `dispatch.timeseries` | +| `plugin` | `list`, `byId`, `register`, `restoreBackup`, `exportBackup`, `approve`, `suspend`, `issueApiKey`, `revokeApiKey`, `remove`, `rotateSecret` | +| `status` | `list`, `create`, `reorder` | +| `template` | `list`, `byId`, `create`, `update`, `delete` (issue templates) | +| `projectTemplate` | `list`, `create`, `update`, `delete` | +| `agent` | `list`, `byId`, `byProfileKey`, `create`, `update`, `archive`, `delete`, `testWebhook`, `heartbeat`, `pipeline`, `timeline`, `uptime`, `webhookHealth` | +| `agentRun` | `activeForIssue`, `events`, `activeAll`, `recentTerminal`, `heatmap`, `eventsInRange`, `recentEventCounts`, `coachDiagnosis`, `runsInRange`, `eta`, `abandon`, `redispatch`, `nudge` | | `chat` | `threads`, `defaultThread`, `thread`, `createConversation`, `forkThread`, `updateConversation`, `setOverride`, `markRead`, `compactThread`, `clearThread`, `getThread`, `send`, `createPendingMessage`, `dispatchMessage`, `appendAgentMessage`, `threadDiagnostics`, `chatReadiness`, `retryLastUserMessage`, `kickThreadRun`, `stopThreadRun`, `deleteThread`, `archiveThread`, `restoreThread`, `history` | -| `event` | `recent`, `unreadCount` | -| `dispatchRule` | `list`, `create`, `update`, `reorder`, `toggle`, `delete` (admin) | -| `admin` | `webhookDeliveries.list`, `webhookDeliveries.retry` (admin) | -| `user` | `me`, `updateAppearance` | -| `cycle` | `list`, `byId`, `current`, `create`, `update`, `plan`, `rollover`, `addIssue`, `removeIssue` | -| `initiative` | `list`, `byId`, `create`, `update`, `linkProject`, `unlinkProject` | -| `relation` | `add`, `remove`, `listForIssue` | -| `time` | `start`, `stop`, `log`, `list`, `summary`, `running` | -| `attachment` | `initUpload`, `finalize`, `attachLink`, `list`, `getDownloadUrl`, `delete` | -| `access` | `list`, `create`, `update`, `revoke`, `delete`, `rotate`, `createPersonal`, `createSession` | -| `integration` | `list`, `byKind`, `applyToAgent` | -| `runtime` | `list`, `byId`, `register`, `heartbeat`, `archive`, `update` | -| `pin` | `list`, `set`, `toggle` (legacy issue-only); `listAll`, `add`, `remove`, `toggleEntity`, `reorder` (polymorphic) | -| `recentItem` | `list`, `track` | -| `commandPalette` | `search` | -| `savedView` | `list`, `create`, `update`, `delete` | -| `note` | `list`, `create`, `update`, `archive`, `unarchive`, `delete`, `convertToIssue`, `todayJournal`, `listJournal` | -| `inbox` | `list`, `badge`, `visit` | -| `dashboard` | `suggestions`, `stalledInProgress` | -| `notification` | `list`, `markRead`, `markAllRead`, `dismiss` | +| `event` | `recent`, `unreadCount` | +| `dispatchRule` | `list`, `create`, `update`, `reorder`, `toggle`, `delete` (admin) | +| `admin` | `webhookDeliveries.list`, `webhookDeliveries.retry` (admin) | +| `user` | `me`, `updateAppearance` | +| `cycle` | `list`, `byId`, `current`, `create`, `update`, `plan`, `rollover`, `addIssue`, `removeIssue` | +| `initiative` | `list`, `byId`, `create`, `update`, `linkProject`, `unlinkProject` | +| `relation` | `add`, `remove`, `listForIssue` | +| `time` | `start`, `stop`, `log`, `list`, `summary`, `running` | +| `attachment` | `initUpload`, `finalize`, `attachLink`, `list`, `getDownloadUrl`, `delete` | +| `access` | `list`, `create`, `update`, `revoke`, `delete`, `rotate`, `createPersonal`, `createSession` | +| `integration` | `list`, `byKind`, `applyToAgent` | +| `runtime` | `list`, `byId`, `register`, `heartbeat`, `archive`, `update` | +| `pin` | `list`, `set`, `toggle` (legacy issue-only); `listAll`, `add`, `remove`, `toggleEntity`, `reorder` (polymorphic) | +| `recentItem` | `list`, `track` | +| `commandPalette` | `search` | +| `savedView` | `list`, `create`, `update`, `delete` | +| `note` | `list`, `create`, `update`, `archive`, `unarchive`, `delete`, `convertToIssue`, `todayJournal`, `listJournal` | +| `inbox` | `list`, `badge`, `visit` | +| `dashboard` | `suggestions`, `stalledInProgress` | +| `notification` | `list`, `markRead`, `markAllRead`, `dismiss` | ## Notable procedures @@ -153,7 +153,7 @@ agent.stalled({ agentId }) → { - **Stalled runs** are `AgentRun` rows still `ACTIVE` whose `lastEventAt` is older than `STALE_RUN_MS` (5 min). UI signal — distinct from the - watchdog that *closes* runs (`Workspace.agentRunStaleMinutes`). + watchdog that _closes_ runs (`Workspace.agentRunStaleMinutes`). - **Stalled issues** are issues currently assigned to the agent in an IN_PROGRESS / IN_REVIEW status that haven't been updated in `Workspace.stalledThresholdDays`. Snoozed rows are excluded. When @@ -236,72 +236,72 @@ require a server-side read receipt model. The chat router manages per-(workspace, user, agent) persistent threads. -| Procedure | Type | Summary | -|---|---|---| -| `threads` | query | List the caller's threads with all agents. Returns up to 75, newest last-message first, including `lastReadAt` for unread badges. | -| `thread({ agentId })` | mutation | Upsert and open a thread. Returns `{ thread, agent, messages }` (last 50 messages). | -| `getThread({ threadId })` | query | Fetch a concrete thread by id. Owner-scoped and includes `lastReadAt`, diagnostics, recent messages, and attachments. | -| `markRead({ threadId, readAt? })` | mutation | Move the caller's per-thread read anchor forward. Used by the Chat page and Mission Control Chat tab; timestamps never move backward. | -| `defaultThread({ agentId })` / `thread({ agentId })` | mutation | Upsert and open the caller's always-on DM thread for one agent. | -| `createConversation({ agentId, title?, topic?, ... })` | mutation | Create a named side conversation with an agent. | -| `forkThread({ threadId, fromMessageId? })` | mutation | Start a new conversation copied from an existing thread prefix. | -| `updateConversation({ threadId, title?, topic?, contextMode? })` | mutation | Update conversation metadata and context policy. | -| `setOverride({ threadId, provider? })` | mutation | Set or clear a per-thread provider override. | -| `compactThread({ threadId })` | mutation | Summarize older messages into durable context and keep recent messages live. | -| `clearThread({ threadId })` | mutation | Delete the thread's messages/attachments/events and reset summary context while preserving the thread row. | -| `send({ agentId, body, context? })` | mutation | Legacy tRPC send path: persist a USER message and trigger dispatch. The interactive UI now uses `/api/chat/stream` so runs/completions can stream. | -| `createPendingMessage({ threadId, ... })` / `dispatchMessage({ messageId })` | mutation | Two-step dispatch path used by clients that need an optimistic pending row before waking an agent. | -| `appendAgentMessage({ threadId, body, sourceRunId? })` | mutation | Agent-only path. Requires the calling API key's `linkedAgentId` to match the thread's agent; acknowledges the latest unfinished USER turn and returns `{ messageId }`. | -| `threadDiagnostics({ threadId })` | query | Return provider-neutral turn state, latest USER lifecycle, stream error/interruption, last run, and last delivery for the status rail. | -| `chatReadiness({ agentId, threadId? })` | query | Resolve whether chat reaches a model/runtime and report the effective transport/capabilities/hint. | -| `retryLastUserMessage({ threadId })` | mutation | Re-wake the latest dispatched USER message when a reply or delivery stalls. | -| `kickThreadRun({ threadId, runId })` | mutation | Emit a kick event for a stale active run linked to the conversation. | -| `stopThreadRun({ threadId, runId })` | mutation | Best-effort stop a live managed-runtime run and close the Forge mirror. | -| `archiveThread({ threadId })` / `restoreThread({ threadId })` | mutation | Hide or restore a conversation without deleting its history. | -| `deleteThread({ threadId })` | mutation | Permanently delete a conversation, its messages, and attachments; stops a live managed run when possible. | -| `history({ threadId, before?, limit })` | query | Paginate older messages. `before` is a date cursor; `limit` max 100. Scoped to the caller's own threads. | +| Procedure | Type | Summary | +| ---------------------------------------------------------------------------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `threads` | query | List the caller's threads with all agents. Returns up to 75, newest last-message first, including `lastReadAt` for unread badges. | +| `thread({ agentId })` | mutation | Upsert and open a thread. Returns `{ thread, agent, messages }` (last 50 messages). | +| `getThread({ threadId })` | query | Fetch a concrete thread by id. Owner-scoped and includes `lastReadAt`, diagnostics, recent messages, and attachments. | +| `markRead({ threadId, readAt? })` | mutation | Move the caller's per-thread read anchor forward. Used by the Chat page and Mission Control Chat tab; timestamps never move backward. | +| `defaultThread({ agentId })` / `thread({ agentId })` | mutation | Upsert and open the caller's always-on DM thread for one agent. | +| `createConversation({ agentId, title?, topic?, ... })` | mutation | Create a named side conversation with an agent. | +| `forkThread({ threadId, fromMessageId? })` | mutation | Start a new conversation copied from an existing thread prefix. | +| `updateConversation({ threadId, title?, topic?, contextMode? })` | mutation | Update conversation metadata and context policy. | +| `setOverride({ threadId, provider? })` | mutation | Set or clear a per-thread provider override. | +| `compactThread({ threadId })` | mutation | Summarize older messages into durable context and keep recent messages live. | +| `clearThread({ threadId })` | mutation | Delete the thread's messages/attachments/events and reset summary context while preserving the thread row. | +| `send({ agentId, body, context? })` | mutation | Legacy tRPC send path: persist a USER message and trigger dispatch. The interactive UI now uses `/api/chat/stream` so runs/completions can stream. | +| `createPendingMessage({ threadId, ... })` / `dispatchMessage({ messageId })` | mutation | Two-step dispatch path used by clients that need an optimistic pending row before waking an agent. | +| `appendAgentMessage({ threadId, body, sourceRunId? })` | mutation | Agent-only path. Requires the calling API key's `linkedAgentId` to match the thread's agent; acknowledges the latest unfinished USER turn and returns `{ messageId }`. | +| `threadDiagnostics({ threadId })` | query | Return provider-neutral turn state, latest USER lifecycle, stream error/interruption, last run, and last delivery for the status rail. | +| `chatReadiness({ agentId, threadId? })` | query | Resolve whether chat reaches a model/runtime and report the effective transport/capabilities/hint. | +| `retryLastUserMessage({ threadId })` | mutation | Re-wake the latest dispatched USER message when a reply or delivery stalls. | +| `kickThreadRun({ threadId, runId })` | mutation | Emit a kick event for a stale active run linked to the conversation. | +| `stopThreadRun({ threadId, runId })` | mutation | Best-effort stop a live managed-runtime run and close the Forge mirror. | +| `archiveThread({ threadId })` / `restoreThread({ threadId })` | mutation | Hide or restore a conversation without deleting its history. | +| `deleteThread({ threadId })` | mutation | Permanently delete a conversation, its messages, and attachments; stops a live managed run when possible. | +| `history({ threadId, before?, limit })` | query | Paginate older messages. `before` is a date cursor; `limit` max 100. Scoped to the caller's own threads. | ### `agentRun.*` additions In addition to the original `activeForIssue`, `events`, `activeAll`, `recentTerminal`, and `heatmap` procedures, the following were added: -| Procedure | Type | Summary | -|---|---|---| -| `recentEventCounts({ windowMinutes?, bucketSeconds? })` | query | Per-minute bucketed event counts for the activity sparkline in Mission Control. Default 30-minute window, 60-second buckets. | -| `coachDiagnosis({ runId })` | query | Latest AI Coach comment for a run (or `null` when coaching is disabled). | -| `runsInRange({ fromMinutesAgo?, limit? })` | query | All runs (active + terminal) overlapping a sliding window. Powers the swimlane/Gantt view. | -| `eta({ runId })` | query | Predictive ETA based on median agent+label duration over the past 30 days. Returns `{ medianMs, sampleSize, etaMs }` or `null`. | -| `eventsInRange({ from, to, limit? })` | query | `AgentRunEvent` rows in an explicit time range with run+agent+issue summary. Powers the timeline scrubber. | -| `abandon({ runId, summary?, alsoUnassign? })` | mutation | Mark a run ABANDONED, optionally clear the issue assignment. | -| `redispatch({ runId })` | mutation | Abandon the current run, re-queue the issue, and trigger auto-dispatch. | -| `nudge({ runId, message? })` | mutation | Post a `@{profileKey} {message}` comment on the issue; the audit fan-out routes it to the agent's webhook. | -| `kick({ runId })` | mutation | Re-fire the dispatch webhook for a stalled run without changing assignment or `controlState`. Eligibility: run is `ACTIVE` and quiet 5+ minutes (`STALE_RUN_MS` from `src/lib/agent-stale.ts`). Younger runs return `{ ok: true, kicked: false }`; non-active runs throw. Records `AGENT_RUN_KICKED`. | +| Procedure | Type | Summary | +| ------------------------------------------------------- | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `recentEventCounts({ windowMinutes?, bucketSeconds? })` | query | Per-minute bucketed event counts for the activity sparkline in Mission Control. Default 30-minute window, 60-second buckets. | +| `coachDiagnosis({ runId })` | query | Latest AI Coach comment for a run (or `null` when coaching is disabled). | +| `runsInRange({ fromMinutesAgo?, limit? })` | query | All runs (active + terminal) overlapping a sliding window. Powers the swimlane/Gantt view. | +| `eta({ runId })` | query | Predictive ETA based on median agent+label duration over the past 30 days. Returns `{ medianMs, sampleSize, etaMs }` or `null`. | +| `eventsInRange({ from, to, limit? })` | query | `AgentRunEvent` rows in an explicit time range with run+agent+issue summary. Powers the timeline scrubber. | +| `abandon({ runId, summary?, alsoUnassign? })` | mutation | Mark a run ABANDONED, optionally clear the issue assignment. | +| `redispatch({ runId })` | mutation | Abandon the current run, re-queue the issue, and trigger auto-dispatch. | +| `nudge({ runId, message? })` | mutation | Post a `@{profileKey} {message}` comment on the issue; the audit fan-out routes it to the agent's webhook. | +| `kick({ runId })` | mutation | Re-fire the dispatch webhook for a stalled run without changing assignment or `controlState`. Eligibility: run is `ACTIVE` and quiet 5+ minutes (`STALE_RUN_MS` from `src/lib/agent-stale.ts`). Younger runs return `{ ok: true, kicked: false }`; non-active runs throw. Records `AGENT_RUN_KICKED`. | ### `access.*` Workspace API key management. Admin-gated for all mutations. -| Procedure | Summary | -|---|---| -| `list` | List non-plugin keys for the workspace. | -| `create` | Create a key with explicit `kind` (or infer from `linkedAgentId`). | -| `createPersonal` | Shorthand for `kind: PERSONAL`. No agent link. Permanent until revoked. | -| `createSession` | Shorthand for `kind: SESSION`. Requires `ttlHours` (1–168, default 24). Auto-expires. | -| `update` | Edit name or narrowing arrays. Scopes and hash are immutable. | -| `revoke` | Set `revokedAt`; immediately rejects all further calls. | -| `delete` | Hard-delete a non-plugin key. | -| `rotate` | Revoke and re-issue with the same name, scopes, and narrowing. Returns `rawKey` once. | +| Procedure | Summary | +| ---------------- | ------------------------------------------------------------------------------------- | +| `list` | List non-plugin keys for the workspace. | +| `create` | Create a key with explicit `kind` (or infer from `linkedAgentId`). | +| `createPersonal` | Shorthand for `kind: PERSONAL`. No agent link. Permanent until revoked. | +| `createSession` | Shorthand for `kind: SESSION`. Requires `ttlHours` (1–168, default 24). Auto-expires. | +| `update` | Edit name or narrowing arrays. Scopes and hash are immutable. | +| `revoke` | Set `revokedAt`; immediately rejects all further calls. | +| `delete` | Hard-delete a non-plugin key. | +| `rotate` | Revoke and re-issue with the same name, scopes, and narrowing. Returns `rawKey` once. | ### `integration.*` Read-only adapter manifest queries plus one mutation for tagging legacy agents. -| Procedure | Summary | -|---|---| -| `list` | Return all adapter manifests merged with matching agents in this workspace. | -| `byKind({ kind, presence? })` | Return one adapter manifest + its installed agents. `presence` disambiguates the two `CLAUDE` adapters. | -| `applyToAgent({ agentId, kind, presence? })` | Stamp an existing agent with the adapter's `provider` and `defaultRuntimeMode`. | +| Procedure | Summary | +| -------------------------------------------- | ------------------------------------------------------------------------------------------------------- | +| `list` | Return all adapter manifests merged with matching agents in this workspace. | +| `byKind({ kind, presence? })` | Return one adapter manifest + its installed agents. `presence` disambiguates the two `CLAUDE` adapters. | +| `applyToAgent({ agentId, kind, presence? })` | Stamp an existing agent with the adapter's `provider` and `defaultRuntimeMode`. | ### `runtime.*` @@ -309,14 +309,14 @@ CRUD for the `Runtime` primitive — the compute environment that hosts one or more agents. See [/agents/runtimes.html](/agents/runtimes.html) for the broader concept. -| Procedure | Summary | -|---|---| -| `list` | All non-archived runtimes for the workspace, with `_count: { agents }` and `owner` summary. | -| `byId({ id })` | Single runtime + its agents (id, name, profileKey, status, runtimeMode). | -| `register` | Create a runtime. `{ name, kind, endpoint?, providersAvailable }`. Sets `ownerId` from session. Used by the `forge` daemon and by admins manually wiring a REMOTE_HTTP runtime. | -| `heartbeat({ id })` | Bump `heartbeatAt`. The local daemon calls every 60s. | -| `update({ id, name?, providersAvailable? })` | Edit metadata. Endpoint and secret are not editable post-creation (rotate by archive + re-register). | -| `archive({ id })` | Soft-delete. Agents on the runtime keep their `runtimeId` but the runtime is hidden from the index. | +| Procedure | Summary | +| -------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `list` | All non-archived runtimes for the workspace, with `_count: { agents }` and `owner` summary. | +| `byId({ id })` | Single runtime + its agents (id, name, profileKey, status, runtimeMode). | +| `register` | Create a runtime. `{ name, kind, endpoint?, providersAvailable }`. Sets `ownerId` from session. Used by the `forge` daemon and by admins manually wiring a REMOTE_HTTP runtime. | +| `heartbeat({ id })` | Bump `heartbeatAt`. The local daemon calls every 60s. | +| `update({ id, name?, providersAvailable? })` | Edit metadata. Endpoint and secret are not editable post-creation (rotate by archive + re-register). | +| `archive({ id })` | Soft-delete. Agents on the runtime keep their `runtimeId` but the runtime is hidden from the index. | ### `agent.unifiedTimeline` @@ -348,29 +348,29 @@ Per-(workspace, user) markdown scratchpad. The dashboard `notes.*` mirrors a narrower agent-facing subset (no `unarchive`, no `delete`, no `convertToIssue` — those are human-only). -| Procedure | Summary | -|---|---| -| `list({ archived?, kind?, limit? })` | Caller's notes ordered by `(pinned desc, updatedAt desc)`. Default `kind = NOTE`, unarchived. | -| `create({ title?, body, pinned?, kind?, journalDate? })` | Create a note for the calling user. `kind = JOURNAL` requires `journalDate` (or defaults to now). | -| `update({ id, title?, body?, pinned? })` | Patch a note the caller owns. `title: null` clears the title. Empty body allowed (for journal entries that are still being filled in). | -| `archive({ id })` | Soft-archive. | -| `unarchive({ id })` | Reverse archive. | -| `delete({ id })` | Hard-delete. Prefer `archive` — this is the cleanup path. | -| `convertToIssue({ id, projectId? })` | Spawn an Issue with `title = note.title \|\| first line of body` and `description = body`. The note is left in place. Returns `{ issueId, issueKey, number }`. | -| `todayJournal()` | Get-or-create today's `JOURNAL` entry for the caller, anchored to UTC midnight on the user's wall-clock date (driven by `User.timezone`). Idempotent across calls in the same day. | -| `listJournal({ from?, to?, limit? = 30 })` | List recent journal entries for the caller, ordered by `journalDate desc`. | +| Procedure | Summary | +| -------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `list({ archived?, kind?, limit? })` | Caller's notes ordered by `(pinned desc, updatedAt desc)`. Default `kind = NOTE`, unarchived. | +| `create({ title?, body, pinned?, kind?, journalDate? })` | Create a note for the calling user. `kind = JOURNAL` requires `journalDate` (or defaults to now). | +| `update({ id, title?, body?, pinned? })` | Patch a note the caller owns. `title: null` clears the title. Empty body allowed (for journal entries that are still being filled in). | +| `archive({ id })` | Soft-archive. | +| `unarchive({ id })` | Reverse archive. | +| `delete({ id })` | Hard-delete. Prefer `archive` — this is the cleanup path. | +| `convertToIssue({ id, projectId? })` | Spawn an Issue with `title = note.title \|\| first line of body` and `description = body`. The note is left in place. Returns `{ issueId, issueKey, number }`. | +| `todayJournal()` | Get-or-create today's `JOURNAL` entry for the caller, anchored to UTC midnight on the user's wall-clock date (driven by `User.timezone`). Idempotent across calls in the same day. | +| `listJournal({ from?, to?, limit? = 30 })` | List recent journal entries for the caller, ordered by `journalDate desc`. | ### `issue.watch / unwatch / watchers / watching` Per-(issue, user OR agent) subscriptions. See [Watching](/guide/watching.html) for the full breakdown. -| Procedure | Summary | -|---|---| -| `watch({ issueId })` | Add caller as watcher. Idempotent. Identity inferred from API key (`linkedAgentId` → agent-watch, otherwise user-watch). | -| `unwatch({ issueId })` | Remove caller's watch. No-op if not watching. | -| `watchers({ issueId })` | List watchers with hydrated `user` / `agent` identity fields. | -| `watching({ limit? = 50 })` | Issues the caller currently watches, ordered by issue `updatedAt desc`. Powers the inbox **Watching** section. | +| Procedure | Summary | +| --------------------------- | ------------------------------------------------------------------------------------------------------------------------ | +| `watch({ issueId })` | Add caller as watcher. Idempotent. Identity inferred from API key (`linkedAgentId` → agent-watch, otherwise user-watch). | +| `unwatch({ issueId })` | Remove caller's watch. No-op if not watching. | +| `watchers({ issueId })` | List watchers with hydrated `user` / `agent` identity fields. | +| `watching({ limit? = 50 })` | Issues the caller currently watches, ordered by issue `updatedAt desc`. Powers the inbox **Watching** section. | ### `issue.applyCommands` @@ -388,7 +388,7 @@ issue.applyCommands.mutate({ { kind: "label", name: "deploy" }, { kind: "watch" }, ], -}) +}); // → { results: [{ kind, status: "applied" | "skipped", reason? }] } ``` @@ -407,13 +407,13 @@ The legacy issue-only procs (`list`, `set`, `toggle`) are preserved verbatim for backward compat with existing Hermes/agent runtimes. New consumers use the polymorphic surface: -| Procedure | Summary | -|---|---| -| `listAll({ workspaceId? })` | All pins for the caller, hydrated to small "card" objects. Dead targets are silently dropped. | -| `add({ targetType, targetId, workspaceId? })` | Add a pin. | -| `remove({ id })` | Remove by Pin id. | -| `toggleEntity({ targetType, targetId, workspaceId? })` | Convenience: add if absent, remove if present. | -| `reorder({ ids })` | Set `orderIndex` based on the array position. | +| Procedure | Summary | +| ------------------------------------------------------ | --------------------------------------------------------------------------------------------- | +| `listAll({ workspaceId? })` | All pins for the caller, hydrated to small "card" objects. Dead targets are silently dropped. | +| `add({ targetType, targetId, workspaceId? })` | Add a pin. | +| `remove({ id })` | Remove by Pin id. | +| `toggleEntity({ targetType, targetId, workspaceId? })` | Convenience: add if absent, remove if present. | +| `reorder({ ids })` | Set `orderIndex` based on the array position. | `targetType` is `PinTargetType` (`ISSUE` / `PROJECT` / `INITIATIVE` / `SAVED_VIEW` / `CYCLE` / `AGENT`). `workspaceId` may be null for @@ -421,10 +421,10 @@ cross-workspace pins (the legacy topbar-strip semantic). ### `recentItem.*` -| Procedure | Summary | -|---|---| -| `list({ limit? })` | Most-recently-visited entities for the caller in this workspace, hydrated. | -| `track({ targetType, targetId })` | Upsert a row. Server-side debounced 5s — fast nav doesn't spam writes. | +| Procedure | Summary | +| --------------------------------- | -------------------------------------------------------------------------- | +| `list({ limit? })` | Most-recently-visited entities for the caller in this workspace, hydrated. | +| `track({ targetType, targetId })` | Upsert a row. Server-side debounced 5s — fast nav doesn't spam writes. | ## Cross-references diff --git a/prisma/migrations/0097_agent_run_orchestration_context_snapshot/migration.sql b/prisma/migrations/0097_agent_run_orchestration_context_snapshot/migration.sql new file mode 100644 index 00000000..fb2a4c58 --- /dev/null +++ b/prisma/migrations/0097_agent_run_orchestration_context_snapshot/migration.sql @@ -0,0 +1,6 @@ +-- Preserve the bounded Goal / Plan / ExecutionStep context an agent received +-- at dispatch so historical runs remain truthful after orchestration edits. +-- Nullable keeps legacy and non-orchestrated runs compatible; application +-- readers fall back to live hydration only when no snapshot exists. +ALTER TABLE "AgentRun" + ADD COLUMN "orchestrationContextSnapshot" JSONB; diff --git a/prisma/migrations/0098_issue_archive_list_index/migration.sql b/prisma/migrations/0098_issue_archive_list_index/migration.sql new file mode 100644 index 00000000..69d10af7 --- /dev/null +++ b/prisma/migrations/0098_issue_archive_list_index/migration.sql @@ -0,0 +1,2 @@ +CREATE INDEX "Issue_workspaceId_deletedAt_updatedAt_idx" +ON "Issue"("workspaceId", "deletedAt", "updatedAt"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 457d36ed..f8b594a5 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -1181,6 +1181,7 @@ model Issue { @@index([workspaceId, cycleId]) @@index([workspaceId, assignedAgentId]) @@index([workspaceId, aiTriageStatus]) + @@index([workspaceId, deletedAt, updatedAt]) @@index([snoozedUntil]) } @@ -2443,96 +2444,101 @@ model DispatchRule { /// can exist over an issues lifetime if it is re-dispatched. The single /// 1:1 STATUS Comment for this run is referenced via Comment.runId. model AgentRun { - id String @id @default(cuid()) - workspaceId String - issueId String - agentId String - status AgentRunStatus @default(ACTIVE) - startedAt DateTime @default(now()) + id String @id @default(cuid()) + workspaceId String + issueId String + agentId String + status AgentRunStatus @default(ACTIVE) + startedAt DateTime @default(now()) /// Updated on every appended AgentRunEvent — what the watchdog reads. - lastEventAt DateTime @default(now()) - finishedAt DateTime? + lastEventAt DateTime @default(now()) + finishedAt DateTime? /// Compact label rendered by the live pulse strip ("running tests"). - currentStep String? + currentStep String? /// Optional final summary populated when the agent reports completion. - summary String? @db.Text + summary String? @db.Text /// Optional list of artifact ids the agent produced for this run. /// Populated by `runs.complete` so the UI can render evidence links /// alongside the summary. - producedArtifactIds String[] @default([]) + producedArtifactIds String[] @default([]) /// Optional structured verification result: snapshot of the issue's /// verificationChecklist at completion time with `done` flags set. - verificationResult Json? + verificationResult Json? /// Optional list of follow-up items the agent flagged for next steps. /// Shape: array of `{ title: string, body?: string, kind?: string }`. - followUps Json? + followUps Json? /// Mode-specific structured completion metadata. Examples: /// `{ confidence: "HIGH" }` for RESEARCH, `{ verdict: "APPROVE" }` /// for REVIEW, plus the Forge run contract version used at completion. - completionMeta Json? + completionMeta Json? /// Provider-side run id when this AgentRun is backed by a structured /// agent-run connector (e.g. Hermes `/v1/runs`). Lets Forge poll / /// approve / stop the live run and correlate streamed `/events`. /// Null for webhook/completions-driven runs. - externalRunId String? + externalRunId String? /// Effective runtime/tool policy snapshot captured at dispatch. Stored /// on the run so old cards remain truthful after Runtime.config changes. - runtimePolicy Json? + runtimePolicy Json? + /// Immutable, bounded Goal / Plan / ExecutionStep context captured when + /// this run is opened (or first dispatched for legacy rows). Run-scoped + /// consumers prefer this snapshot so later plan edits cannot rewrite the + /// instructions and dependency evidence that the agent actually received. + orchestrationContextSnapshot Json? /// Set when a connector-driven run is paused awaiting operator /// permission (e.g. Hermes flagged a dangerous command). Drives the /// Approve/Reject affordance in the Live tab; cleared when the run /// resumes (approved) or finishes (rejected/stopped). The stale /// watchdog skips runs with this set so a blocked run isn't /// misclassified as STALLED. - awaitingApprovalAt DateTime? + awaitingApprovalAt DateTime? /// Detail of the pending approval captured from the live `/events` /// stream (Hermes `approval.request`): `{ command, description, /// choices }`. Lets the Live-tab banner show *what* is being approved, /// not just that something is. Null when not blocked. - pendingApproval Json? + pendingApproval Json? /// Optional pointer at the AGENT_ASSIGNED ActivityEvent that triggered /// this run. Useful for ack-window reasoning later. - assignmentEventId String? + assignmentEventId String? /// ActivityEvent.id that *caused* the most recent wake. For /// AGENT_ASSIGNED runs this mirrors `assignmentEventId`; for runs /// kept warm by mentions / priority bumps / watcher fan-out it holds /// the latest trigger so the inbox can distinguish "Victor was just /// re-mentioned" from "Victor is the static assignee". - triggerEventId String? + triggerEventId String? /// String mirror of ActivityEvent.kind for the latest trigger. Not /// enum-typed on purpose — EventKind grows and we don't want to /// migrate AgentRun every time. Conventional values: AGENT_ASSIGNED, /// ISSUE_QUEUED, ISSUE_PRIORITY_CHANGED, COMMENT_CREATED, /// CHAT_MESSAGE_POSTED. - triggerKind String? + triggerKind String? /// Set when the assigned agent has acknowledged the dispatch and /// intends to act on it. Drives the UI transition from "wake sent" → /// "acknowledged" and silences the chat typing animation diagnostic. - acknowledgedAt DateTime? + acknowledgedAt DateTime? /// Set the first time the agent's reply / status / step output lands. /// Drives the UI transition from "acknowledged" → "running". - outputStartedAt DateTime? + outputStartedAt DateTime? /// Worker-side: latest wake attempt timestamp regardless of HTTP /// success. Diagnostic only — canonical work state lives elsewhere. - lastWakeAt DateTime? + lastWakeAt DateTime? /// Number of wake attempts the worker has made for this run. Distinct /// from WebhookDelivery.attempt (which counts retries on one row). - wakeAttempts Int @default(0) + wakeAttempts Int @default(0) /// Optional pointer at the most recent WebhookDelivery so the /// diagnostics panel can link straight to the (potentially failed) /// delivery row. - lastWakeDeliveryId String? + lastWakeDeliveryId String? /// Token-usage telemetry reported by the agent via `runs.recordUsage`. /// Cumulative as reported — the latest call replaces the prior values /// (idempotent), so agents should send running totals, not per-step /// deltas. Null = agent did not report. - tokensIn Int? - tokensOut Int? - tokensCached Int? + tokensIn Int? + tokensOut Int? + tokensCached Int? /// Cost the agent attributes to the run, in USD. Source-of-truth is /// the agent's own pricing knowledge for v1; a server-side rate table /// is a future cleanup. - costUsd Decimal? @db.Decimal(10, 4) + costUsd Decimal? @db.Decimal(10, 4) /// Operator-requested control signal. NONE is the steady state; /// PAUSE_REQUESTED / CANCEL_REQUESTED are pending instructions for /// the agent's runtime to honor on its next loop tick. The runtime @@ -2540,19 +2546,19 @@ model AgentRun { /// transitioning the run to an appropriate terminal state on /// CANCEL). Webhook fan-out via `recordChange` carries the same /// payload to the agent's `webhookUrl`. - controlState AgentRunControlState @default(NONE) + controlState AgentRunControlState @default(NONE) /// When the most recent control was requested. Null when /// `controlState = NONE` (no signal pending). - controlRequestedAt DateTime? + controlRequestedAt DateTime? /// User who requested the control. SetNull on user delete so the /// run row survives membership churn. - controlRequestedById String? + controlRequestedById String? /// Operator-cleared terminal failures stay in durable history but are /// hidden from Command Center / pipeline failure queues by default. - clearedAt DateTime? + clearedAt DateTime? /// User who cleared the terminal failure from operational surfaces. /// SetNull on user delete so the run row survives membership churn. - clearedById String? + clearedById String? /// Set when a newer run for the same (issue, agent) supersedes this /// one (added 2026-06-23). A re-dispatch / kick / grant that opens a /// fresh run links the prior STALLED/abandoned attempts here so @@ -2560,12 +2566,12 @@ model AgentRun { /// showing N repeated cards (the AXI-75 pathology). The run keeps its /// own terminal status for history; surfaces filter on /// `supersededByRunId IS NULL` to show only the head of each chain. - supersededByRunId String? + supersededByRunId String? /// Set only by clean completion via `runs.complete` (added /// 2026-06-23). Distinct from `finishedAt`, which is stamped for ANY /// terminal transition (including STALLED/ABANDONED). Rollups/surfaces /// that mean "succeeded" key off `completedAt`, not `finishedAt`. - completedAt DateTime? + completedAt DateTime? /// Optional ExecutionStep this run is executing (migration: AXI-57). Set /// when the Goal-orchestration loop opens a run for a step, so orchestrated diff --git a/src/app/(app)/w/[slug]/issues/[id]/page.tsx b/src/app/(app)/w/[slug]/issues/[id]/page.tsx index 0541d157..531810be 100644 --- a/src/app/(app)/w/[slug]/issues/[id]/page.tsx +++ b/src/app/(app)/w/[slug]/issues/[id]/page.tsx @@ -1,9 +1,9 @@ "use client"; import { use, useEffect, useRef, useState } from "react"; -import { useRouter } from "next/navigation"; +import { useRouter, useSearchParams } from "next/navigation"; import { toast } from "sonner"; import type { AgentStatus, WorkItemKind } from "@prisma/client"; -import { Bot, Paperclip, Plus, Timer, Trash2, Workflow } from "lucide-react"; +import { Archive, ArchiveRestore, Bot, Paperclip, Plus, Timer, Workflow } from "lucide-react"; import { Topbar } from "@/components/topbar"; import { Badge } from "@/components/ui/badge"; import { Avatar } from "@/components/ui/avatar"; @@ -79,6 +79,8 @@ const PRIORITIES = ["NONE", "LOW", "MEDIUM", "HIGH", "URGENT"] as const; export default function IssueDetailPage({ params }: { params: Promise<{ id: string }> }) { const { id } = use(params); const router = useRouter(); + const searchParams = useSearchParams(); + const includeArchived = searchParams?.get("archived") === "1"; const workspace = useWorkspace(); const slug = workspace.slug; const isAdmin = workspace.role === "OWNER" || workspace.role === "ADMIN"; @@ -93,7 +95,7 @@ export default function IssueDetailPage({ params }: { params: Promise<{ id: stri data: issue, isLoading, error, - } = trpc.issue.byId.useQuery({ id }, { refetchInterval: 15_000 }); + } = trpc.issue.byId.useQuery({ id, includeArchived }, { refetchInterval: 15_000 }); const { data: statuses } = trpc.status.list.useQuery(); const { data: members } = trpc.workspace.members.useQuery(); const { data: projects } = trpc.project.list.useQuery({ archived: false, limit: 100 }); @@ -134,19 +136,21 @@ export default function IssueDetailPage({ params }: { params: Promise<{ id: stri const update = trpc.issue.update.useMutation({ onMutate: async (input) => { - await utils.issue.byId.cancel({ id: input.id }); - const prev = utils.issue.byId.getData({ id: input.id }); - utils.issue.byId.setData({ id: input.id }, (old) => { + await utils.issue.byId.cancel({ id: input.id, includeArchived }); + const prev = utils.issue.byId.getData({ id: input.id, includeArchived }); + utils.issue.byId.setData({ id: input.id, includeArchived }, (old) => { if (!old) return old; return { ...old, ...input } as typeof old; }); return { prev }; }, onError: (err, input, ctx) => { - if (ctx?.prev) utils.issue.byId.setData({ id: input.id }, ctx.prev); + if (ctx?.prev) { + utils.issue.byId.setData({ id: input.id, includeArchived }, ctx.prev); + } toast.error(err.message); }, - onSettled: () => utils.issue.byId.invalidate({ id }), + onSettled: () => utils.issue.byId.invalidate({ id, includeArchived }), }); const assign = trpc.issue.assign.useMutation({ @@ -180,18 +184,30 @@ export default function IssueDetailPage({ params }: { params: Promise<{ id: stri onError: (e) => toast.error(e.message), }); - const softDelete = trpc.issue.softDelete.useMutation({ + const archiveIssue = trpc.issue.archive.useMutation({ + onSuccess: () => { + toast.success("Issue archived."); + utils.issue.list.invalidate(); + utils.issue.count.invalidate(); + router.push(`/w/${slug}/issues?archived=1`); + }, + onError: (e) => toast.error(e.message), + }); + + const restoreIssue = trpc.issue.restore.useMutation({ onSuccess: () => { - toast.success("Issue deleted."); + toast.success("Issue restored."); + utils.issue.byId.invalidate({ id, includeArchived: true }); utils.issue.list.invalidate(); - router.push(`/w/${slug}/issues`); + utils.issue.count.invalidate(); + router.replace(`/w/${slug}/issues/${id}`); }, onError: (e) => toast.error(e.message), }); const [titleDraft, setTitleDraft] = useState(""); const [editingTitle, setEditingTitle] = useState(false); - const [deleteOpen, setDeleteOpen] = useState(false); + const [archiveOpen, setArchiveOpen] = useState(false); const [releaseOpen, setReleaseOpen] = useState(false); const [agentPickerOpen, setAgentPickerOpen] = useState(false); @@ -306,6 +322,62 @@ export default function IssueDetailPage({ params }: { params: Promise<{ id: stri { label: issueKey, mono: true }, ]; + if (issue.deletedAt) { + return ( + <> + } + subtitle={archived} + actions={ + + } + /> +
+
+ } + title={`${issueKey} is archived`} + description={ + + {issue.title} left active views on {formatDate(issue.deletedAt, timePrefs)}. Its + history and relationships are preserved. + + } + action={ +
+ + +
+ } + /> +
+
+ + ); + } + return ( <> setDeleteOpen(true)} - title="Delete issue" - aria-label="Delete issue" + onClick={() => setArchiveOpen(true)} + title="Archive issue" + aria-label="Archive issue" > - - Delete + + Archive } @@ -689,15 +761,13 @@ export default function IssueDetailPage({ params }: { params: Promise<{ id: stri
softDelete.mutate({ id })} + open={archiveOpen} + onOpenChange={setArchiveOpen} + title={`Archive ${issueKey}?`} + description="Moves the issue out of active views and stops its active agent work. History and relationships stay preserved, and you can restore it later." + primaryLabel="Archive issue" + loading={archiveIssue.isPending} + onConfirm={() => archiveIssue.mutate({ id })} /> { const p = new URLSearchParams(); if (s.view) p.set("view", s.view); @@ -135,6 +137,7 @@ export default function IssuesPage() { if (s.sort !== "priority") p.set("sort", s.sort); if (s.group !== "status") p.set("group", s.group); if (s.dueOn) p.set("dueOn", s.dueOn); + if (s.archived) p.set("archived", "1"); return p.toString(); }, [], @@ -148,6 +151,7 @@ export default function IssuesPage() { group: IssueGroupBy; view: string | null; dueOn: string | null; + archived: boolean; }>, opts?: { push?: boolean }, ) => { @@ -158,6 +162,7 @@ export default function IssuesPage() { group: next.group ?? groupBy, view: next.view !== undefined ? next.view : (activeViewId ?? viewIdFromUrl), dueOn: next.dueOn !== undefined ? next.dueOn : dueOn, + archived: next.archived ?? showArchived, }); const url = qs ? `${pathname}?${qs}` : pathname; if (opts?.push) router.push(url); @@ -172,6 +177,7 @@ export default function IssuesPage() { activeViewId, viewIdFromUrl, dueOn, + showArchived, pathname, router, ], @@ -219,7 +225,9 @@ export default function IssuesPage() { }, [fParam, viewIdFromUrl, views?.length]); // Project / Cycle / initiative chip state derives from `filters`. - const projectId: ProjectFilter = filters.withoutProject ? null : (filters.projectIds?.[0] ?? undefined); + const projectId: ProjectFilter = filters.withoutProject + ? null + : (filters.projectIds?.[0] ?? undefined); const cycleId: CycleFilter = filters.withoutCycle ? null : (filters.cycleIds?.[0] ?? undefined); const initiativeId: InitiativeFilter = filters.withoutInitiative ? null @@ -288,8 +296,12 @@ export default function IssuesPage() { commit({ dueOn: null }); } + function setShowArchived(archived: boolean) { + commit({ archived }, { push: true }); + } + const hasFilters = !isEmptyFilters(filters) || !!query || !!dueOn; - const isWorkspaceEmpty = !!wsCount && wsCount._count.issues === 0 && !hasFilters; + const isWorkspaceEmpty = !showArchived && !!wsCount && wsCount._count.issues === 0 && !hasFilters; // The query object we pass down. `query` is kept separate from the // saved-view filter blob (it's not persisted with views by default to @@ -307,11 +319,12 @@ export default function IssuesPage() { const { data: statuses } = trpc.status.list.useQuery(); const doneIds = useMemo(() => doneStatusIds(statuses ?? []), [statuses]); const countIncludeDone = - view === "board" ? true : resolveIncludeDone(issueQueryFilters, false, doneIds); + showArchived || view === "board" ? true : resolveIncludeDone(issueQueryFilters, false, doneIds); const { data: countData } = trpc.issue.count.useQuery({ ...issueQueryFilters, includeDone: countIncludeDone, dueOn: dueOn ?? undefined, + archived: showArchived, }); const activeView = activeViewId && views ? views.find((v) => v.id === activeViewId) : null; @@ -319,11 +332,11 @@ export default function IssuesPage() { return ( )} - {view === "list" && !isWorkspaceEmpty && } - {!isWorkspaceEmpty && } + {(showArchived || view === "list") && !isWorkspaceEmpty && } + {!showArchived && !isWorkspaceEmpty && } +
} /> @@ -420,7 +446,7 @@ export default function IssuesPage() { } />
- ) : view === "list" ? ( + ) : showArchived || view === "list" ? (
setShowArchived(false)} /> + ) : hasFilters ? ( void }) { + return ( +
+ } + title="Archive is empty" + description="Archived issues stay here until you restore them." + action={ + + } + /> +
+ ); +} + /** * Inline empty-state shown inside the list when the user's filters * have narrowed to zero matches. Distinct from the workspace-empty diff --git a/src/components/command-palette.tsx b/src/components/command-palette.tsx index 5a40e3de..50ae5e33 100644 --- a/src/components/command-palette.tsx +++ b/src/components/command-palette.tsx @@ -60,7 +60,13 @@ type StaticAction = { }; type FlatRow = - | { kind: "entity"; key: string; data: EntitySearchResultData; run: () => void; workspaceLabel?: string } + | { + kind: "entity"; + key: string; + data: EntitySearchResultData; + run: () => void; + workspaceLabel?: string; + } | { kind: "action"; key: string; action: StaticAction } | { kind: "create"; key: string; run: () => void; query: string }; @@ -148,14 +154,13 @@ export function CommandPalette() { Icon: Plus, run: () => { setOpen(false); - // Trigger the existing QuickCreate via its data-attribute hook. - // Synthesizes a click on a hidden button so the overlay opens. - const synthetic = document.createElement("button"); - synthetic.setAttribute("data-quick-create", ""); - synthetic.style.display = "none"; - document.body.appendChild(synthetic); - synthetic.click(); - synthetic.remove(); + // This action explicitly promises an issue, so bypass the + // pathname-aware default (issue detail normally opens Comment). + window.dispatchEvent( + new CustomEvent("forge:quick-create", { + detail: { mode: "issue" }, + }), + ); }, }, { @@ -299,7 +304,15 @@ export function CommandPalette() { // `target.targetType` and use the target's own workspace slug when // present (so cross-workspace pins still navigate to the right tenant). const dispatchPin = ( - p: { target: { targetType: string; id: string; slug?: string; profileKey?: string; workspaceSlug?: string } | null }, + p: { + target: { + targetType: string; + id: string; + slug?: string; + profileKey?: string; + workspaceSlug?: string; + } | null; + }, fallbackSlug: string, ) => { setOpen(false); @@ -533,15 +546,11 @@ export function CommandPalette() { // Filtered actions matching the query. const q = debouncedQuery.toLowerCase(); - const matchingActions = staticActions.filter((a) => - a.label.toLowerCase().includes(q), - ); + const matchingActions = staticActions.filter((a) => a.label.toLowerCase().includes(q)); if (matchingActions.length > 0) { out.push({ title: "Actions", - rows: matchingActions.map( - (a): FlatRow => ({ kind: "action", key: a.id, action: a }), - ), + rows: matchingActions.map((a): FlatRow => ({ kind: "action", key: a.id, action: a })), }); } @@ -598,9 +607,7 @@ export function CommandPalette() { if (staticActions.length > 0) { out.push({ title: "Actions", - rows: staticActions.map( - (a): FlatRow => ({ kind: "action", key: a.id, action: a }), - ), + rows: staticActions.map((a): FlatRow => ({ kind: "action", key: a.id, action: a })), }); } @@ -714,7 +721,7 @@ export function CommandPalette() { kbdHint={selected ? "↵" : undefined} /> {row.workspaceLabel && ( - + {row.workspaceLabel} )} @@ -791,7 +798,7 @@ export function CommandPalette() { ))} {emptySections.length === 0 && ( -
+
Start typing to search.
)} @@ -875,4 +882,3 @@ function ActionRow({
); } - diff --git a/src/components/issue-board.tsx b/src/components/issue-board.tsx index c2c5436c..3b4f5c08 100644 --- a/src/components/issue-board.tsx +++ b/src/components/issue-board.tsx @@ -134,10 +134,12 @@ function BoardColumn({ projectId?: string; columnFilter: Parameters[0]; }) { - const { data, hasNextPage, isFetchingNextPage, fetchNextPage } = - trpc.issue.list.useInfiniteQuery(columnFilter, { + const { data, hasNextPage, isFetchingNextPage, fetchNextPage } = trpc.issue.list.useInfiniteQuery( + columnFilter, + { getNextPageParam: (last) => last.nextCursor, - }); + }, + ); const items = data?.pages.flatMap((p) => p.items) ?? []; return ( @@ -149,10 +151,8 @@ function BoardColumn({ {items.length} {hasNextPage ? "+" : ""}
- {/* Quick-add into this column. Opens the shared new-issue flow - (prefilled to the project when the board is project-scoped). - Status prefill isn't supported by quick-create yet, so the new - issue lands in the default status. */} + {/* Quick-add into this column. The shared capture flow preserves + both the project scope and this column's status. */} @@ -94,10 +94,7 @@ export function SubIssuesPanel({ {total > 0 && ( - + {done}/{total} @@ -132,7 +129,12 @@ export function SubIssuesPanel({ placeholder={`New ${childNoun} title…`} className="h-7 text-xs" /> - @@ -140,9 +142,7 @@ export function SubIssuesPanel({ )}
    - {isLoading && ( -
  • Loading…
  • - )} + {isLoading &&
  • Loading…
  • } {data?.items.map((c) => (
  • @@ -166,9 +166,7 @@ export function SubIssuesPanel({ {c.title} - - {c.status.name} - + {c.status.name}
  • ))}
@@ -184,20 +182,30 @@ export function SubIssuesPanel({ export function ParentIssueBacklink({ parent, }: { - parent: { id: string; number: number; title: string } | null; + parent: { + id: string; + number: number; + title: string; + deletedAt: Date | string | null; + } | null; }) { const ws = useWorkspace(); if (!parent) return null; return ( ↑ part of {formatIssueId(ws.key, parent.number)} {parent.title} + {parent.deletedAt ? ( + + archived + + ) : null} ); } diff --git a/src/components/issue-list.tsx b/src/components/issue-list.tsx index 2460930e..457315d1 100644 --- a/src/components/issue-list.tsx +++ b/src/components/issue-list.tsx @@ -6,6 +6,7 @@ import { toast } from "sonner"; import { ArrowRightLeft, Archive, + ArchiveRestore, CalendarClock, CalendarRange, FolderInput, @@ -53,6 +54,7 @@ export function IssueList({ emptyHint, emptyOverride, enableBulk = true, + archived = false, }: { workspaceKey: string; projectId?: string; @@ -89,6 +91,8 @@ export function IssueList({ */ emptyOverride?: React.ReactNode; enableBulk?: boolean; + /** Show only soft-archived issues and replace mutation actions with restore. */ + archived?: boolean; }) { const { data: statuses } = trpc.status.list.useQuery(); // The global /issues list hides DONE/CANCELED rows by default @@ -101,7 +105,9 @@ export function IssueList({ // saved view that pins `includeDone` stays authoritative unless the // active filter explicitly asks for a completed status. const doneIds = useMemo(() => doneStatusIds(statuses ?? []), [statuses]); - const effectiveIncludeDone = resolveIncludeDone(extraFilters, includeDone, doneIds); + const effectiveIncludeDone = archived + ? true + : resolveIncludeDone(extraFilters, includeDone, doneIds); const { data, isLoading, hasNextPage, isFetchingNextPage, fetchNextPage } = trpc.issue.list.useInfiniteQuery( @@ -115,6 +121,7 @@ export function IssueList({ dueOn, ...(extraFilters ?? {}), includeDone: effectiveIncludeDone, + archived, sort, }, { getNextPageParam: (last) => last.nextCursor }, @@ -376,9 +383,7 @@ export function IssueList({ if (activeIndex < 0) return; const id = filtered[activeIndex]?.id; if (!id) return; - document - .querySelector(`[data-row-id="${id}"]`) - ?.scrollIntoView({ block: "nearest" }); + document.querySelector(`[data-row-id="${id}"]`)?.scrollIntoView({ block: "nearest" }); }, [activeIndex, filtered]); useHotkey("j", () => moveActive(1), [moveActive]); @@ -390,9 +395,9 @@ export function IssueList({ () => { if (activeIndex < 0) return; const issue = filtered[activeIndex]; - if (issue) router.push(`${base}/issues/${issue.id}`); + if (issue) router.push(`${base}/issues/${issue.id}${archived ? "?archived=1" : ""}`); }, - [activeIndex, filtered, router, base], + [activeIndex, filtered, router, base, archived], ); // `x` toggles the active row, falling back to the mouse-hovered row. @@ -412,9 +417,7 @@ export function IssueList({ if (e.key !== "Escape") return; const target = e.target as HTMLElement | null; const isEditable = - target?.tagName === "INPUT" || - target?.tagName === "TEXTAREA" || - target?.isContentEditable; + target?.tagName === "INPUT" || target?.tagName === "TEXTAREA" || target?.isContentEditable; if (isEditable) return; if (enableBulk && selected.size > 0) { e.preventDefault(); @@ -443,12 +446,19 @@ export function IssueList({ ); const { data: cyclesData } = trpc.cycle.list.useQuery({}, { enabled: cyclePickerOpen }); + // Bulk edits can move rows into or out of the active filtered view. Keep + // the separate header count query in lockstep with the paginated rows. + const invalidateVisibleIssues = () => { + void utils.issue.list.invalidate(); + void utils.issue.count.invalidate(); + }; + // ---- Bulk mutations (Phase 0 procs) ---------------------------------- const bulkTransition = trpc.issue.bulkTransition.useMutation({ onSuccess: (res) => { toast.success(`Status changed on ${res.updated} issue(s).`); clearSelection(); - utils.issue.list.invalidate(); + invalidateVisibleIssues(); }, onError: (e) => toast.error(e.message), }); @@ -456,7 +466,7 @@ export function IssueList({ const bulkAddLabel = trpc.issue.bulkAddLabel.useMutation({ onSuccess: (res) => { toast.success(`Added label to ${res.updated} issue(s).`); - utils.issue.list.invalidate(); + invalidateVisibleIssues(); }, onError: (e) => toast.error(e.message), }); @@ -464,7 +474,7 @@ export function IssueList({ const bulkRemoveLabel = trpc.issue.bulkRemoveLabel.useMutation({ onSuccess: (res) => { toast.success(`Removed label from ${res.updated} issue(s).`); - utils.issue.list.invalidate(); + invalidateVisibleIssues(); }, onError: (e) => toast.error(e.message), }); @@ -473,7 +483,7 @@ export function IssueList({ onSuccess: (res) => { toast.success(`Assigned ${res.updated} issue(s).`); clearSelection(); - utils.issue.list.invalidate(); + invalidateVisibleIssues(); }, onError: (e) => toast.error(e.message), }); @@ -482,7 +492,7 @@ export function IssueList({ onSuccess: (res) => { toast.success(`Agent updated on ${res.updated} issue(s).`); clearSelection(); - utils.issue.list.invalidate(); + invalidateVisibleIssues(); }, onError: (e) => toast.error(e.message), }); @@ -491,7 +501,7 @@ export function IssueList({ onSuccess: ({ updated }) => { toast.success(`Snoozed ${updated} issue${updated === 1 ? "" : "s"}.`); clearSelection(); - utils.issue.list.invalidate(); + invalidateVisibleIssues(); }, onError: (e) => toast.error(e.message), }); @@ -502,6 +512,17 @@ export function IssueList({ clearSelection(); setBulkArchiveOpen(false); utils.issue.list.invalidate(); + utils.issue.count.invalidate(); + }, + onError: (e) => toast.error(e.message), + }); + + const bulkRestoreM = trpc.issue.bulkRestore.useMutation({ + onSuccess: ({ updated }) => { + toast.success(`Restored ${updated} issue${updated === 1 ? "" : "s"}.`); + clearSelection(); + utils.issue.list.invalidate(); + utils.issue.count.invalidate(); }, onError: (e) => toast.error(e.message), }); @@ -511,7 +532,7 @@ export function IssueList({ toast.success(`Moved ${updated} issue${updated === 1 ? "" : "s"}.`); clearSelection(); setProjectPickerOpen(false); - utils.issue.list.invalidate(); + invalidateVisibleIssues(); }, onError: (e) => toast.error(e.message), }); @@ -521,7 +542,7 @@ export function IssueList({ toast.success(`Moved ${updated} issue${updated === 1 ? "" : "s"}.`); clearSelection(); setCyclePickerOpen(false); - utils.issue.list.invalidate(); + invalidateVisibleIssues(); }, onError: (e) => toast.error(e.message), }); @@ -534,6 +555,7 @@ export function IssueList({ bulkAssignAgent.isPending || snoozeManyM.isPending || bulkArchiveM.isPending || + bulkRestoreM.isPending || bulkSetProjectM.isPending || bulkSetCycleM.isPending; @@ -551,71 +573,87 @@ export function IssueList({ return counts; }, [filtered, selected]); - const bulkActions: BulkBarAction[] = [ - { - id: "status", - label: "Status…", - icon: , - title: "Move to status", - disabled: bulkPending, - onClick: () => setStatusPickerOpen(true), - }, - { - id: "assign", - label: "Assign…", - icon: , - title: "Assign selected", - disabled: bulkPending, - onClick: () => setAssigneePickerOpen(true), - }, - { - id: "project", - label: "Project…", - icon: , - title: "Move to project", - disabled: bulkPending, - onClick: () => setProjectPickerOpen(true), - }, - { - id: "sprint", - label: "Sprint…", - icon: , - title: "Move to sprint", - disabled: bulkPending, - onClick: () => setCyclePickerOpen(true), - }, - { - id: "snooze", - label: null, - render: (cls) => ( - snoozeManyM.mutate({ ids: selectedArray, until })} - trigger={ - - } - /> - ), - }, - { - id: "label", - label: "Label…", - icon: , - title: "Add or remove labels", - disabled: bulkPending, - onClick: () => setLabelPickerOpen(true), - }, - { - id: "archive", - label: "Archive", - icon: , - title: "Archive (soft delete) selected", - disabled: bulkPending, - onClick: () => setBulkArchiveOpen(true), - }, - ]; + const bulkActions: BulkBarAction[] = archived + ? [ + { + id: "restore", + label: "Restore", + icon: , + title: "Restore selected issues", + disabled: bulkPending, + onClick: () => bulkRestoreM.mutate({ ids: selectedArray }), + }, + ] + : [ + { + id: "status", + label: "Status…", + icon: , + title: "Move to status", + disabled: bulkPending, + onClick: () => setStatusPickerOpen(true), + }, + { + id: "assign", + label: "Assign…", + icon: , + title: "Assign selected", + disabled: bulkPending, + onClick: () => setAssigneePickerOpen(true), + }, + { + id: "project", + label: "Project…", + icon: , + title: "Move to project", + disabled: bulkPending, + onClick: () => setProjectPickerOpen(true), + }, + { + id: "sprint", + label: "Sprint…", + icon: , + title: "Move to sprint", + disabled: bulkPending, + onClick: () => setCyclePickerOpen(true), + }, + { + id: "snooze", + label: null, + render: (cls) => ( + snoozeManyM.mutate({ ids: selectedArray, until })} + trigger={ + + } + /> + ), + }, + { + id: "label", + label: "Label…", + icon: , + title: "Add or remove labels", + disabled: bulkPending, + onClick: () => setLabelPickerOpen(true), + }, + { + id: "archive", + label: "Archive", + icon: , + title: "Archive selected issues", + disabled: bulkPending, + onClick: () => setBulkArchiveOpen(true), + }, + ]; if (isLoading) { return ( @@ -634,13 +672,15 @@ export function IssueList({ } - title="No active issues." + title={archived ? "No archived issues." : "No active issues."} description={ - emptyHint ?? ( - - Press ⇧C to create one. - - ) + archived + ? "Archived issues remain recoverable here." + : (emptyHint ?? ( + + Press ⇧C to create one. + + )) } />
@@ -664,11 +704,11 @@ export function IssueList({ onChange={toggleAll} className="h-3.5 w-3.5 rounded border-border" /> - Select all + Select loaded - j/k move · ↵ open · x select · - Shift+Click for range + j/k move · ↵ open · x select · Shift+Click + for range
)} @@ -686,11 +726,8 @@ export function IssueList({ {group.issues.length} {/* Per-group quick-add (status + project groupings only). - Fires the shared new-issue flow; the `forge:quick-create` - event detail only supports a `projectId` prefill (the - group's project when grouping by project, else the list's - own scope), NOT a `statusId`, so the new issue lands in the - workspace default status. */} + The shared capture flow preserves the originating project + and status when either grouping supplies one. */} {(groupBy === "status" || groupBy === "project") && (