diff --git a/.changeset/plan-agents-label.md b/.changeset/plan-agents-label.md deleted file mode 100644 index c5b8be1e7..000000000 --- a/.changeset/plan-agents-label.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -"@sapiom/harness": patch -"@sapiom/harness-desktop": patch ---- - -Rename the pinned Agent Map tab in the Studio project sidebar to Plan Agents so its purpose is clear. The underlying Agent Map view and behavior are unchanged. diff --git a/.changeset/planner-owned-agent-creation.md b/.changeset/planner-owned-agent-creation.md deleted file mode 100644 index 75c311e8e..000000000 --- a/.changeset/planner-owned-agent-creation.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -"@sapiom/harness": minor -"@sapiom/harness-desktop": patch ---- - -Make Agent Map planning the only agent-creation route in Studio projects. Empty sidebar rows no longer offer to create the first agent directly, and project menus no longer offer direct create or in-session scaffold actions. diff --git a/.changeset/unified-project-agents.md b/.changeset/unified-project-agents.md index 9b892e914..e233b2ea0 100644 --- a/.changeset/unified-project-agents.md +++ b/.changeset/unified-project-agents.md @@ -8,19 +8,20 @@ Unify Agent Studio project sessions around one ordinary coding-agent identity, m **Breaking for embedders** (minor while `@sapiom/harness` is pre-1.0): `HarnessSession.agentMapIdentity` is now the role-neutral `ProjectAgentSession { projectId, userId, sessionId }`; `role` and `assignment` -are no longer present. Valid persisted `planning` metadata is migrated into the -optional `projectBootstrap` lifecycle field and then removed. The deprecated -planner-message alias now returns `ProjectBootstrapMetadata | null`, with -`projectId`, `userId`, `targetSessionId`, and `bootstrap` replacing the former -nested `identity` and `greeting` fields. +are no longer present. Valid persisted pre-upgrade session metadata is migrated +into the optional `projectBootstrap` lifecycle field and then removed. Retired +project-session HTTP aliases and public API names are removed; live clients use +the generic session routes. **Migration:** stop branching on `agentMapIdentity.role` or `.assignment`, read -optional `projectBootstrap` only for bootstrap status, and handle `metadata: -null` from the compatibility alias—or move to the generic session routes. An +optional `projectBootstrap` only for bootstrap status, and use the generic +session routes. An embedder that already owns a new session's first prompt should send `initialUserInputPending: true` in the same `CreateSessionRequest`, so automatic bootstrap yields before launch. New telemetry consumers should recognize the neutral `project_agent.*` and `project_bootstrap.*` events. Valid legacy state keeps its session/provider IDs, cwd, title, transcript, and Canvas; malformed or -conflicting authority is retained and fails closed. Downgrading does not restore -the former planner coordinator semantics. +conflicting authority is retained and fails closed. Released infrastructure +bootstrap event markers remain read-compatible so their private control prompt +never becomes a human transcript turn after upgrade. Downgrading does not +restore the superseded session authority model. diff --git a/.github/workflows/claude-code-review.yml b/.github/workflows/claude-code-review.yml index c245195cb..2bf016cb8 100644 --- a/.github/workflows/claude-code-review.yml +++ b/.github/workflows/claude-code-review.yml @@ -11,6 +11,12 @@ name: Claude Code Review # tarballs, so customer names and internal business context must be caught # at review time — a static blocklist committed here would itself publish # the names it protects. +# +# The prompt also carries a frontend component-hygiene section, which is +# ADVISORY by design. We do not gate a build on design parity, so those +# findings inform and never block. It exists because `lint` in the harness +# package is `eslint src`, which does not cover `web/src`, where nearly all +# Studio UI lives: this review is the only automated read of that code. on: pull_request: @@ -258,6 +264,60 @@ jobs: a released version — beyond what fixing it inherently reveals — flag that the details belong in private reporting per SECURITY.md, not in a public PR. + ## Frontend component hygiene + + **Applies only when the diff touches frontend component code**: `.tsx`/`.css` under + `packages/harness/web/src`, `packages/harness-desktop/src/renderer`, or any other `web/` or + `renderer/` tree. If the diff has none, skip this section and say nothing about it. + + **You are the only automated eye on this code.** The harness `lint` script is + `eslint src --ext .ts`, which does not cover `web/src`, where nearly all Studio UI lives. Do + not assume a linter already caught duplication, dead state, or a hand-written token value. + + **This is advisory, not a gate.** We do not fail a build because the UI differs from a + design, and nothing in this section is grounds for blocking. Label these findings + `HYGIENE (advisory)` and word each one as the concrete next step the author can take: name + the existing component, hook, token, or class and give its path. If you cannot point at + something that already exists in the repo, it is an opinion, so drop it. + + Grep before claiming something is new: `packages/harness/web/src/components` holds 70+ + components and the sibling you want usually exists. Report **at most 3**, highest signal + first, from: + + 1. **A re-implemented shared primitive.** A hand-rolled popover, menu, empty state, or icon + when the repo already ships one (`AnchoredPopover`, `MenuChoice`, `EmptyState`, `Icon`, + and neighbours). Name the existing one and its path. **Dialogs are the exception and you + must know it before flagging one:** the harness has no generic modal component and no + Radix. The shared thing is the idiom, a `modal-backdrop` > `modal` wrapper with + `useDismissable`, used across roughly a dozen files. `SecretDialogShell` is the secrets + dialogs' own shell, not a general one. So the finding on a new dialog is "it dismisses + differently from its siblings", never "import the shared Dialog", which does not exist. + 2. **A second recipe for a concept that already has one.** Two ways to render the same + status, two ways to load the same resource, a new hook beside an existing hook doing that + job. The cost is that every later change has to be made in both places. + 3. **Presentation and logic tangled in one component.** Fetching, polling, or business rules + inside a component that also owns layout, where a sibling in the same directory keeps them + apart. Say which half should move and where it goes. + 4. **State that should be derived.** A `useState` paired with a `useEffect` that only + recomputes a value from props or other state: it can go out of sync, so compute it during + render instead. + 5. **An effect doing an event handler's job.** Work belonging in the click, submit, or change + handler placed in a `useEffect` keyed on the value it just wrote. Those also fire on mount + and on unrelated re-renders. + 6. **A redefined token or a one-off style.** A literal colour, radius, font size, or spacing + where a `var(--...)` token exists, or a new class where an existing one fits. + `packages/harness/CLAUDE.md` rule 6 is the standard: read tokens with `var()`, never + snapshot their values, because a local copy drifts the moment the design system moves. + + **Do not flag**: naming, file placement, prop ordering, unmeasured memoisation, "consider + extracting" on a component nobody has had to change twice, a missing test for a purely + visual change, or any divergence from a design mock. Do not ask a PR to extract a shared + primitive that does not exist yet: introducing one is a design decision with its own + ticket, and demanding it in review is how an unrelated change gets blocked on somebody + else's refactor. Hygiene findings never outrank the + confidentiality and published-package findings above; a section that floods the PR with + nitpicks gets the whole review ignored, which is worse than not writing it. + ## General review Code quality, potential bugs, security concerns, test coverage. Use the repository's @@ -280,7 +340,10 @@ jobs: Length budget: 6,000 characters for a first review, 2,000 for a follow-up — a budget to come in under, not a target. Do not add the `claude-review-sha` marker yourself. - claude_args: '--allowed-tools "Write,Bash(gh issue view:*),Bash(gh search:*),Bash(gh issue list:*),Bash(gh pr diff:*),Bash(gh pr view:*),Bash(gh pr list:*)"' + # Read/Glob/Grep are read-only on the PR checkout. The component-hygiene + # section requires naming the existing component or token a change + # bypasses, which is a claim about the repo, not about the diff. + claude_args: '--allowed-tools "Write,Read,Glob,Grep,Bash(gh issue view:*),Bash(gh search:*),Bash(gh issue list:*),Bash(gh pr diff:*),Bash(gh pr view:*),Bash(gh pr list:*)"' - name: Post review comment if: steps.pr.outputs.should_run == 'true' diff --git a/docs/plans/agent-studio-plan-first-agent-map/design.md b/docs/plans/agent-studio-plan-first-agent-map/design.md new file mode 100644 index 000000000..f6f003dd1 --- /dev/null +++ b/docs/plans/agent-studio-plan-first-agent-map/design.md @@ -0,0 +1,72 @@ +# Agent Studio unified Agent Map + +Status: implemented by SAP-3148 through SAP-3151; SAP-3152 verifies and +reconciles the cutover. + +## Product authority + +Every Studio project session is one ordinary writable project agent. Its +trusted principal is `{ projectId, userId, sessionId }`, derived by the server. +Assignment, map-node, and focused-brief references are context, never +authorization. Every project session receives the same project-agent prompt, +Agent Map tools, build-plan tools, coding surface, and delegation tool subject +to normal project isolation and capability lifecycle. + +The project owns one durable Agent Map and one current project build plan. +Sessions read and update that shared state through validated tools. A clear +implementation request proceeds directly. Agents update the map or plan only +when work changes architectural boundaries, ownership, contracts, resources, +connectors, artifacts, sequencing, or cross-agent flow. Internal code choices +remain local. + +## Navigation + +The project name selects the production Agent Map. That selection is a +deterministic read of durable state and never creates, resumes, focuses, or +prompts a session. Every session tab selects exactly one ordinary conversation +and its Canvas/Steps surface. A new project starts with one ordinary session +initially named `Plan Agents`; the name and first position grant no special +authority. + +## Bootstrap and continuous maintenance + +Project creation durably schedules one evidence-first bootstrap turn for the +first ordinary session when the map is meaningfully empty. Attempts, readiness, +preemption, retry, restart recovery, and delivery correlation are durable and +idempotent. User input remains usable and wins races without being discarded. +Opening the map does not trigger model work. After bootstrap, the common prompt +makes map maintenance a responsibility of every session. + +## Versions, briefs, and delegation + +Map, plan, and brief content use canonical digests and project-bound immutable +version references. Accepted changes append a version and atomically advance a +current pointer. Concurrent writes use exact expected versions; stale overlap +conflicts require reread/rebase. Restoration appends a new record carrying +`restoredFromVersionId`; history is never rewritten or rewound. + +Focused briefs are deterministic, bounded, exact-source context overlays. They +focus a mission, scope, dependencies, contracts, deliverables, constraints, and +acceptance evidence without changing prompt or tools. Sessions without briefs +retain full capabilities and global context. + +Any project agent may delegate writable work. The coordinator uses stable +project/parent/key bindings, durable spawn claims, exact session reuse, +readiness-gated kickoff delivery, acknowledgement, bounded retention, nested +delegation, and explicit stale-context recovery. Cleanup owns only sessions it +created; unrelated manual sessions are never adopted or mutated. + +## Evidence boundary + +Source and runtime evidence may verify or challenge project intent but never +silently becomes intent. The per-agent execution graph remains the authority for +internal steps and ordinary tool calls. The project map stays at architectural +altitude. + +## Security and observability + +Trusted scope never comes from model arguments. Capabilities are private, +project/session-scoped, rotated on resume, revoked on exit, and rejected across +projects. Telemetry records bounded lifecycle outcomes and identifiers only; it +must not contain prompts, plan prose, source text, paths, credentials, connector +payloads, or raw provider errors. diff --git a/docs/plans/agent-studio-plan-first-agent-map/journey-roadmap.json b/docs/plans/agent-studio-plan-first-agent-map/journey-roadmap.json new file mode 100644 index 000000000..d58a45fb4 --- /dev/null +++ b/docs/plans/agent-studio-plan-first-agent-map/journey-roadmap.json @@ -0,0 +1,19 @@ +{ + "schemaVersion": 1, + "authority": "SAP-3147", + "checkpoint": "SAP-3152", + "currentStack": [ + { "issue": "SAP-3148", "pr": 804, "head": "5d00c55925d1907670bed6e885384b25eb775a73", "outcome": "unified identity, bootstrap, and navigation" }, + { "issue": "SAP-3149", "pr": 806, "head": "90eb569eb6b90b917c49128b6a23c7215a0843d6", "outcome": "neutral immutable versions and universal plan authoring" }, + { "issue": "SAP-3150", "pr": 807, "head": "acb2dbae6a3e533e01a065c43fa4109bdd82ca14", "outcome": "role-neutral focused briefs" }, + { "issue": "SAP-3151", "pr": 808, "head": "ca747232e0b775b5be5c69c5427c778b3774dd4f", "outcome": "writable idempotent nested delegation", "review": "user-directed exception recorded in cutover ledger" }, + { "issue": "SAP-3152", "pr": null, "head": null, "outcome": "verification, documentation, and reconciliation" } + ], + "dependencyOrder": ["SAP-3148", "SAP-3149", "SAP-3150", "SAP-3151", "SAP-3152"], + "historicalFoundations": ["E0", "E1", "E2"], + "laterWork": [ + { "epic": "E6", "directive": "consume shared neutral map, plan, brief, and session context" }, + { "epic": "E7", "directive": "reconcile evidence without silently changing intent" }, + { "epic": "E8", "directive": "adopt existing projects and remove obsolete authority without changing the per-agent graph" } + ] +} diff --git a/docs/plans/agent-studio-plan-first-agent-map/modules/journey/interfaces.md b/docs/plans/agent-studio-plan-first-agent-map/modules/journey/interfaces.md new file mode 100644 index 000000000..518670a09 --- /dev/null +++ b/docs/plans/agent-studio-plan-first-agent-map/modules/journey/interfaces.md @@ -0,0 +1,60 @@ +# Unified Agent Map journey interfaces + +## Shared identity + +```ts +type ProjectAgentSession = Readonly<{ + projectId: StudioProjectId; + userId: UserId; + sessionId: SessionId; +}>; +``` + +The server derives this identity for creation, resume, bootstrap, manual +sessions, and delegated sessions. Focus references are carried separately. + +## Navigation seam + +- Project-name selection renders `AgentMapPane` and does not change the active + session ID. +- A session-tab selection activates that exact session and renders the ordinary + conversation plus Canvas/Steps. +- The tab key is the durable session ID; there is one visible tab per live ID. + +## Map and plan seam + +- `GraphContentDigest` identifies canonical semantic graph content. +- `AgentMapVersionRef` binds `projectId`, `versionId`, and `contentDigest`. +- `ProjectAgentActorRef` records trusted user/session attribution. +- `ProjectBuildPlanVersion` is immutable and exact-map-bound. +- `AgentBriefVersion` is immutable and exact map/plan-bound. +- Current reads and historical exact-version reads are distinct operations. +- Apply/rebase/restore append before atomically advancing a pointer. + +All sessions discover `agent_map_read`, `agent_map_validate`, +`agent_map_propose`, `build_plan_read`, `build_plan_validate`, +`build_plan_apply`, `build_plan_rebase`, `build_plan_brief_refresh`, and +`project_subsession_delegate`. + +## Focused-context seam + +A focused projection is allowlisted, deterministic, source-verified, and size +bounded. Authored prose is delimited as untrusted data. The projection excludes +secrets, raw evidence, local paths, connector values, unrelated history, and +arbitrary instructions. It supplements the common project-agent prompt. + +## Delegation seam + +Delegation authority comes from the caller's private project capability. Inputs +contain a stable request key, stable delegation key, assignment, and optional +exact focused-context reference; they contain no trusted project/user/session +selector. Durable claims fence creation, spawning, kickoff, acknowledgement, +release, and restart recovery. Nested delegation uses the same interface and +capabilities. Manual sessions remain outside coordinator ownership. + +## Future journey contracts + +Later shared-context, reconciliation, and existing-project adoption work must +consume these neutral identity, version, brief, and session contracts. Evidence +remains diagnostic; no later issue may add an approval or mode boundary before +ordinary coding or delegation. diff --git a/docs/plans/agent-studio-plan-first-agent-map/rollout-rollback.md b/docs/plans/agent-studio-plan-first-agent-map/rollout-rollback.md new file mode 100644 index 000000000..fb1642657 --- /dev/null +++ b/docs/plans/agent-studio-plan-first-agent-map/rollout-rollback.md @@ -0,0 +1,39 @@ +# Unified Agent Map rollout and recovery + +## Release gate + +Ship desktop beta first. Before a tag, record the exact main SHA, changeset +files, generated release PR, desktop package version, last known good tag, and +the intended `vX.Y.Z-beta.N` tag. Build and smoke the packaged AppImage, inspect +the packaged resources when runtime files changed, and walk the project-map, +ordinary-session, direct-build, and delegation journeys. + +The npm path is changeset → merged version PR → publish. The desktop path is a +tag exactly matching `packages/harness-desktop/package.json`. Stable release is +allowed only after beta evidence and the update manifests are present. + +## Rollback reality + +There is no in-place downgrade for users who already installed a bad npm or +desktop version. Recovery is roll-forward: + +1. identify and revert the faulty commit on a new branch; +2. add a new changeset and publish a strictly higher package version; +3. build and publish a strictly higher desktop tag; +4. verify installers, `latest*.yml`, and blockmaps remain available for the + last known good and new recovery releases; +5. use deprecation only as an installer warning, never as an unpublish plan. + +Deleting a tag or release cannot downgrade installed desktop applications and +may strand the updater. `SAPIOM_UPDATE_CHANNEL` is a single-machine diagnostic, +not fleet rollback. Record an out-of-hours approver and drill the full +revert→changeset→version-PR→tag sequence before stable rollout. + +## Product-state restoration + +Product restoration is separate from binary rollback. A map or plan restore is +an ordinary expected-version write. It appends a new immutable version whose +content matches the selected historical version and whose +`restoredFromVersionId` names that source. The previous history remains +byte-for-byte unchanged, the current pointer advances atomically, and a stale +restore conflicts like any other concurrent write. diff --git a/docs/plans/agent-studio-plan-first-agent-map/sap-3152-cutover-ledger.md b/docs/plans/agent-studio-plan-first-agent-map/sap-3152-cutover-ledger.md new file mode 100644 index 000000000..f201d18aa --- /dev/null +++ b/docs/plans/agent-studio-plan-first-agent-map/sap-3152-cutover-ledger.md @@ -0,0 +1,90 @@ +# SAP-3152 cutover ledger + +Recorded against predecessor +`ca747232e0b775b5be5c69c5427c778b3774dd4f`. This ledger uses the frozen +preflight snapshot; legacy pull requests were not queried again. + +## Replacement stack + +| Issue | PR | Exact head | Replacement evidence | +| --- | ---: | --- | --- | +| SAP-3148 | #804 | `5d00c55925d1907670bed6e885384b25eb775a73` | Neutral session principal, common prompt/tools, project-name map selection, ordinary tabs, durable bootstrap | +| SAP-3149 | #806 | `90eb569eb6b90b917c49128b6a23c7215a0843d6` | Canonical digests, immutable history, CAS/rebase/restore, migration, universal authoring | +| SAP-3150 | #807 | `acb2dbae6a3e533e01a065c43fa4109bdd82ca14` | Deterministic canonical/ad-hoc briefs, impact, bounded context projection | +| SAP-3151 | #808 | `ca747232e0b775b5be5c69c5427c778b3774dd4f` | Writable nested delegation, claims, reuse, kickoff acknowledgement, recovery, manual-session ownership | + +SAP-3151 is frozen by explicit user direction. Its exact-head review confirmed +the prior bounded fixes and left one known cleanup-recovery finding. The user +directed the stack to move forward without another SAP-3151 change. This is a +recorded exception, not an approval claim; SAP-3152 does not modify that PR. + +## Frozen legacy disposition + +| PR | Frozen head | Relationship | Replacement | Retained in replacement | Removed behavior | Closure | +| ---: | --- | --- | --- | --- | --- | --- | +| #773 | `0f9e86bd386b8c1bcffa2ab105b7c0c0707fb46b` | independent draft | SAP-3148 / #804 | PR-body design history only | browser-only simulated concept surface | Pending gate; date unset | +| #783 | `044c2664a8ac8bc94433f684fa85030671e75e10` | sibling of #784 | SAP-3149 / #806 | canonical graph ordering, ancestry, integrity tests | approval materialization and user-message evidence | Pending gate; date unset | +| #784 | `48dc1c5cdf5507e14f74e5b9bccdb0a863c2c16d` | root of #785–#787 | SAP-3149 / #806 and SAP-3150 / #807 | plan/brief records, exact sources, CAS, store integrity | role-bearing authorship, submissions, eligibility permission | Pending gate; date unset | +| #785 | `9cf604dc26172310e393dd05fc9b8ee2c63f0243` | child of #784 | SAP-3149 / #806 | read/validate/apply/rebase, atomic IDs and replay | role-only assertions and conditional tool registration | Pending gate; date unset | +| #786 | `2de609c6c0da7435ff35bf7d046f3b3a5aed3658` | child of #785 | SAP-3150 / #807 | compiler, impact fingerprints, bounded projection | restricted-session naming and implementation gate | Pending gate; date unset | +| #787 | `e0ca5cc9fefe0b03893e1d6bfe95d61d07bc0210` | child of #786 | SAP-3151 / #808 | spawn claims, reuse, kickoff delivery/recovery | consent ceremony, read-only sessions, fixed fan-out and authority hierarchy | Pending gate; date unset | +| #791 | `b7384928a97f128d1bbcf62187550bbef3c874ca` | independent | SAP-3148 / #804 | readiness, retry, preemption and evidence discipline | special-session bootstrap and one-shot maintenance | Pending gate; date unset | + +#783 and #784 share an old base but are not stacked. They exported conflicting +revision/digest concepts and computed different digest preimages. SAP-3149 +reconstructed one vocabulary from the replacement base rather than merging +both and repairing them afterward. + +No branch is to be deleted, rewritten, or merged for preservation. Closure is +permitted only after the SAP-3152 exact head has complete local evidence, +hosted checks, and exact-head autonomous review. Ownership-excluded pull +requests are outside this ledger, all API operations, and every completion +gate. + +## Traceability commands + +The following replacement tests passed together at the SAP-3152 checkpoint: + +- #783: `agent-map-version.test.ts`, `agent-map-canonical.test.ts`, and + `agent-map-codec.test.ts`; +- #784 and #785: `build-plan-service.test.ts`, + `build-plan-canonicalization.test.ts`, `build-plan-codec.test.ts`, and the + build-plan MCP cases in `agent-map.test.ts`; +- #786: `agent-brief-compiler.test.ts`, `agent-brief.test.ts`, and the focused + context cases in `session-manager.test.ts`; +- #787: `subsession-coordinator.test.ts`, + `subsession-coordinator-store.test.ts`, and + `subsession-delegation.spec.ts`; +- #791: `project-bootstrap.test.ts`, `project-bootstrap-outbox.test.ts`, + `project-bootstrap-outbox.test.ts` under the server suite, and the bootstrap + cases in `agent-map-mcp-wiring.test.ts`. + +The aggregate absence gate is `pnpm terminology:check`. It scans live code, +tests, browser journeys, docs, and changesets and fails when a retired literal +appears outside the exact migration allowlist or an allowlisted occurrence +count changes. + +The closure comment for each row must link SAP-3147, SAP-3152, this PR (#811), +and the row's replacement PR; quote the frozen head; state that the branch and +review history remain available; summarize the retained and removed columns; +and set the actual UTC closure date here immediately after the remote action. + +## Retained legacy strings + +The terminology allowlist is the executable register. Retired literals are +limited to: + +- the read-only E2 proposal-actor decoder and its exact migration tests; +- deployed E2 aggregate migration fixtures; +- pre-upgrade project-bootstrap and session-metadata migration fixtures; +- the isolated legacy bootstrap-state path resolver; +- the released `plannerOrigin` prompt-event key, decoded only to keep private + bootstrap instructions out of upgraded session transcripts. + +That prompt-event key is intentionally retained rather than included in the +dead-model deletion set. It affects transcript compatibility only and cannot +select identity, tools, prompts, sandbox policy, or implementation authority. + +Each entry has an exact path, pattern, occurrence count, rationale, and stale +entry failure. `agent-map-legacy-migration.test.ts` also proves the decoder is +referenced only by aggregate migration and not by live proposal services. diff --git a/docs/plans/agent-studio-plan-first-agent-map/sap-3152-journey-evidence.md b/docs/plans/agent-studio-plan-first-agent-map/sap-3152-journey-evidence.md new file mode 100644 index 000000000..47bac1876 --- /dev/null +++ b/docs/plans/agent-studio-plan-first-agent-map/sap-3152-journey-evidence.md @@ -0,0 +1,87 @@ +# SAP-3152 journey evidence + +Evidence date: 2026-09-04 UTC. Replacement predecessor: +`ca747232e0b775b5be5c69c5427c778b3774dd4f`. + +This record distinguishes automated proof from checks that require a hosted +or interactive desktop environment. It does not turn an unavailable manual +check into a pass. + +## Automated journey matrix + +| # | Journey | Evidence | Result | +| ---: | --- | --- | --- | +| 1 | One ordinary `Plan Agents` session and retry-safe bootstrap | `project-bootstrap-outbox.test.ts`, `project-bootstrap.test.ts`, `project-bootstrap-outbox.test.ts`, `agent-map-mcp-wiring.test.ts`; full Harness and browser suites | Pass for scheduling, retry, restart, preemption, one-session identity, and one accepted map update. A human real-model evidence-quality walk remains required. | +| 2 | Project name renders the map without creating or switching a session | `project-map-navigation.spec.ts` in the 501-test Chromium run | Pass | +| 3 | `Plan Agents` is an ordinary writable conversation/canvas tab | `project-map-navigation.spec.ts`, `session-tabs.spec.ts`, and MCP wiring tests | Pass | +| 4 | Clear implementation begins without a role/mode/confirmation gate | `direct-action-gating.test.ts`, `direct-actions.spec.ts`, `new-session-composer.spec.ts` | Pass | +| 5 | A manual session updates the shared map and plan | `agent-map.test.ts`, `agent-map-mcp-wiring.test.ts`, `build-plan-service.test.ts` | Pass | +| 6 | Concurrent plan writes conflict or explicitly rebase without loss | `agent-map-proposal-service.test.ts`, `build-plan-service.test.ts` | Pass | +| 7 | Canonical and ad hoc briefs focus context without changing tools | `agent-brief-compiler.test.ts`, `agent-brief-service.test.ts`, `focused-project-context.test.ts` | Pass | +| 8 | Delegation retry produces one child/process/kickoff/tab | `subsession-coordinator*.test.ts`, `subsession-delegation.spec.ts` | Pass | +| 9 | A delegated child can delegate with the same project capabilities | `subsession-coordinator.test.ts`, `agent-map-mcp-wiring.test.ts` | Pass | +| 10 | Spawn/kickoff restart boundaries reconcile durably | `subsession-coordinator-store.test.ts` and `subsession-coordinator.test.ts`, including uncertain-delivery and fresh-restart cases | Pass at the fault-injected storage/service layer. A real process-kill desktop walk remains required. | +| 11 | Reconciliation does not claim, kill, or release manual sessions | `subsession-coordinator.test.ts`, `subsession-delegation.spec.ts` | Pass for service/UI ownership. The preflight's byte-and-mtime snapshot variant was not added to the frozen SAP-3151 product PR. | +| 12 | Historical state is readable and restoration appends | `agent-map-version.test.ts`, `build-plan-service.test.ts`; both assert a new version, copied semantics, explicit `restoredFromVersionId`, immutable ancestry, and a new record digest | Pass | +| 13 | Existing Canvas/Steps and ordinary session creation remain intact | 501/501 web Playwright tests and 11/11 Canvas Playwright tests; no `.skip` or `.fixme` in either suite | Pass | + +## Commands and exact results + +| Command | Result | +| --- | --- | +| `pnpm build` | Pass | +| `pnpm typecheck` | Pass, including the Harness web TypeScript project | +| `pnpm lint` | Pass under the repository's existing lint boundary; SAP-3152 does not widen or change it | +| `pnpm --filter @sapiom/harness test` | Pass: 223 files, 3,579 tests; performance tier 3 files, 10 tests | +| Review-fix transcript/migration/containment regressions | Pass: 3 files, 220 tests | +| Review-fix bootstrap/server/privacy regressions | Pass: 5 files, 123 tests | +| Focused map/plan/brief/bootstrap/delegation Vitest command | Pass: 9 files, 117 tests | +| Explicit E1/E2-to-neutral migration Vitest command | Pass: 2 files, 20 tests | +| `pnpm --filter @sapiom/harness test:ui` | Pass: 501 Chromium tests | +| `pnpm --filter @sapiom/harness test:canvas` | Pass: 11 Chromium tests | +| examples, terminology, and provider-copy gates | Pass: terminology audited 914 files with no stale allowlist entries | +| Harness production build | Pass | +| Desktop distribution | AppImage created; `.deb` packaging then failed because this VM lacks `libcrypt.so.1` | +| Packaged AppImage smoke | Not runnable here: this VM has neither FUSE nor an X server/`xvfb-run`. The AppImage exists and extracts successfully. | +| `pnpm --filter @sapiom/harness e2e:live` | Partial: node-pty and the complete first server/session/ingest/canvas phase passed; the second phase failed an unchanged cached-API-key fixture assertion | +| `pnpm --filter @sapiom/harness sim:e2e` | Failed in an unchanged script: its ingest-router fixture omits the now-required `authenticate` dependency | + +The two opt-in simulation failures do not touch SAP-3152 files and are +recorded as follow-up test-infrastructure drift, not hidden or repaired in this +audit/docs ticket. + +## Dead-model and privacy audit + +- No live prompt, tool-registration, sandbox, session-selection, or telemetry + path branches on the retired project-agent roles. +- Retired literals occur only in explicit migration decoders/fixtures and are + enforced by exact-path, exact-count terminology allowlist entries. +- The browser and server redaction tests cover prompt bodies, source text, + local paths, credentials, connector content, provider errors, and focused + brief prose. Lifecycle telemetry carries bounded enums, identifiers, counts, + and digests rather than authored content. +- No web or Canvas spec is skipped or marked fixme. + +## Manual/hosted completion items + +Before final closure or stable rollout, attach: + +1. a real-model first-project recording showing that seeded nodes are supported + by available project evidence; +2. a packaged desktop walk of project-name map selection, ordinary tabs, + direct implementation, retry-safe delegation, and a real process restart; +3. the hosted exact-head checks and exact-head autonomous SAP-3152 review; +4. the beta-first rollout drill described in `rollout-rollback.md`. + +Until those items exist, the legacy-PR closure gate remains closed. + +The default branch changed its autonomous-review workflow after the replacement +stack was frozen. SAP-3152 copied that workflow byte-for-byte in a dedicated CI +commit, then used a temporary review-base branch rooted at the exact SAP-3151 +head with the same workflow blob. A no-content feature-branch merge made the +shim commit an ancestor of SAP-3152 without changing its tree. This removed the +workflow from the PR file list and allowed an exact-head review of the unchanged +product/docs diff. The first usable review found the released-event transcript +compatibility and containment-test gaps now covered by the focused regressions +above. The PR returns to its real SAP-3151 base after a clean exact-head review; +the temporary branch is retained and never merged as a pull request. diff --git a/docs/plans/agent-studio-plan-first-agent-map/sap-3152-linear-reconciliation.md b/docs/plans/agent-studio-plan-first-agent-map/sap-3152-linear-reconciliation.md new file mode 100644 index 000000000..63675da64 --- /dev/null +++ b/docs/plans/agent-studio-plan-first-agent-map/sap-3152-linear-reconciliation.md @@ -0,0 +1,39 @@ +# SAP-3152 Linear reconciliation ledger + +Status: ready to apply from an authenticated Linear context. This environment +has no Linear CLI, credential, or connector, so no status, relation, comment, +or project description was changed or fabricated. + +## Required updates + +1. Mark SAP-3051 and children SAP-3059, SAP-3060, SAP-3061, and SAP-3064 Done; + their E2 PRs are merged. Confirm the E1 parent/children are Done. +2. Make `SAP-3148 → SAP-3149 → SAP-3150 → SAP-3151 → SAP-3152` the only + checkpoint execution chain. +3. Record the four post-E2 untracked merges as architecture drift corrected by + SAP-3148/SAP-3152. +4. Resolve the concrete E6, E7, and E8 issue IDs in Linear, then rewrite them to + consume the neutral current identity/version/brief/session contracts before + they become executable. + +| Old ticket | Ready-to-apply status | Replacement | Rationale | +| --- | --- | --- | --- | +| SAP-3048 | Cancel | SAP-3149 | Architecture confirmation no longer authorizes coding; immutable history survives | +| SAP-3047 | Supersede or rewrite remaining distinct later work | SAP-3149 + SAP-3150 | Durable plan and brief outcomes survive under universal authorship | +| SAP-3050 | Cancel elevation outcome; rewrite any distinct launch follow-up | SAP-3151 | Delegation is ordinary writable session creation, not an execution permission boundary | +| SAP-3062 | Supersede | SAP-3149 | Preserve canonicalization/ancestry; record sibling conflict with SAP-3067 | +| SAP-3067 | Supersede | SAP-3149 + SAP-3150 | Preserve store/records; remove role and eligibility authority | +| SAP-3068 | Supersede | SAP-3149 | Preserve four authoring operations; make discovery universal | +| SAP-3070 | Supersede | SAP-3150 | Preserve deterministic compiler/impact/projection under neutral context | +| SAP-3074 | Supersede | SAP-3151 | Preserve reliability; remove consent/read-only/fixed fan-out lifecycle | +| SAP-3063 | Inspect and disposition explicitly | To resolve in Linear | It is cited by the frozen #783 record but absent from the supplied minimum list | + +The SAP-3147 final comment should link the replacement PRs, paste the frozen +legacy disposition table from the cutover ledger, record this ticket table, and +state that ownership-excluded work remained untouched and non-blocking. + +The project description should preserve E0–E2 as historical foundations and +describe the verified current product: one capable project-agent identity, one +shared evolving map/plan, project-name map selection, ordinary session tabs, +optional focused briefs, writable nested delegation, append-only restoration, +manual-session preservation, and evidence that never silently becomes intent. diff --git a/packages/harness/README.md b/packages/harness/README.md index 03129ac7a..33096db7b 100644 --- a/packages/harness/README.md +++ b/packages/harness/README.md @@ -113,29 +113,21 @@ proves that turn cannot overlap; prompts are never concatenated or blindly interleaved. Opening the map never schedules bootstrap. Bootstrap state lives under -`/agent-map/project-bootstrap/`. Valid pre-upgrade planner metadata +`/agent-map/project-bootstrap/`. Valid pre-upgrade session metadata and queue files are read and normalized without changing the session ID, provider binding, working directory, title, transcript, or Canvas. Malformed or ambiguous legacy identity is retained and rejected safely rather than deleting -or duplicating the session. The older -`/api/projects/:projectId/planner-sessions...` endpoints remain bounded rolling -compatibility aliases into the ordinary session/bootstrap services; new clients -use the generic session routes. The aliases are scheduled for removal in -SAP-3152. +or duplicating the session. Retired record strings live only in dedicated, +tested migration decoders. Live clients use the generic session routes. #### Embedder migration The public `HarnessSession.agentMapIdentity` is now the exported `ProjectAgentSession { projectId, userId, sessionId }`. Embedders must stop -reading `role` or `assignment`; those legacy fields no longer describe live -authority. Persisted `planning` data is migration input only. Read the optional -`projectBootstrap` field when displaying bootstrap lifecycle state. - -The deprecated planner-message alias can return `metadata: null` for an -ordinary project session. When metadata is present, read its top-level -`projectId`, `userId`, and `targetSessionId` plus `bootstrap` instead of the old -nested `identity` and `greeting` fields. New clients should use the generic -session routes. If an embedder already owns the first prompt for a session, set +reading legacy authority fields; those fields no longer describe live +authority. Persisted pre-upgrade project-session data is migration input only. +Read the optional `projectBootstrap` field when displaying bootstrap lifecycle +state. If an embedder already owns the first prompt for a session, set `initialUserInputPending: true` in that session's `CreateSessionRequest`; this content-free flag makes project bootstrap yield before launch and never changes the session's authority or tools. diff --git a/packages/harness/src/core/agent-map-aggregate-migration.ts b/packages/harness/src/core/agent-map-aggregate-migration.ts index 10617e688..a6b744fdd 100644 --- a/packages/harness/src/core/agent-map-aggregate-migration.ts +++ b/packages/harness/src/core/agent-map-aggregate-migration.ts @@ -9,10 +9,10 @@ import { parseAgentMapProposalReceipt, parseMapChangeProposal, parseMapOperation, - parseLegacyE2ProposalActor, parseProjectAgentActorRef, type PersistedAgentMapProposalReceipt, } from "../shared/agent-map-codec.js"; +import { parseLegacyE2ProposalActor } from "../shared/agent-map-legacy-migration.js"; import { parseAgentMapVersion } from "../shared/agent-map-version-codec.js"; import { canonicalDigest, canonicalJson } from "../shared/agent-map-canonical.js"; import type { diff --git a/packages/harness/src/core/agent-map-capability-registry.test.ts b/packages/harness/src/core/agent-map-capability-registry.test.ts index 1118d18d9..e8f3127d1 100644 --- a/packages/harness/src/core/agent-map-capability-registry.test.ts +++ b/packages/harness/src/core/agent-map-capability-registry.test.ts @@ -1,9 +1,6 @@ import { describe, expect, it, vi } from "vitest"; -import type { - PlanningSessionIdentity, - ProjectAgentSession, -} from "../shared/agent-map.js"; +import type { ProjectAgentSession } from "../shared/agent-map.js"; import { AgentMapCapabilityError, AgentMapCapabilityRegistry, @@ -30,21 +27,19 @@ describe("AgentMapCapabilityRegistry", () => { ); }); - it("strips legacy origin metadata before it enters live capability authority", () => { - const legacy: PlanningSessionIdentity = { + it("copies only the neutral identity fields into live capability authority", () => { + const identityWithUntrustedExtras = { ...identity(), - role: "agent-builder", - assignment: { kind: "planned", agentId: "agent-1" }, + untrustedContext: { assignmentId: "assignment-1" }, }; const registry = new AgentMapCapabilityRegistry({ randomToken: () => "legacy-session-token", }); - const issued = registry.issue(legacy); + const issued = registry.issue(identityWithUntrustedExtras); expect(issued.identity).toEqual(identity()); - expect(issued.identity).not.toHaveProperty("role"); - expect(issued.identity).not.toHaveProperty("assignment"); + expect(issued.identity).not.toHaveProperty("untrustedContext"); }); it("fails closed for expired, revoked and unknown tokens without emitting material", () => { diff --git a/packages/harness/src/core/agent-map-proposal-schema.test.ts b/packages/harness/src/core/agent-map-proposal-schema.test.ts index 3a625d999..d02195d0e 100644 --- a/packages/harness/src/core/agent-map-proposal-schema.test.ts +++ b/packages/harness/src/core/agent-map-proposal-schema.test.ts @@ -91,7 +91,7 @@ describe("Agent Map proposal caller schema", () => { it.each([ ["project authority", { projectId: "project_1" }, "immutable_field"], - ["actor authority", { actor: { role: "map-planner" } }, "immutable_field"], + ["actor authority", { actor: { authority: "forged" } }, "immutable_field"], ["unknown root field", { unexpected: true }, "malformed_input"], ])("rejects %s rather than stripping it", (_name, extra, code) => { const parsed = parseProposalBatchRequest({ ...allOperations, ...extra }); diff --git a/packages/harness/src/core/agent-map-proposal-service.test.ts b/packages/harness/src/core/agent-map-proposal-service.test.ts index 71e22cdcc..92a745e83 100644 --- a/packages/harness/src/core/agent-map-proposal-service.test.ts +++ b/packages/harness/src/core/agent-map-proposal-service.test.ts @@ -8,7 +8,6 @@ import type { MapProposalId, PlanNodeId, PlanRelationshipId, - PlanningSessionIdentity, ProjectAgentSession, ProposalBatchRequest, ProposalOperationId, @@ -369,29 +368,11 @@ describe("AgentMapProposalService", () => { }); }); - it("ignores former origin metadata and uses one neutral write authority", async () => { + it("uses one neutral write authority for every ordinary project session", async () => { const { service } = await fixture(); - const planner: PlanningSessionIdentity = { - ...identity("planner"), - role: "map-planner", - }; - const first = await service.propose(planner, addNode("planner", 0, null)); - const assigned: PlanningSessionIdentity = { - projectId, - userId: "user-1", - sessionId: "assigned", - role: "agent-builder", - assignment: { kind: "planned", agentId: "planned-agent" }, - }; - await service.propose(assigned, addNode("assigned", 1, first.proposalId)); - const unplanned: PlanningSessionIdentity = { - projectId, - userId: "user-1", - sessionId: "unplanned", - role: "agent-builder", - assignment: { kind: "unplanned" }, - }; - await service.propose(unplanned, addNode("unplanned", 2, first.proposalId)); + const first = await service.propose(identity("session-one"), addNode("one", 0, null)); + await service.propose(identity("session-two"), addNode("two", 1, first.proposalId)); + await service.propose(identity("session-three"), addNode("three", 2, first.proposalId)); expect( (await service.read(projectId)).proposal?.history.map( ({ actor }) => actor, @@ -399,15 +380,15 @@ describe("AgentMapProposalService", () => { ).toEqual([ { userId: "user-1", - sessionId: "planner", + sessionId: "session-one", }, { userId: "user-1", - sessionId: "assigned", + sessionId: "session-two", }, { userId: "user-1", - sessionId: "unplanned", + sessionId: "session-three", }, ]); }); diff --git a/packages/harness/src/core/inject/claude-settings.test.ts b/packages/harness/src/core/inject/claude-settings.test.ts index 1d1513570..1bf43728d 100644 --- a/packages/harness/src/core/inject/claude-settings.test.ts +++ b/packages/harness/src/core/inject/claude-settings.test.ts @@ -63,13 +63,13 @@ describe("generateClaudeSettings", () => { } }); - it("shows planner onboarding only for a fresh SessionStart hook", async () => { + it("shows a configured startup message only for a fresh SessionStart hook", async () => { const message = [ - "Agent Map planning session", - "Use this session to scope what you want to build—not to implement it yet.", + "Agent Studio session", + "Review the current project context before beginning.", ].join("\n"); const { emitScriptPath } = await generateClaudeSettings({ - harnessSessionId: "planner-session", + harnessSessionId: "project-session", generatedRoot: tmpDir, sessionStartSystemMessage: message, }); diff --git a/packages/harness/src/core/paths.test.ts b/packages/harness/src/core/paths.test.ts index 1177e89ce..a690170ac 100644 --- a/packages/harness/src/core/paths.test.ts +++ b/packages/harness/src/core/paths.test.ts @@ -31,9 +31,6 @@ describe("resolveStatePaths", () => { expect(paths.settings).toBe(path.join(root, "settings.json")); expect(paths.studioProjects).toBe(path.join(root, "studio-projects.json")); expect(paths.agentMap).toBe(path.join(root, "agent-map")); - expect(paths.plannerSessions).toBe( - path.join(root, "agent-map", "planner-sessions"), - ); expect(paths.generated).toBe(path.join(root, "generated")); expect(paths.sampleProject).toBe(path.join(root, "sample-project")); }); @@ -48,9 +45,6 @@ describe("resolveStatePaths", () => { expect(paths.settings).toBe("/scratch/state/settings.json"); expect(paths.studioProjects).toBe("/scratch/state/studio-projects.json"); expect(paths.agentMap).toBe("/scratch/state/agent-map"); - expect(paths.plannerSessions).toBe( - "/scratch/state/agent-map/planner-sessions", - ); expect(paths.generated).toBe("/scratch/state/generated"); expect(paths.sampleProject).toBe("/scratch/state/sample-project"); }); diff --git a/packages/harness/src/core/paths.ts b/packages/harness/src/core/paths.ts index e7e28483f..aaa26598d 100644 --- a/packages/harness/src/core/paths.ts +++ b/packages/harness/src/core/paths.ts @@ -29,8 +29,6 @@ export interface HarnessStatePaths { pendingSecrets: string; agentMap: string; projectBootstrap: string; - /** @deprecated Read-only migration source; remove in SAP-3152. */ - plannerSessions: string; generated: string; records: string; sampleProject: string; @@ -63,7 +61,6 @@ export function resolveStatePaths(stateRoot?: string): HarnessStatePaths { pendingSecrets: join(root, relativeToHome(HARNESS_PATHS.pendingSecrets)), agentMap: join(root, relativeToHome(HARNESS_PATHS.agentMap)), projectBootstrap: join(root, "agent-map", "project-bootstrap"), - plannerSessions: join(root, "agent-map", "planner-sessions"), generated: join(root, relativeToHome(HARNESS_PATHS.generated)), records: join(root, relativeToHome(HARNESS_PATHS.records)), sampleProject: join(root, relativeToHome(HARNESS_PATHS.sampleProject)), diff --git a/packages/harness/src/core/planning-session.test.ts b/packages/harness/src/core/planning-session.test.ts deleted file mode 100644 index e8bdfd18b..000000000 --- a/packages/harness/src/core/planning-session.test.ts +++ /dev/null @@ -1,885 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; - -import type { AgentMapWorkspaceState } from "../shared/agent-map.js"; -import type { - CreateSessionRequest, - HarnessSession, - SessionRecord, -} from "../shared/types.js"; -import type { AgentMapWorkspaceStore } from "./agent-map-workspace-store.js"; -import { - buildFocusedPlannerContext, - buildFocusedProjectContext, - isCurrentProjectRoot, - isPlannerDispatchAuthorized, - isProjectSessionDispatchAuthorized, - isWithinCurrentProject, - localPlanningPrincipal, - localProjectPrincipal, - PlanningSessionService, - ProjectSessionService, - type ProjectSessionLifecycleEvent, -} from "./planning-session.js"; -import type { SessionManager } from "./session-manager.js"; -import type { - StudioProjectCatalog, - StudioProjectIdentity, -} from "./studio-project-catalog.js"; - -const projectId = "project_00000000-0000-4000-8000-000000000001"; -const projectRoot = "/Users/private/customer-secret-project"; - -const project: StudioProjectIdentity = { - projectId, - identityVersion: 1, - displayName: "Private research", - rootBindings: [ - { - id: "root_00000000-0000-4000-8000-000000000001", - repositoryId: "repo-private", - localRootRef: projectRoot, - status: "active", - }, - ], - legacyWorkspaceKeys: ["private-workspace-key"], - createdAt: "2026-09-01T00:00:00.000Z", - updatedAt: "2026-09-01T00:00:00.000Z", -}; - -const workspace: AgentMapWorkspaceState = { - projectId, - schemaVersion: 1, - recordVersion: 1, - confirmedRevisionId: null, - activeProposalId: null, - projectBuildPlanId: null, - createdAt: "2026-09-01T00:00:00.000Z", - updatedAt: "2026-09-01T00:00:00.000Z", -}; - -function session( - id: string, - overrides: Partial = {}, -): HarnessSession { - return { - id, - agentSessionId: null, - harness: "codex", - cwd: projectRoot, - title: "Ordinary session", - status: "running", - createdAt: "2026-09-01T00:00:00.000Z", - lastActiveAt: "2026-09-01T00:00:00.000Z", - exitCode: null, - boundWorkflowPath: null, - ready: false, - agentMapIdentity: { projectId, sessionId: id, userId: "user-1" }, - ...overrides, - }; -} - -interface FixtureOptions { - existing?: HarnessSession[]; - initialProject?: StudioProjectIdentity | null; - stampCreatedIdentity?: boolean; - createImpl?: ( - request: CreateSessionRequest, - createdId: string, - ) => Promise; - resumeImpl?: (id: string) => Promise; -} - -function fixture(options: FixtureOptions = {}) { - const existing = options.existing ?? []; - const created: HarnessSession[] = []; - let next = 0; - let resolvedProject = - options.initialProject === undefined ? project : options.initialProject; - let currentUserId: string | null = "user-1"; - const lifecycleEvents: ProjectSessionLifecycleEvent[] = []; - const legacyRegistration = vi.fn(); - const legacyEvents = vi.fn(); - const create = vi.fn(async (request: CreateSessionRequest) => { - const id = `new-${++next}`; - const value = options.createImpl - ? await options.createImpl(request, id) - : session(id, { - cwd: request.cwd, - harness: request.harness, - ...(request.theme ? { theme: request.theme } : {}), - ...(options.stampCreatedIdentity === false - ? { agentMapIdentity: undefined } - : { - agentMapIdentity: { - projectId, - sessionId: id, - userId: localProjectPrincipal(currentUserId, "machine-1"), - }, - }), - }); - created.push(value); - return value; - }); - const resume = vi.fn(async (id: string) => { - if (options.resumeImpl) return options.resumeImpl(id); - const value = [...existing, ...created].find( - (candidate) => candidate.id === id, - ); - if (!value) throw new Error("missing session"); - value.status = "running"; - return value; - }); - const kill = vi.fn(async () => true); - const manager = { - create, - resume, - list: () => [...existing, ...created], - isLive: (id: string) => - [...existing, ...created].some( - (candidate) => candidate.id === id && candidate.status !== "exited", - ), - get: (id: string) => - [...existing, ...created].find((candidate) => candidate.id === id), - kill, - } as unknown as SessionManager; - const service = new ProjectSessionService({ - catalog: { - resolveIdentity: async (id: string) => - id === projectId ? resolvedProject : null, - } as unknown as StudioProjectCatalog, - workspaceStore: { - readOrCreate: async () => workspace, - } as unknown as AgentMapWorkspaceStore, - sessionManager: manager, - readRecord: async () => null, - userId: "user-1", - currentUserId: () => currentUserId, - machineId: "machine-1", - defaultHarness: "codex", - onPlannerSession: legacyRegistration, - onEvent: legacyEvents, - onProjectSessionEvent: (event) => { - lifecycleEvents.push(event); - }, - }); - return { - service, - create, - resume, - kill, - created, - lifecycleEvents, - legacyRegistration, - legacyEvents, - setProject: (value: StudioProjectIdentity | null) => { - resolvedProject = value; - }, - setUserId: (value: string | null) => { - currentUserId = value; - }, - }; -} - -describe("neutral project-session compatibility exports", () => { - it("keeps planner-named APIs as aliases of the neutral implementation", () => { - expect(PlanningSessionService).toBe(ProjectSessionService); - expect(localPlanningPrincipal).toBe(localProjectPrincipal); - expect(isPlannerDispatchAuthorized).toBe( - isProjectSessionDispatchAuthorized, - ); - expect(isCurrentProjectRoot).toBe(isWithinCurrentProject); - expect(buildFocusedPlannerContext).toBe(buildFocusedProjectContext); - }); - - it("uses the authenticated user or a stable machine-local principal", () => { - expect(localProjectPrincipal("user-1", "machine-1")).toBe("user-1"); - expect(localProjectPrincipal(null, "machine-1")).toBe("local:machine-1"); - }); -}); - -describe("project root containment", () => { - it("accepts the root and descendants of every active binding", () => { - const multiRoot: StudioProjectIdentity = { - ...project, - rootBindings: [ - ...project.rootBindings, - { - id: "root_00000000-0000-4000-8000-000000000002", - repositoryId: "repo-secondary", - localRootRef: "/Users/private/secondary", - status: "active", - }, - ], - }; - - expect(isWithinCurrentProject(multiRoot, projectRoot)).toBe(true); - expect( - isWithinCurrentProject(multiRoot, `${projectRoot}/agents/research`), - ).toBe(true); - expect( - isWithinCurrentProject(multiRoot, "/Users/private/secondary/packages/a"), - ).toBe(true); - }); - - it("rejects prefix siblings, parents, inactive roots, and mixed path families", () => { - const withInactive: StudioProjectIdentity = { - ...project, - rootBindings: [ - ...project.rootBindings, - { - id: "root_00000000-0000-4000-8000-000000000003", - repositoryId: null, - localRootRef: "/Users/private/inactive", - status: "missing", - }, - ], - }; - - expect(isWithinCurrentProject(withInactive, `${projectRoot}-old`)).toBe( - false, - ); - expect(isWithinCurrentProject(withInactive, "/Users/private")).toBe(false); - expect( - isWithinCurrentProject(withInactive, "/Users/private/inactive/agent"), - ).toBe(false); - expect( - isWithinCurrentProject(withInactive, "C:\\Users\\private\\project"), - ).toBe(false); - }); - - it("normalizes Windows separators and compares on segment boundaries", () => { - const windowsProject: StudioProjectIdentity = { - ...project, - rootBindings: [ - { - ...project.rootBindings[0]!, - localRootRef: "C:\\Users\\private\\project", - }, - ], - }; - - expect( - isWithinCurrentProject( - windowsProject, - "C:/Users/private/project/agents/research", - ), - ).toBe(true); - expect( - isWithinCurrentProject(windowsProject, "C:\\Users\\private\\project-old"), - ).toBe(false); - }); -}); - -describe("role-neutral dispatch authorization", () => { - it("authorizes only the exact neutral principal inside its current project", async () => { - const ordinary = session("ordinary", { - cwd: `${projectRoot}/packages/research`, - }); - - await expect( - isProjectSessionDispatchAuthorized({ - session: ordinary, - currentPrincipal: () => "user-1", - resolveProject: async () => project, - }), - ).resolves.toBe(true); - await expect( - isProjectSessionDispatchAuthorized({ - session: ordinary, - currentPrincipal: () => "user-2", - resolveProject: async () => project, - }), - ).resolves.toBe(false); - await expect( - isProjectSessionDispatchAuthorized({ - session: session("foreign", { - agentMapIdentity: { - projectId: "project_foreign", - sessionId: "foreign", - userId: "user-1", - }, - }), - currentPrincipal: () => "user-1", - resolveProject: async () => null, - }), - ).resolves.toBe(false); - }); - - it("does not authorize planner-era metadata without a neutral identity", async () => { - const legacy = session("legacy", { - agentMapIdentity: undefined, - planning: { - identity: { - projectId, - sessionId: "legacy", - userId: "user-1", - role: "map-planner", - }, - greeting: { status: "pending" }, - queuedInputIds: [], - }, - }); - - await expect( - isProjectSessionDispatchAuthorized({ - session: legacy, - currentPrincipal: () => "user-1", - resolveProject: async () => project, - }), - ).resolves.toBe(false); - }); - - it("rechecks principal and session identity after the project lookup await", async () => { - let userId = "user-1"; - const ordinary = session("dispatch-race"); - let resolveProject!: (value: StudioProjectIdentity | null) => void; - const authorization = isProjectSessionDispatchAuthorized({ - session: ordinary, - currentPrincipal: () => userId, - resolveProject: () => - new Promise((resolve) => { - resolveProject = resolve; - }), - }); - - await Promise.resolve(); - userId = "user-2"; - ordinary.agentMapIdentity = { - projectId, - sessionId: ordinary.id, - userId: "user-2", - }; - resolveProject(project); - - await expect(authorization).resolves.toBe(false); - }); -}); - -describe("focused project context compatibility projection", () => { - it("is role-neutral, path-free, bounded, and ignores planner onboarding", () => { - const context = buildFocusedProjectContext({ - project, - workspace, - sessionId: "session-1", - userId: "user-1", - onboardOnFirstResponse: true, - details: { - warnings: Array.from( - { length: 40 }, - (_, index) => `warning-${index}-${"w".repeat(400)}`, - ), - }, - }); - const parsed = JSON.parse(context.split("\n")[2]!) as { - identity: Record; - project: { warnings: string[] }; - }; - - expect(parsed.identity).toEqual({ - projectId, - sessionId: "session-1", - userId: "user-1", - }); - expect(parsed.project.warnings).toHaveLength(16); - expect(parsed.project.warnings[0]!.length).toBeLessThanOrEqual(256); - expect(context).not.toContain('"role"'); - expect(context).not.toContain("map-planner"); - expect(context).not.toContain("planning agent"); - expect(context).not.toContain("first response"); - expect(context).not.toContain(projectRoot); - expect(context).not.toContain("private-workspace-key"); - expect(context).not.toContain("localRootRef"); - expect(context.length).toBeLessThan(16_384); - }); -}); - -describe("ProjectSessionService", () => { - it("creates through the ordinary SessionManager path with no trusted override", async () => { - const { - service, - create, - lifecycleEvents, - legacyRegistration, - legacyEvents, - } = fixture(); - - const result = await service.open(projectId, { - mode: "fresh", - harness: "claude-code", - theme: "dark", - }); - - expect(result).toMatchObject({ - resolution: "created", - session: { - id: "new-1", - agentMapIdentity: { - projectId, - sessionId: "new-1", - userId: "user-1", - }, - }, - }); - expect(result.session.planning).toBeUndefined(); - expect(create).toHaveBeenCalledWith({ - cwd: projectRoot, - harness: "claude-code", - theme: "dark", - }); - expect(create.mock.calls[0]).toHaveLength(1); - expect(legacyRegistration).not.toHaveBeenCalled(); - expect(legacyEvents).not.toHaveBeenCalled(); - expect(lifecycleEvents).toEqual([ - { - name: "project_session.created", - projectId, - sessionId: "new-1", - resolution: "created", - }, - ]); - }); - - it("uses the same deterministic outer launch root for the rolling alias", async () => { - const innerRoot = `${projectRoot}/packages/app`; - const { service, create } = fixture({ - initialProject: { - ...project, - rootBindings: [ - { - id: "root_00000000-0000-4000-8000-000000000099", - repositoryId: "repo-inner", - localRootRef: innerRoot, - status: "active", - }, - ...project.rootBindings, - ], - }, - }); - - await service.open(projectId, { mode: "fresh" }); - - expect(create).toHaveBeenCalledWith({ - cwd: projectRoot, - harness: "codex", - }); - }); - - it("fails closed without restamping and stops the exact unclaimed session it created", async () => { - const { service, created, kill } = fixture({ - stampCreatedIdentity: false, - }); - - await expect( - service.open(projectId, { mode: "fresh" }), - ).rejects.toMatchObject({ code: "forbidden" }); - expect(created).toHaveLength(1); - expect(created[0]!.agentMapIdentity).toBeUndefined(); - expect(created[0]!.planning).toBeUndefined(); - expect(kill).toHaveBeenCalledOnce(); - expect(kill).toHaveBeenCalledWith("new-1"); - }); - - it("serializes concurrent resume-or-create calls without duplicate creation", async () => { - const { service, create } = fixture(); - - const [first, second] = await Promise.all([ - service.open(projectId, { mode: "resume-or-create" }), - service.open(projectId, { mode: "resume-or-create" }), - ]); - - expect(create).toHaveBeenCalledTimes(1); - expect(first).toMatchObject({ resolution: "created" }); - expect(second).toMatchObject({ - resolution: "live", - session: { id: first.session.id }, - }); - }); - - it("returns the most recently active live ordinary session, including a descendant cwd", async () => { - const older = session("older", { - cwd: `${projectRoot}/packages/older`, - lastActiveAt: "2026-09-01T01:00:00.000Z", - }); - const latest = session("latest", { - cwd: `${projectRoot}/packages/latest`, - lastActiveAt: "2026-09-01T02:00:00.000Z", - }); - const { service, create, resume } = fixture({ - existing: [older, latest], - }); - - await expect( - service.open(projectId, { mode: "resume-or-create" }), - ).resolves.toMatchObject({ - resolution: "live", - session: { id: "latest" }, - }); - expect(create).not.toHaveBeenCalled(); - expect(resume).not.toHaveBeenCalled(); - }); - - it("never adopts or restamps a cwd-only manual or planner-era session", async () => { - const manual = session("manual", { agentMapIdentity: undefined }); - const legacy = session("legacy", { - agentMapIdentity: undefined, - planning: { - identity: { - projectId, - sessionId: "legacy", - userId: "user-1", - role: "map-planner", - }, - greeting: { status: "delivered", messageId: "message-1" }, - queuedInputIds: [], - }, - }); - const before = structuredClone([manual, legacy]); - const { service, create, resume, kill } = fixture({ - existing: [manual, legacy], - }); - - const result = await service.open(projectId, { - mode: "resume-or-create", - }); - - expect(result).toMatchObject({ resolution: "created" }); - expect(create).toHaveBeenCalledTimes(1); - expect(resume).not.toHaveBeenCalled(); - expect(kill).not.toHaveBeenCalled(); - expect([manual, legacy]).toEqual(before); - }); - - it("resumes an exited session under the same harness ID without trusted metadata or rehydration", async () => { - const prior = session("resume-me", { - status: "exited", - agentSessionId: "provider-session", - cwd: `${projectRoot}/packages/research`, - title: "User title", - boundWorkflowPath: `${projectRoot}/agents/research`, - rehydratedFrom: "older-session", - }); - const { service, create, resume, kill } = fixture({ existing: [prior] }); - - const result = await service.open(projectId, { - mode: "resume-or-create", - }); - - expect(result).toEqual({ session: prior, resolution: "resumed" }); - expect(result.session).toMatchObject({ - id: "resume-me", - agentSessionId: "provider-session", - cwd: `${projectRoot}/packages/research`, - title: "User title", - boundWorkflowPath: `${projectRoot}/agents/research`, - rehydratedFrom: "older-session", - }); - expect(resume).toHaveBeenCalledWith("resume-me"); - expect(resume.mock.calls[0]).toHaveLength(1); - expect(create).not.toHaveBeenCalled(); - expect(kill).not.toHaveBeenCalled(); - }); - - it("surfaces resume failure without creating a copied session", async () => { - const prior = session("not-resumable", { - status: "exited", - agentSessionId: "provider-session", - }); - const failure = new Error("provider cannot resume exact session"); - const { service, create, resume, kill } = fixture({ - existing: [prior], - resumeImpl: async () => { - throw failure; - }, - }); - - await expect( - service.open(projectId, { mode: "resume-or-create" }), - ).rejects.toBe(failure); - expect(resume).toHaveBeenCalledWith(prior.id); - expect(create).not.toHaveBeenCalled(); - expect(kill).not.toHaveBeenCalled(); - }); - - it("deduplicates repeated projections of the same persisted session ID", async () => { - const first = session("duplicate", { - status: "exited", - agentSessionId: "provider-session", - }); - const repeated = structuredClone(first); - const failure = new Error("not resumable"); - const { service, resume, create } = fixture({ - existing: [first, repeated], - resumeImpl: async () => { - throw failure; - }, - }); - - await expect( - service.open(projectId, { mode: "resume-or-create" }), - ).rejects.toBe(failure); - expect(resume).toHaveBeenCalledTimes(1); - expect(create).not.toHaveBeenCalled(); - }); - - it("rejects a resume adapter returning a different harness ID without killing either session", async () => { - const prior = session("expected", { - status: "exited", - agentSessionId: "provider-session", - }); - const replacement = session("replacement"); - const { service, create, kill } = fixture({ - existing: [prior], - resumeImpl: async () => replacement, - }); - - await expect( - service.open(projectId, { mode: "resume-or-create" }), - ).rejects.toMatchObject({ code: "forbidden" }); - expect(create).not.toHaveBeenCalled(); - expect(kill).not.toHaveBeenCalled(); - }); - - it("revalidates scope after resume, stops that exact process, and never starts a second candidate", async () => { - const newest = session("newest", { - status: "exited", - agentSessionId: "provider-newest", - lastActiveAt: "2026-09-01T02:00:00.000Z", - }); - const older = session("older", { - status: "exited", - agentSessionId: "provider-older", - lastActiveAt: "2026-09-01T01:00:00.000Z", - }); - const setup = fixture({ - existing: [older, newest], - resumeImpl: async (id) => { - setup.setProject({ - ...project, - rootBindings: project.rootBindings.map((binding) => ({ - ...binding, - localRootRef: "/Users/private/moved-during-resume", - })), - }); - const resumed = id === newest.id ? newest : older; - resumed.status = "running"; - return resumed; - }, - }); - - await expect( - setup.service.open(projectId, { mode: "resume-or-create" }), - ).rejects.toMatchObject({ code: "forbidden" }); - expect(setup.resume).toHaveBeenCalledTimes(1); - expect(setup.resume).toHaveBeenCalledWith(newest.id); - expect(setup.create).not.toHaveBeenCalled(); - expect(setup.kill).toHaveBeenCalledOnce(); - expect(setup.kill).toHaveBeenCalledWith(newest.id); - }); - - it("stops the exact resumed process when the trusted principal changes during resume", async () => { - const prior = session("prior", { - status: "exited", - agentSessionId: "provider-prior", - }); - const setup = fixture({ - existing: [prior], - resumeImpl: async () => { - prior.status = "running"; - setup.setUserId("user-2"); - return prior; - }, - }); - - await expect( - setup.service.open(projectId, { mode: "resume-or-create" }), - ).rejects.toMatchObject({ code: "forbidden" }); - expect(setup.resume).toHaveBeenCalledOnce(); - expect(setup.kill).toHaveBeenCalledOnce(); - expect(setup.kill).toHaveBeenCalledWith(prior.id); - expect(setup.create).not.toHaveBeenCalled(); - }); - - it("does not copy a project-owned session whose cwd is outside current bindings", async () => { - const stale = session("stale-root", { - cwd: "/Users/private/old-project-root", - status: "exited", - agentSessionId: "provider-session", - }); - const { service, create, resume, kill } = fixture({ existing: [stale] }); - - await expect( - service.open(projectId, { mode: "resume-or-create" }), - ).rejects.toMatchObject({ code: "forbidden" }); - expect(create).not.toHaveBeenCalled(); - expect(resume).not.toHaveBeenCalled(); - expect(kill).not.toHaveBeenCalled(); - }); - - it("re-resolves current scope for every requireOwned call", async () => { - const owned = session("owned", { cwd: `${projectRoot}/packages/a` }); - const { service, setProject } = fixture({ existing: [owned] }); - - await expect(service.requireOwned(projectId, owned.id)).resolves.toBe( - owned, - ); - setProject({ - ...project, - rootBindings: project.rootBindings.map((binding) => ({ - ...binding, - localRootRef: "/Users/private/moved-project", - })), - }); - await expect( - service.requireOwned(projectId, owned.id), - ).rejects.toMatchObject({ code: "forbidden" }); - }); - - it("rejects malformed, foreign-project, and foreign-user principals", async () => { - const malformed = session("malformed", { - agentMapIdentity: { - projectId, - sessionId: "different-id", - userId: "user-1", - }, - }); - const foreignProject = session("foreign-project", { - agentMapIdentity: { - projectId: "project_foreign", - sessionId: "foreign-project", - userId: "user-1", - }, - }); - const foreignUser = session("foreign-user", { - agentMapIdentity: { - projectId, - sessionId: "foreign-user", - userId: "user-2", - }, - }); - const { service } = fixture({ - existing: [malformed, foreignProject, foreignUser], - }); - - await expect( - service.requireOwned(projectId, malformed.id), - ).rejects.toMatchObject({ code: "forbidden" }); - await expect( - service.requireOwned(projectId, foreignProject.id), - ).rejects.toMatchObject({ code: "forbidden" }); - await expect( - service.requireOwned(projectId, foreignUser.id), - ).rejects.toMatchObject({ code: "forbidden" }); - }); - - it("revalidates the principal after an awaited create and stops only that stale-principal session", async () => { - let release!: () => void; - const gate = new Promise((resolve) => { - release = resolve; - }); - let harnessSession!: HarnessSession; - const setup = fixture({ - createImpl: async (request, id) => { - await gate; - harnessSession = session(id, { - cwd: request.cwd, - agentMapIdentity: { - projectId, - sessionId: id, - userId: "user-1", - }, - }); - return harnessSession; - }, - }); - const opening = setup.service.open(projectId, { mode: "fresh" }); - - await vi.waitFor(() => expect(setup.create).toHaveBeenCalledTimes(1)); - setup.setUserId("user-2"); - release(); - - await expect(opening).rejects.toMatchObject({ code: "forbidden" }); - expect(harnessSession.agentMapIdentity?.userId).toBe("user-1"); - expect(setup.kill).toHaveBeenCalledOnce(); - expect(setup.kill).toHaveBeenCalledWith(harnessSession.id); - }); - - it("revalidates identity and project bindings after awaited creation and stops the created session", async () => { - const setup = fixture({ - createImpl: async (request, id) => { - setup.setProject({ - ...project, - rootBindings: project.rootBindings.map((binding) => ({ - ...binding, - localRootRef: "/Users/private/moved-during-create", - })), - }); - return session(id, { cwd: request.cwd }); - }, - }); - - await expect( - setup.service.open(projectId, { mode: "fresh" }), - ).rejects.toMatchObject({ code: "forbidden" }); - expect(setup.created).toHaveLength(1); - expect(setup.kill).toHaveBeenCalledOnce(); - expect(setup.kill).toHaveBeenCalledWith(setup.created[0]!.id); - }); - - it("fails with bounded errors for missing projects, roots, and sessions", async () => { - const missingProject = fixture({ initialProject: null }); - await expect( - missingProject.service.open(projectId, { mode: "fresh" }), - ).rejects.toMatchObject({ code: "project_not_found" }); - - const missingRoot = fixture({ - initialProject: { - ...project, - rootBindings: project.rootBindings.map((binding) => ({ - ...binding, - status: "missing", - })), - }, - }); - await expect( - missingRoot.service.open(projectId, { mode: "fresh" }), - ).rejects.toMatchObject({ code: "project_launch_unavailable" }); - - const ordinary = fixture(); - await expect( - ordinary.service.requireOwned(projectId, "missing"), - ).rejects.toMatchObject({ code: "session_not_found" }); - }); - - it("retains the deprecated constructor/API shape without reading legacy stores", async () => { - const readRecord = vi.fn(async () => null as SessionRecord | null); - const readWorkspace = vi.fn(async () => workspace); - const manager = { - create: vi.fn(async (request: CreateSessionRequest) => - session("created", { cwd: request.cwd }), - ), - resume: vi.fn(), - list: () => [], - isLive: () => false, - get: () => undefined, - } as unknown as SessionManager; - const legacy = new PlanningSessionService({ - catalog: { - resolveIdentity: async () => project, - } as unknown as StudioProjectCatalog, - workspaceStore: { - readOrCreate: readWorkspace, - } as unknown as AgentMapWorkspaceStore, - sessionManager: manager, - readRecord, - userId: "user-1", - machineId: "machine-1", - defaultHarness: "codex", - }); - - await expect( - legacy.open(projectId, { mode: "fresh" }), - ).resolves.toMatchObject({ resolution: "created" }); - expect(readRecord).not.toHaveBeenCalled(); - expect(readWorkspace).not.toHaveBeenCalled(); - }); -}); diff --git a/packages/harness/src/core/planning-session.ts b/packages/harness/src/core/planning-session.ts deleted file mode 100644 index e48d93e67..000000000 --- a/packages/harness/src/core/planning-session.ts +++ /dev/null @@ -1,570 +0,0 @@ -import type { - AgentMapWorkspaceState, - PlannerLifecycleEvent, - PlannerSessionRequest, - PlannerSessionResponse, - ProjectAgentSession, - StudioProjectId, -} from "../shared/agent-map.js"; -import type { - CreateSessionRequest, - HarnessSession, - SessionRecord, -} from "../shared/types.js"; -import type { AgentMapWorkspaceStore } from "./agent-map-workspace-store.js"; -import { preferredProjectRoot } from "../shared/project-roots.js"; -import { isWithinDir } from "../shared/paths.js"; -import { canonicalGraphPath } from "./canonical-graph-path.js"; -import type { PlannerRegistrationMode } from "./planner-greeting.js"; -import type { SessionManager } from "./session-manager.js"; -import type { - StudioProjectCatalog, - StudioProjectIdentity, -} from "./studio-project-catalog.js"; - -export interface FocusedProjectContextDetails { - confirmedRevision?: { - digest?: string | null; - summaries?: readonly string[]; - } | null; - activeProposal?: { - status?: string | null; - summary?: string | null; - } | null; - projectBuildPlan?: { - status?: string | null; - summary?: string | null; - } | null; - warnings?: readonly string[]; -} - -/** - * @deprecated Compatibility name for callers compiled against the E1 planner - * service. Focused data is context only and never selects a role or authority. - */ -export type PlannerFocusedContextDetails = FocusedProjectContextDetails; - -export interface ProjectSessionLifecycleEvent { - name: "project_session.created" | "project_session.resumed"; - projectId: StudioProjectId; - sessionId: string; - resolution: Exclude; -} - -export interface ProjectSessionServiceOptions { - catalog: StudioProjectCatalog; - /** @deprecated Retained for the bounded planner-route compatibility API. */ - workspaceStore?: AgentMapWorkspaceStore; - sessionManager: SessionManager; - /** @deprecated Rehydration is intentionally unsupported by this service. */ - readRecord?: (id: string) => Promise; - userId: string | null; - /** Live authenticated identity. When omitted, `userId` remains the static - * principal for tests/embedded callers. */ - currentUserId?: () => string | null; - machineId: string; - defaultHarness: CreateSessionRequest["harness"]; - /** @deprecated Focused context is composed by the ordinary session path. */ - readFocusedContext?: ( - projectId: StudioProjectId, - workspace: AgentMapWorkspaceState, - ) => Promise; - /** - * @deprecated Project bootstrap registration is owned by SessionManager's - * ordinary create/resume preparation path. This callback is retained only - * so rolling server code remains source-compatible. - */ - onPlannerSession?: ( - session: HarnessSession, - context: { emptyProject: boolean; mode: PlannerRegistrationMode }, - ) => Promise | void; - onProjectSessionEvent?: ( - event: ProjectSessionLifecycleEvent, - ) => Promise | void; - /** - * @deprecated Planner-named telemetry is no longer emitted. Remove this - * rolling-compatibility option with the planner HTTP aliases in SAP-3152. - */ - onEvent?: (event: PlannerLifecycleEvent) => Promise | void; -} - -/** @deprecated Use ProjectSessionServiceOptions. */ -export type PlanningSessionServiceOptions = ProjectSessionServiceOptions; - -export class ProjectSessionError extends Error { - constructor( - readonly code: - | "project_not_found" - | "project_launch_unavailable" - | "session_not_found" - | "forbidden", - ) { - super(code.replace(/_/g, " ")); - this.name = "ProjectSessionError"; - } -} - -export function localProjectPrincipal( - userId: string | null, - machineId: string, -): string { - return userId ?? `local:${machineId}`; -} - -/** @deprecated Use localProjectPrincipal. */ -export const localPlanningPrincipal = localProjectPrincipal; - -function launchRoot(project: StudioProjectIdentity): string { - const root = preferredProjectRoot( - project.rootBindings - .filter((entry) => entry.status === "active") - .map((entry) => entry.localRootRef), - ); - if (!root) throw new ProjectSessionError("project_launch_unavailable"); - return root; -} - -function isWithinRoot(root: string, candidate: string): boolean { - if (root.trim() === "" || candidate.trim() === "") return false; - try { - return isWithinDir(canonicalGraphPath(root), canonicalGraphPath(candidate)); - } catch { - return false; - } -} - -/** - * Whether a session cwd is equal to or descends from a current active project - * root. Durable project identity remains the authority boundary; containment - * is an additional server-side launch/resume safety check. - */ -export function isWithinCurrentProject( - project: StudioProjectIdentity, - cwd: string, -): boolean { - return project.rootBindings.some( - (binding) => - binding.status === "active" && isWithinRoot(binding.localRootRef, cwd), - ); -} - -/** @deprecated Use isWithinCurrentProject. */ -export const isCurrentProjectRoot = isWithinCurrentProject; - -function samePrincipal( - identity: ProjectAgentSession | null | undefined, - expected: ProjectAgentSession, -): boolean { - return Boolean( - identity && - identity.projectId === expected.projectId && - identity.userId === expected.userId && - identity.sessionId === expected.sessionId, - ); -} - -export async function isProjectSessionDispatchAuthorized(input: { - session: HarnessSession; - currentPrincipal: () => string; - resolveProject: ( - projectId: StudioProjectId, - ) => Promise; -}): Promise { - const identity = input.session.agentMapIdentity; - if (!identity || identity.sessionId !== input.session.id) return false; - const expected: ProjectAgentSession = { - projectId: identity.projectId, - sessionId: identity.sessionId, - userId: identity.userId, - }; - if (input.currentPrincipal() !== expected.userId) return false; - let project: StudioProjectIdentity | null; - try { - project = await input.resolveProject(expected.projectId); - } catch { - return false; - } - return Boolean( - project && - input.currentPrincipal() === expected.userId && - input.session.id === expected.sessionId && - samePrincipal(input.session.agentMapIdentity, expected) && - isWithinCurrentProject(project, input.session.cwd), - ); -} - -/** @deprecated Use isProjectSessionDispatchAuthorized. */ -export const isPlannerDispatchAuthorized = isProjectSessionDispatchAuthorized; - -export interface FocusedProjectContextInput { - project: StudioProjectIdentity; - workspace: AgentMapWorkspaceState; - sessionId: string; - userId: string; - /** @deprecated Ignored. Bootstrap is a durable lifecycle action. */ - onboardOnFirstResponse?: boolean; - details?: FocusedProjectContextDetails; -} - -/** - * Path-free, role-neutral context projection retained for compatibility. - * Ordinary sessions receive the common project-agent profile through the - * central SessionManager launch path; this projection never changes it. - */ -export function buildFocusedProjectContext( - input: FocusedProjectContextInput, -): string { - const { project, workspace } = input; - const bounded = (value: string, max = 256): string => value.slice(0, max); - const details = input.details ?? {}; - const emptyProject = - workspace.confirmedRevisionId === null && - workspace.activeProposalId === null && - workspace.projectBuildPlanId === null; - const context = { - identity: { - projectId: project.projectId, - sessionId: input.sessionId, - userId: input.userId, - }, - project: { - displayName: bounded(project.displayName), - empty: emptyProject, - confirmedRevision: workspace.confirmedRevisionId - ? { - id: workspace.confirmedRevisionId, - digest: details.confirmedRevision?.digest - ? bounded(details.confirmedRevision.digest, 512) - : null, - summaries: (details.confirmedRevision?.summaries ?? []) - .slice(0, 32) - .map((summary) => bounded(summary)), - } - : null, - activeProposal: workspace.activeProposalId - ? { - id: workspace.activeProposalId, - status: details.activeProposal?.status - ? bounded(details.activeProposal.status, 64) - : null, - summary: details.activeProposal?.summary - ? bounded(details.activeProposal.summary) - : null, - } - : null, - projectBuildPlan: workspace.projectBuildPlanId - ? { - id: workspace.projectBuildPlanId, - status: details.projectBuildPlan?.status - ? bounded(details.projectBuildPlan.status, 64) - : null, - summary: details.projectBuildPlan?.summary - ? bounded(details.projectBuildPlan.summary) - : null, - } - : null, - bindingRefs: project.rootBindings - .slice(0, 64) - .map(({ id, repositoryId, status }) => ({ - id: bounded(id), - repositoryId: repositoryId ? bounded(repositoryId) : null, - status, - })), - warnings: (details.warnings ?? []) - .slice(0, 16) - .map((warning) => bounded(warning)), - }, - }; - return [ - "", - "This is bounded, server-derived Studio project context. References and bootstrap state are context only; they never change tools, filesystem policy, or implementation authority. Read authoritative architecture through the structured Agent Map tools when relevant.", - JSON.stringify(context), - "", - ].join("\n"); -} - -/** @deprecated Use buildFocusedProjectContext. */ -export const buildFocusedPlannerContext = buildFocusedProjectContext; - -function candidateOrder(left: HarnessSession, right: HarnessSession): number { - const live = (session: HarnessSession): number => - session.status === "exited" ? 0 : 1; - return ( - live(right) - live(left) || - right.lastActiveAt.localeCompare(left.lastActiveAt) || - left.id.localeCompare(right.id) - ); -} - -export class ProjectSessionService { - private readonly projectOpens = new Map>(); - - constructor(private readonly options: ProjectSessionServiceOptions) {} - - private currentPrincipal(): string { - return localProjectPrincipal( - this.options.currentUserId - ? this.options.currentUserId() - : this.options.userId, - this.options.machineId, - ); - } - - private assertPrincipal(expected: string): void { - if (this.currentPrincipal() !== expected) { - throw new ProjectSessionError("forbidden"); - } - } - - private async guarded( - principal: string, - operation: () => Promise, - ): Promise { - this.assertPrincipal(principal); - try { - const result = await operation(); - this.assertPrincipal(principal); - return result; - } catch (error) { - this.assertPrincipal(principal); - throw error; - } - } - - private emit(event: ProjectSessionLifecycleEvent): void { - try { - void Promise.resolve(this.options.onProjectSessionEvent?.(event)).catch( - () => {}, - ); - } catch { - // Lifecycle telemetry is best effort and content-free. - } - } - - owns( - session: HarnessSession, - projectId: StudioProjectId, - principal = this.currentPrincipal(), - ): boolean { - const identity = session.agentMapIdentity; - return Boolean( - identity && - identity.sessionId === session.id && - identity.projectId === projectId && - identity.userId === principal, - ); - } - - private async project( - projectId: StudioProjectId, - principal: string, - ): Promise { - const project = await this.guarded(principal, () => - this.options.catalog.resolveIdentity(projectId), - ); - if (!project) throw new ProjectSessionError("project_not_found"); - return project; - } - - private async assertRunnable( - projectId: StudioProjectId, - session: HarnessSession, - principal: string, - ): Promise { - this.assertPrincipal(principal); - if (!this.owns(session, projectId, principal)) { - throw new ProjectSessionError("forbidden"); - } - const current = await this.project(projectId, principal); - this.assertPrincipal(principal); - if ( - !this.owns(session, projectId, principal) || - !isWithinCurrentProject(current, session.cwd) - ) { - throw new ProjectSessionError("forbidden"); - } - } - - private async create( - projectId: StudioProjectId, - request: PlannerSessionRequest, - principal: string, - ): Promise { - const project = await this.project(projectId, principal); - let session: HarnessSession | undefined; - try { - this.assertPrincipal(principal); - session = await this.options.sessionManager.create({ - cwd: launchRoot(project), - harness: request.harness ?? this.options.defaultHarness, - ...(request.theme ? { theme: request.theme } : {}), - }); - this.assertPrincipal(principal); - // The central ordinary-session path derives the neutral identity. This - // compatibility service never stamps a missing identity after the fact. - await this.assertRunnable(projectId, session, principal); - } catch (error) { - // This route owns only the session it just created. If authorization - // changes during the awaited launch, stop that exact process without - // touching pre-existing/manual project sessions. - if (session && this.options.sessionManager.isLive(session.id)) { - await this.options.sessionManager.kill(session.id).catch(() => false); - } - this.assertPrincipal(principal); - throw error; - } - this.emit({ - name: "project_session.created", - projectId, - sessionId: session.id, - resolution: "created", - }); - return session; - } - - private serializeOpen( - projectId: StudioProjectId, - operation: () => Promise, - ): Promise { - const prior = this.projectOpens.get(projectId) ?? Promise.resolve(); - const next = prior.catch(() => {}).then(operation); - this.projectOpens.set(projectId, next); - const cleanup = (): void => { - if (this.projectOpens.get(projectId) === next) { - this.projectOpens.delete(projectId); - } - }; - void next.then(cleanup, cleanup); - return next; - } - - open( - projectId: StudioProjectId, - request: PlannerSessionRequest, - ): Promise { - return this.serializeOpen(projectId, () => - this.openOnce(projectId, request), - ); - } - - private async openOnce( - projectId: StudioProjectId, - request: PlannerSessionRequest, - ): Promise { - const principal = this.currentPrincipal(); - const project = await this.project(projectId, principal); - // Preserve the bounded compatibility endpoint's launch error while all - // actual creation remains on SessionManager's ordinary path. - launchRoot(project); - if (request.mode === "fresh") { - return { - session: await this.create(projectId, request, principal), - resolution: "created", - }; - } - - this.assertPrincipal(principal); - const seen = new Set(); - const candidates = this.options.sessionManager - .list() - .filter((session) => { - if (!this.owns(session, projectId, principal) || seen.has(session.id)) { - return false; - } - seen.add(session.id); - return true; - }) - .sort(candidateOrder); - let resumeFailure: unknown; - let inCurrentScope = 0; - for (const candidate of candidates) { - try { - await this.assertRunnable(projectId, candidate, principal); - } catch (error) { - if ( - error instanceof ProjectSessionError && - error.code === "forbidden" && - this.currentPrincipal() === principal - ) { - continue; - } - throw error; - } - inCurrentScope += 1; - if (this.options.sessionManager.isLive(candidate.id)) { - this.emit({ - name: "project_session.resumed", - projectId, - sessionId: candidate.id, - resolution: "live", - }); - return { session: candidate, resolution: "live" }; - } - - let resumed: HarnessSession; - try { - this.assertPrincipal(principal); - resumed = await this.options.sessionManager.resume(candidate.id); - } catch (error) { - this.assertPrincipal(principal); - resumeFailure ??= error; - continue; - } - // Once resume succeeds, never try another record: doing so could leave - // two live processes if this post-await authorization check fails. - try { - this.assertPrincipal(principal); - if (resumed.id !== candidate.id) { - throw new ProjectSessionError("forbidden"); - } - await this.assertRunnable(projectId, resumed, principal); - } catch (error) { - if (this.options.sessionManager.isLive(resumed.id)) { - await this.options.sessionManager.kill(resumed.id).catch(() => false); - } - this.assertPrincipal(principal); - throw error; - } - this.emit({ - name: "project_session.resumed", - projectId, - sessionId: resumed.id, - resolution: "resumed", - }); - return { session: resumed, resolution: "resumed" }; - } - - // An existing project-owned record is never copied into a new ID. Surface - // its exact resume/scope failure and leave the record untouched. - if (resumeFailure !== undefined) throw resumeFailure; - if (candidates.length > 0 && inCurrentScope === 0) { - throw new ProjectSessionError("forbidden"); - } - - return { - session: await this.create(projectId, request, principal), - resolution: "created", - }; - } - - async requireOwned( - projectId: StudioProjectId, - sessionId: string, - ): Promise { - const principal = this.currentPrincipal(); - const session = this.options.sessionManager.get(sessionId); - if (!session) throw new ProjectSessionError("session_not_found"); - await this.assertRunnable(projectId, session, principal); - return session; - } -} - -/** - * @deprecated Rolling compatibility alias for the bounded planner HTTP routes. - * Remove the alias and those routes together in SAP-3152. - */ -export const PlanningSessionService = ProjectSessionService; -/** @deprecated Use ProjectSessionService. */ -export type PlanningSessionService = ProjectSessionService; -/** @deprecated Use ProjectSessionError. */ -export const PlanningSessionError = ProjectSessionError; diff --git a/packages/harness/src/core/planner-greeting.test.ts b/packages/harness/src/core/project-bootstrap.test.ts similarity index 99% rename from packages/harness/src/core/planner-greeting.test.ts rename to packages/harness/src/core/project-bootstrap.test.ts index f601f2b23..c861fe5c8 100644 --- a/packages/harness/src/core/planner-greeting.test.ts +++ b/packages/harness/src/core/project-bootstrap.test.ts @@ -29,7 +29,7 @@ import { ProjectBootstrapRetryUnavailableError, projectBootstrapPrompt, type ProjectBootstrapCoordinatorOptions, -} from "./planner-greeting.js"; +} from "./project-bootstrap.js"; const activeCoordinators = new Set(); const TEST_RUNTIME_EPOCH = "runtime-epoch-test"; @@ -1180,7 +1180,7 @@ describe("ProjectBootstrapCoordinator", () => { const coordinator = new ProjectBootstrapCoordinator({ root, - legacyRoot, + legacyStateRoot: legacyRoot, sessionManager: manager, }); await coordinator.register(session, { emptyProject: true, mode: "boot" }); @@ -4266,6 +4266,8 @@ describe("ProjectBootstrapCoordinator", () => { prompt: submitted[0]!.text, path: "/private/source.ts", connectorPayload: "secret connector body", + credential: "sk-cutover-secret", + compiledBrief: "private focused brief body", }), ); expect(local.payload.prompt).toBe(submitted[0]!.text); @@ -4280,6 +4282,8 @@ describe("ProjectBootstrapCoordinator", () => { }); expect(JSON.stringify(remotePrompt)).not.toContain("private/source"); expect(JSON.stringify(remotePrompt)).not.toContain("connector body"); + expect(JSON.stringify(remotePrompt)).not.toContain("sk-cutover-secret"); + expect(JSON.stringify(remotePrompt)).not.toContain("focused brief body"); const remoteTurn = redactor.redactForTelemetry( analyticsEvent(session.id, "turn.completed", { diff --git a/packages/harness/src/core/planner-greeting.ts b/packages/harness/src/core/project-bootstrap.ts similarity index 99% rename from packages/harness/src/core/planner-greeting.ts rename to packages/harness/src/core/project-bootstrap.ts index b955ceac1..df2e44a9b 100644 --- a/packages/harness/src/core/planner-greeting.ts +++ b/packages/harness/src/core/project-bootstrap.ts @@ -143,7 +143,7 @@ export interface ProjectBootstrapRegistrationContext { export interface ProjectBootstrapCoordinatorOptions { root: string; /** @deprecated Read-only migration source for pre-SAP-3148 queue files. */ - legacyRoot?: string; + legacyStateRoot?: string; sessionManager: SessionManager; now?: () => string; generateId?: () => string; @@ -760,7 +760,7 @@ function telemetryPayload(event: AnalyticsEvent): Record { export class ProjectBootstrapCoordinator { private readonly root: string; - private readonly legacyRoot: string | null; + private readonly legacyStateRoot: string | null; private readonly now: () => string; private readonly generateId: () => string; private readonly readinessTimeoutMs: number; @@ -848,8 +848,8 @@ export class ProjectBootstrapCoordinator { constructor(private readonly options: ProjectBootstrapCoordinatorOptions) { this.root = path.resolve(options.root); - this.legacyRoot = options.legacyRoot - ? path.resolve(options.legacyRoot) + this.legacyStateRoot = options.legacyStateRoot + ? path.resolve(options.legacyStateRoot) : null; this.now = options.now ?? (() => new Date().toISOString()); this.generateId = options.generateId ?? randomUUID; @@ -872,9 +872,9 @@ export class ProjectBootstrapCoordinator { } private legacyFile(sessionId: string, name: string): string | null { - if (!this.legacyRoot) return null; - const directory = path.resolve(this.legacyRoot, sessionId); - if (!directory.startsWith(`${this.legacyRoot}${path.sep}`)) { + if (!this.legacyStateRoot) return null; + const directory = path.resolve(this.legacyStateRoot, sessionId); + if (!directory.startsWith(`${this.legacyStateRoot}${path.sep}`)) { throw new Error("invalid legacy project bootstrap storage identity"); } return path.join(directory, name); @@ -4536,24 +4536,3 @@ export class ProjectBootstrapCoordinator { }); } } - -/** - * @deprecated Rolling compatibility aliases for persisted clients and tests. - * Remove with the bounded planner HTTP aliases in SAP-3152. - */ -export { - ProjectBootstrapCoordinator as PlannerGreetingCoordinator, - ProjectBootstrapDispatchForbiddenError as PlannerDispatchForbiddenError, - ProjectBootstrapRetryUnavailableError as PlannerGreetingRetryUnavailableError, -}; -export type PlannerRegistrationMode = ProjectBootstrapRegistrationMode; - -/** @deprecated SAP-3152 removes the planner-named compatibility export. */ -export function plannerGreetingPrompt( - _emptyProject?: boolean, - retryOrdinal: number | string = 0, -): string { - return typeof retryOrdinal === "string" - ? projectBootstrapPrompt(0, retryOrdinal) - : projectBootstrapPrompt(retryOrdinal); -} diff --git a/packages/harness/src/core/project-session-legacy-migration.ts b/packages/harness/src/core/project-session-legacy-migration.ts new file mode 100644 index 000000000..a09e112d3 --- /dev/null +++ b/packages/harness/src/core/project-session-legacy-migration.ts @@ -0,0 +1,208 @@ +import { join } from "node:path"; + +import type { + ProjectAgentSession, + ProjectBootstrapErrorCode, + ProjectBootstrapMetadata, +} from "../shared/agent-map.js"; +import type { HarnessSession } from "../shared/types.js"; + +export type PersistedIdentityMigration = { + identity?: ProjectAgentSession; + bootstrap?: ProjectBootstrapMetadata; + outcome: "unchanged" | "migrated" | "rejected"; +}; + +const LEGACY_METADATA_KEY = "planning"; + +/** + * Recognizes the infrastructure marker written into durable prompt events by + * released pre-unification builds. Keep the retired record key isolated here: + * it is decoder-only compatibility and never participates in live authority. + */ +export function isPreUnifiedInfrastructureBootstrapPayload( + payload: Record, +): boolean { + return payload["plannerOrigin"] === "infrastructure"; +} + +/** The sole filesystem location for the retired project-session bootstrap store. */ +export function legacyProjectSessionStateRoot(stateRoot: string): string { + return join(stateRoot, "agent-map", "planner-sessions"); +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function parseProjectAgentSession( + value: unknown, + expectedSessionId: string, +): ProjectAgentSession | null { + if ( + !isRecord(value) || + typeof value.projectId !== "string" || + value.projectId === "" || + typeof value.userId !== "string" || + value.userId === "" || + value.sessionId !== expectedSessionId + ) { + return null; + } + return { + projectId: value.projectId, + userId: value.userId, + sessionId: expectedSessionId, + }; +} + +function sameProjectAgent( + left: ProjectAgentSession, + right: ProjectAgentSession, +): boolean { + return ( + left.projectId === right.projectId && + left.userId === right.userId && + left.sessionId === right.sessionId + ); +} + +function parseBootstrapState( + value: unknown, +): ProjectBootstrapMetadata["bootstrap"] | null { + if (!isRecord(value) || typeof value.status !== "string") return null; + switch (value.status) { + case "pending": + return { status: "pending" }; + case "generating": + return typeof value.attemptId === "string" && value.attemptId !== "" + ? { status: "generating", attemptId: value.attemptId } + : null; + case "delivered": + return typeof value.messageId === "string" && value.messageId !== "" + ? { status: "delivered", messageId: value.messageId } + : null; + case "failed": + return typeof value.retryable === "boolean" && + typeof value.errorCode === "string" && + [ + "session_not_ready", + "session_exited", + "injection_failed", + "model_turn_failed", + "delivery_timeout", + "persistence_failed", + ].includes(value.errorCode) + ? { + status: "failed", + retryable: value.retryable, + errorCode: value.errorCode as ProjectBootstrapErrorCode, + } + : null; + case "skipped": + return value.reason === "user-proceeded" || + value.reason === "map-not-empty" + ? { status: "skipped", reason: value.reason } + : null; + default: + return null; + } +} + +/** + * Accepts the final neutral shape plus the frozen pre-cutover session shape. + * Retired role and assignment fields are discarded and never become authority. + */ +export function migratePersistedProjectIdentity( + session: HarnessSession, +): PersistedIdentityMigration { + const raw = session as unknown as Record; + const direct = parseProjectAgentSession(raw.agentMapIdentity, session.id); + const legacy = isRecord(raw[LEGACY_METADATA_KEY]) + ? raw[LEGACY_METADATA_KEY] + : null; + const priorIdentity = + legacy && isRecord(legacy.identity) + ? parseProjectAgentSession(legacy.identity, session.id) + : null; + if (raw.agentMapIdentity !== undefined && !direct) { + return { outcome: "rejected" }; + } + if (raw[LEGACY_METADATA_KEY] !== undefined && (!legacy || !priorIdentity)) { + return { identity: direct ?? undefined, outcome: "rejected" }; + } + if (direct && priorIdentity && !sameProjectAgent(direct, priorIdentity)) { + return { outcome: "rejected" }; + } + let identity = direct ?? priorIdentity ?? undefined; + + let bootstrap: ProjectBootstrapMetadata | undefined; + const current = isRecord(raw.projectBootstrap) ? raw.projectBootstrap : null; + if (raw.projectBootstrap !== undefined && !current) { + return { identity, outcome: "rejected" }; + } + if (current) { + const currentIdentity = parseProjectAgentSession( + { + projectId: current.projectId, + userId: current.userId, + sessionId: current.targetSessionId, + }, + session.id, + ); + const state = parseBootstrapState(current.bootstrap); + if ( + !currentIdentity || + !state || + !Array.isArray(current.queuedInputIds) || + !current.queuedInputIds.every((id) => typeof id === "string") || + (identity && !sameProjectAgent(identity, currentIdentity)) + ) { + return { identity, outcome: "rejected" }; + } + identity ??= currentIdentity; + bootstrap = { + projectId: currentIdentity.projectId, + userId: currentIdentity.userId, + targetSessionId: currentIdentity.sessionId, + bootstrap: state, + queuedInputIds: [...current.queuedInputIds], + }; + } else if (legacy && priorIdentity) { + const state = parseBootstrapState(legacy.greeting); + if ( + !state || + !Array.isArray(legacy.queuedInputIds) || + !legacy.queuedInputIds.every((id) => typeof id === "string") + ) { + return { identity, outcome: "rejected" }; + } + bootstrap = { + projectId: priorIdentity.projectId, + userId: priorIdentity.userId, + targetSessionId: priorIdentity.sessionId, + bootstrap: state, + queuedInputIds: [...legacy.queuedInputIds], + }; + } + + const hadLegacyIdentity = + isRecord(raw.agentMapIdentity) && + ("role" in raw.agentMapIdentity || "assignment" in raw.agentMapIdentity); + return { + ...(identity ? { identity } : {}), + ...(bootstrap ? { bootstrap } : {}), + outcome: + hadLegacyIdentity || + raw[LEGACY_METADATA_KEY] !== undefined || + (!!current && !direct) + ? "migrated" + : "unchanged", + }; +} + +export function removeLegacyProjectSessionMetadata( + session: HarnessSession, +): void { + delete (session as unknown as Record)[LEGACY_METADATA_KEY]; +} diff --git a/packages/harness/src/core/project-session.test.ts b/packages/harness/src/core/project-session.test.ts new file mode 100644 index 000000000..92a769d21 --- /dev/null +++ b/packages/harness/src/core/project-session.test.ts @@ -0,0 +1,162 @@ +import { describe, expect, it } from "vitest"; + +import type { AgentMapWorkspaceState } from "../shared/agent-map.js"; +import type { HarnessSession } from "../shared/types.js"; +import { + buildFocusedProjectContext, + isProjectSessionDispatchAuthorized, + isWithinCurrentProject, + localProjectPrincipal, +} from "./project-session.js"; +import type { StudioProjectIdentity } from "./studio-project-catalog.js"; + +const projectId = "project_00000000-0000-4000-8000-000000000001"; +const projectRoot = "/Users/private/customer-secret-project"; +const project: StudioProjectIdentity = { + projectId, + identityVersion: 1, + displayName: "Private research", + rootBindings: [{ + id: "root_00000000-0000-4000-8000-000000000001", + repositoryId: "repo-private", + localRootRef: projectRoot, + status: "active", + }], + legacyWorkspaceKeys: ["private-workspace-key"], + createdAt: "2026-09-01T00:00:00.000Z", + updatedAt: "2026-09-01T00:00:00.000Z", +}; +const workspace: AgentMapWorkspaceState = { + projectId, + schemaVersion: 1, + recordVersion: 1, + confirmedRevisionId: null, + activeProposalId: null, + projectBuildPlanId: null, + createdAt: "2026-09-01T00:00:00.000Z", + updatedAt: "2026-09-01T00:00:00.000Z", +}; + +function session(id: string): HarnessSession { + return { + id, + agentSessionId: null, + harness: "codex", + cwd: projectRoot, + title: "Ordinary session", + status: "running", + createdAt: "2026-09-01T00:00:00.000Z", + lastActiveAt: "2026-09-01T00:00:00.000Z", + exitCode: null, + boundWorkflowPath: null, + ready: false, + agentMapIdentity: { projectId, sessionId: id, userId: "user-1" }, + }; +} + +describe("role-neutral project session", () => { + it("uses the authenticated user or a stable machine-local principal", () => { + expect(localProjectPrincipal("user-1", "machine-1")).toBe("user-1"); + expect(localProjectPrincipal(null, "machine-1")).toBe("local:machine-1"); + }); + + it("accepts only active project roots and their descendants", () => { + const withMissingBinding: StudioProjectIdentity = { + ...project, + rootBindings: [ + ...project.rootBindings, + { + id: "root_00000000-0000-4000-8000-000000000002", + repositoryId: null, + localRootRef: "/Users/private/inactive", + status: "missing", + }, + ], + }; + expect(isWithinCurrentProject(project, projectRoot)).toBe(true); + expect(isWithinCurrentProject(project, `${projectRoot}/agents/research`)).toBe(true); + expect(isWithinCurrentProject(project, `${projectRoot}-old`)).toBe(false); + expect(isWithinCurrentProject(project, "/Users/private")).toBe(false); + expect( + isWithinCurrentProject( + withMissingBinding, + "/Users/private/inactive/agent", + ), + ).toBe(false); + }); + + it("normalizes Windows separators without mixing path families", () => { + const windowsProject: StudioProjectIdentity = { + ...project, + rootBindings: [ + { + ...project.rootBindings[0]!, + localRootRef: "C:\\Users\\private\\project", + }, + ], + }; + + expect( + isWithinCurrentProject( + windowsProject, + "C:/Users/private/project/agents/research", + ), + ).toBe(true); + expect( + isWithinCurrentProject( + windowsProject, + "/Users/private/project/agents/research", + ), + ).toBe(false); + }); + + it("authorizes only the exact neutral principal inside its project", async () => { + const ordinary = session("ordinary"); + await expect(isProjectSessionDispatchAuthorized({ + session: ordinary, + currentPrincipal: () => "user-1", + resolveProject: async () => project, + })).resolves.toBe(true); + await expect(isProjectSessionDispatchAuthorized({ + session: ordinary, + currentPrincipal: () => "user-2", + resolveProject: async () => project, + })).resolves.toBe(false); + }); + + it("rechecks principal and session identity after project lookup", async () => { + let userId = "user-1"; + const ordinary = session("race"); + let resolve!: (value: StudioProjectIdentity | null) => void; + const authorization = isProjectSessionDispatchAuthorized({ + session: ordinary, + currentPrincipal: () => userId, + resolveProject: () => new Promise((done) => { resolve = done; }), + }); + await Promise.resolve(); + userId = "user-2"; + ordinary.agentMapIdentity = { projectId, sessionId: ordinary.id, userId }; + resolve(project); + await expect(authorization).resolves.toBe(false); + }); + + it("builds bounded path-free context without changing authority", () => { + const context = buildFocusedProjectContext({ + project, + workspace, + sessionId: "session-1", + userId: "user-1", + details: { warnings: Array.from({ length: 40 }, (_, i) => `warning-${i}-${"w".repeat(400)}`) }, + }); + const parsed = JSON.parse(context.split("\n")[2]!) as { + identity: Record; + project: { warnings: string[] }; + }; + expect(parsed.identity).toEqual({ projectId, sessionId: "session-1", userId: "user-1" }); + expect(parsed.project.warnings).toHaveLength(16); + expect(context).not.toContain('"role"'); + expect(context).not.toContain(projectRoot); + expect(context).not.toContain("private-workspace-key"); + expect(context.length).toBeLessThan(16_384); + }); +}); diff --git a/packages/harness/src/core/project-session.ts b/packages/harness/src/core/project-session.ts new file mode 100644 index 000000000..4d4de369f --- /dev/null +++ b/packages/harness/src/core/project-session.ts @@ -0,0 +1,182 @@ +import type { + AgentMapWorkspaceState, + ProjectAgentSession, + StudioProjectId, +} from "../shared/agent-map.js"; +import type { HarnessSession } from "../shared/types.js"; +import { isWithinDir } from "../shared/paths.js"; +import { canonicalGraphPath } from "./canonical-graph-path.js"; +import type { StudioProjectIdentity } from "./studio-project-catalog.js"; + +export interface FocusedProjectContextDetails { + confirmedRevision?: { + digest?: string | null; + summaries?: readonly string[]; + } | null; + activeProposal?: { + status?: string | null; + summary?: string | null; + } | null; + projectBuildPlan?: { + status?: string | null; + summary?: string | null; + } | null; + warnings?: readonly string[]; +} + +export function localProjectPrincipal( + userId: string | null, + machineId: string, +): string { + return userId ?? `local:${machineId}`; +} + +function isWithinRoot(root: string, candidate: string): boolean { + if (root.trim() === "" || candidate.trim() === "") return false; + try { + return isWithinDir(canonicalGraphPath(root), canonicalGraphPath(candidate)); + } catch { + return false; + } +} + +/** + * Whether a session cwd is equal to or descends from a current active project + * root. Durable project identity remains the authority boundary; containment + * is an additional server-side launch/resume safety check. + */ +export function isWithinCurrentProject( + project: StudioProjectIdentity, + cwd: string, +): boolean { + return project.rootBindings.some( + (binding) => + binding.status === "active" && isWithinRoot(binding.localRootRef, cwd), + ); +} + +function samePrincipal( + identity: ProjectAgentSession | null | undefined, + expected: ProjectAgentSession, +): boolean { + return Boolean( + identity && + identity.projectId === expected.projectId && + identity.userId === expected.userId && + identity.sessionId === expected.sessionId, + ); +} + +export async function isProjectSessionDispatchAuthorized(input: { + session: HarnessSession; + currentPrincipal: () => string; + resolveProject: ( + projectId: StudioProjectId, + ) => Promise; +}): Promise { + const identity = input.session.agentMapIdentity; + if (!identity || identity.sessionId !== input.session.id) return false; + const expected: ProjectAgentSession = { + projectId: identity.projectId, + sessionId: identity.sessionId, + userId: identity.userId, + }; + if (input.currentPrincipal() !== expected.userId) return false; + let project: StudioProjectIdentity | null; + try { + project = await input.resolveProject(expected.projectId); + } catch { + return false; + } + return Boolean( + project && + input.currentPrincipal() === expected.userId && + input.session.id === expected.sessionId && + samePrincipal(input.session.agentMapIdentity, expected) && + isWithinCurrentProject(project, input.session.cwd), + ); +} + +export interface FocusedProjectContextInput { + project: StudioProjectIdentity; + workspace: AgentMapWorkspaceState; + sessionId: string; + userId: string; + details?: FocusedProjectContextDetails; +} + +/** + * Path-free, role-neutral project context. It never changes the common prompt, + * tools, filesystem policy, or implementation authority. + */ +export function buildFocusedProjectContext( + input: FocusedProjectContextInput, +): string { + const { project, workspace } = input; + const bounded = (value: string, max = 256): string => value.slice(0, max); + const details = input.details ?? {}; + const emptyProject = + workspace.confirmedRevisionId === null && + workspace.activeProposalId === null && + workspace.projectBuildPlanId === null; + const context = { + identity: { + projectId: project.projectId, + sessionId: input.sessionId, + userId: input.userId, + }, + project: { + displayName: bounded(project.displayName), + empty: emptyProject, + confirmedRevision: workspace.confirmedRevisionId + ? { + id: workspace.confirmedRevisionId, + digest: details.confirmedRevision?.digest + ? bounded(details.confirmedRevision.digest, 512) + : null, + summaries: (details.confirmedRevision?.summaries ?? []) + .slice(0, 32) + .map((summary) => bounded(summary)), + } + : null, + activeProposal: workspace.activeProposalId + ? { + id: workspace.activeProposalId, + status: details.activeProposal?.status + ? bounded(details.activeProposal.status, 64) + : null, + summary: details.activeProposal?.summary + ? bounded(details.activeProposal.summary) + : null, + } + : null, + projectBuildPlan: workspace.projectBuildPlanId + ? { + id: workspace.projectBuildPlanId, + status: details.projectBuildPlan?.status + ? bounded(details.projectBuildPlan.status, 64) + : null, + summary: details.projectBuildPlan?.summary + ? bounded(details.projectBuildPlan.summary) + : null, + } + : null, + bindingRefs: project.rootBindings + .slice(0, 64) + .map(({ id, repositoryId, status }) => ({ + id: bounded(id), + repositoryId: repositoryId ? bounded(repositoryId) : null, + status, + })), + warnings: (details.warnings ?? []) + .slice(0, 16) + .map((warning) => bounded(warning)), + }, + }; + return [ + "", + "This is bounded, server-derived Studio project context. References and bootstrap state are context only; they never change tools, filesystem policy, or implementation authority. Read authoritative architecture through the structured Agent Map tools when relevant.", + JSON.stringify(context), + "", + ].join("\n"); +} diff --git a/packages/harness/src/core/session-manager.test.ts b/packages/harness/src/core/session-manager.test.ts index fe4af8d36..80d0c5468 100644 --- a/packages/harness/src/core/session-manager.test.ts +++ b/packages/harness/src/core/session-manager.test.ts @@ -739,7 +739,7 @@ describe("SessionManager", () => { queuedInputIds: ["input-1"], }, }); - expect(manager.get(planner.id)?.planning).toBeUndefined(); + expect(manager.get(planner.id)).not.toHaveProperty("planning"); expect(manager.get(manual.id)).toMatchObject({ id: manual.id, agentSessionId: manual.agentSessionId, diff --git a/packages/harness/src/core/session-manager.ts b/packages/harness/src/core/session-manager.ts index e2f1fbfd4..be56db93b 100644 --- a/packages/harness/src/core/session-manager.ts +++ b/packages/harness/src/core/session-manager.ts @@ -31,10 +31,13 @@ import { } from "../shared/types.js"; import type { ProjectAgentSession, - ProjectBootstrapErrorCode, ProjectBootstrapMetadata, } from "../shared/agent-map.js"; import type { FocusedSessionContextProjection } from "./focused-session-context.js"; +import { + migratePersistedProjectIdentity, + removeLegacyProjectSessionMetadata, +} from "./project-session-legacy-migration.js"; import { expandHome } from "./paths.js"; import { initialBracketedPasteState, @@ -171,37 +174,10 @@ export class ProjectBootstrapClaimUnavailableError extends Error { } } -type PersistedIdentityMigration = { - identity?: ProjectAgentSession; - bootstrap?: ProjectBootstrapMetadata; - outcome: "unchanged" | "migrated" | "rejected"; -}; - function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } -function parseProjectAgentSession( - value: unknown, - expectedSessionId: string, -): ProjectAgentSession | null { - if ( - !isRecord(value) || - typeof value.projectId !== "string" || - value.projectId === "" || - typeof value.userId !== "string" || - value.userId === "" || - value.sessionId !== expectedSessionId - ) { - return null; - } - return { - projectId: value.projectId, - userId: value.userId, - sessionId: expectedSessionId, - }; -} - function sameProjectAgent( left: ProjectAgentSession, right: ProjectAgentSession, @@ -256,144 +232,6 @@ function sameSubsessionBinding( ); } -function parseBootstrapState( - value: unknown, -): ProjectBootstrapMetadata["bootstrap"] | null { - if (!isRecord(value) || typeof value.status !== "string") return null; - switch (value.status) { - case "pending": - return { status: "pending" }; - case "generating": - return typeof value.attemptId === "string" && value.attemptId !== "" - ? { status: "generating", attemptId: value.attemptId } - : null; - case "delivered": - return typeof value.messageId === "string" && value.messageId !== "" - ? { status: "delivered", messageId: value.messageId } - : null; - case "failed": - return typeof value.retryable === "boolean" && - typeof value.errorCode === "string" && - [ - "session_not_ready", - "session_exited", - "injection_failed", - "model_turn_failed", - "delivery_timeout", - "persistence_failed", - ].includes(value.errorCode) - ? { - status: "failed", - retryable: value.retryable, - errorCode: value.errorCode as ProjectBootstrapErrorCode, - } - : null; - case "skipped": - return value.reason === "user-proceeded" || - value.reason === "map-not-empty" - ? { status: "skipped", reason: value.reason } - : null; - default: - return null; - } -} - -/** - * Accept both the final neutral shape and persisted planner-era metadata. Extra - * role/assignment keys are dropped; conflicting principals are never trusted. - */ -function migratePersistedProjectIdentity( - session: HarnessSession, -): PersistedIdentityMigration { - const raw = session as HarnessSession & { - agentMapIdentity?: unknown; - planning?: unknown; - projectBootstrap?: unknown; - }; - const direct = parseProjectAgentSession(raw.agentMapIdentity, session.id); - const planning = isRecord(raw.planning) ? raw.planning : null; - const planned = - planning && isRecord(planning.identity) - ? parseProjectAgentSession(planning.identity, session.id) - : null; - // A present-but-invalid authority record is ambiguous. Never repair it from - // a second field and silently choose one principal: retain the complete - // persisted session for operator recovery and fail scope revalidation when - // somebody later tries to resume it. - if (raw.agentMapIdentity !== undefined && !direct) { - return { outcome: "rejected" }; - } - if (raw.planning !== undefined && (!planning || !planned)) { - return { identity: direct ?? undefined, outcome: "rejected" }; - } - if (direct && planned && !sameProjectAgent(direct, planned)) { - return { outcome: "rejected" }; - } - let identity = direct ?? planned ?? undefined; - - let bootstrap: ProjectBootstrapMetadata | undefined; - const current = isRecord(raw.projectBootstrap) ? raw.projectBootstrap : null; - if (raw.projectBootstrap !== undefined && !current) { - return { identity, outcome: "rejected" }; - } - if (current) { - const currentIdentity = parseProjectAgentSession( - { - projectId: current.projectId, - userId: current.userId, - sessionId: current.targetSessionId, - }, - session.id, - ); - const state = parseBootstrapState(current.bootstrap); - if ( - !currentIdentity || - !state || - !Array.isArray(current.queuedInputIds) || - !current.queuedInputIds.every((id) => typeof id === "string") || - (identity && !sameProjectAgent(identity, currentIdentity)) - ) { - return { identity, outcome: "rejected" }; - } - identity ??= currentIdentity; - bootstrap = { - projectId: currentIdentity.projectId, - userId: currentIdentity.userId, - targetSessionId: currentIdentity.sessionId, - bootstrap: state, - queuedInputIds: [...current.queuedInputIds], - }; - } else if (planning && planned) { - const state = parseBootstrapState(planning.greeting); - if ( - !state || - !Array.isArray(planning.queuedInputIds) || - !planning.queuedInputIds.every((id) => typeof id === "string") - ) { - return { identity, outcome: "rejected" }; - } - bootstrap = { - projectId: planned.projectId, - userId: planned.userId, - targetSessionId: planned.sessionId, - bootstrap: state, - queuedInputIds: [...planning.queuedInputIds], - }; - } - - const hadLegacyIdentity = - isRecord(raw.agentMapIdentity) && - ("role" in raw.agentMapIdentity || "assignment" in raw.agentMapIdentity); - return { - ...(identity ? { identity } : {}), - ...(bootstrap ? { bootstrap } : {}), - outcome: - hadLegacyIdentity || raw.planning !== undefined || (!!current && !direct) - ? "migrated" - : "unchanged", - }; -} - // node-pty is a native module. Load it lazily so a missing/broken prebuild on // an unsupported platform surfaces as a spawn-time error instead of crashing // the whole server at import time. @@ -1097,7 +935,7 @@ export class SessionManager { } // Planner-era metadata is never live authority after normalization. // Its on-disk input queue is migrated by ProjectBootstrapCoordinator. - delete session.planning; + removeLegacyProjectSessionMetadata(session); dirty = true; } if (migration.outcome !== "unchanged") { diff --git a/packages/harness/src/core/session-record.test.ts b/packages/harness/src/core/session-record.test.ts index e2ae05bde..2e3398f08 100644 --- a/packages/harness/src/core/session-record.test.ts +++ b/packages/harness/src/core/session-record.test.ts @@ -239,6 +239,31 @@ describe("foldSessionRecord", () => { ); }); + it("keeps released pre-unification bootstrap events out of the human transcript", () => { + const record = foldSessionRecord([ + event({ + type: "prompt.submitted", + ts: "2026-07-01T10:00:00.000Z", + payload: { + prompt: "private released bootstrap instruction", + ["plannerOrigin"]: "infrastructure", + }, + }), + completed("2026-07-01T10:00:01.000Z", "What would you like to build?"), + ]); + + expect(record.turnCount).toBe(0); + expect(record.turns).toHaveLength(1); + expect(record.turns[0]).toMatchObject({ + prompt: null, + promptAt: null, + assistantText: "What would you like to build?", + }); + expect(JSON.stringify(record)).not.toContain( + "private released bootstrap instruction", + ); + }); + it("a second prompt closes the open turn as incomplete rather than dropping it", () => { const record = foldSessionRecord([ prompt("2026-07-01T10:00:00.000Z", "first"), diff --git a/packages/harness/src/core/session-record.ts b/packages/harness/src/core/session-record.ts index b8fb919b3..b9c3a3f9d 100644 --- a/packages/harness/src/core/session-record.ts +++ b/packages/harness/src/core/session-record.ts @@ -55,6 +55,7 @@ import { projectDirsFor } from "./adapters/claude-code.js"; import { PAYLOAD_TRUNCATION_MARKER } from "./collector/normalizer.js"; import { readLastAssistantTurn } from "./collector/transcript.js"; import type { EventIndex, EventReader } from "./collector/store.js"; +import { isPreUnifiedInfrastructureBootstrapPayload } from "./project-session-legacy-migration.js"; function stringOrNull(value: unknown): string | null { return typeof value === "string" && value.length > 0 ? value : null; @@ -161,30 +162,28 @@ export function foldSessionRecord( if (cwd === null) cwd = stringOrNull(payload.cwd); break; - case "prompt.submitted": + case "prompt.submitted": { // A prompt arriving while a turn is open means that turn never // completed (killed mid-turn, or the user queued another prompt). // Keep it, marked incomplete — dropping it would lose real tool calls. close(null); + const infrastructureBootstrap = + payload.projectBootstrapOrigin === "infrastructure" || + isPreUnifiedInfrastructureBootstrapPayload(payload); open = { // Project bootstrap control is retained locally for diagnostics but // projected as an assistant-initiated turn: its private instruction // must never appear as a user message or inflate the human turn count. - prompt: - payload.projectBootstrapOrigin === "infrastructure" || - payload.plannerOrigin === "infrastructure" - ? null - : typeof payload.prompt === "string" - ? payload.prompt - : "", - promptAt: - payload.projectBootstrapOrigin === "infrastructure" || - payload.plannerOrigin === "infrastructure" - ? null - : event.ts, + prompt: infrastructureBootstrap + ? null + : typeof payload.prompt === "string" + ? payload.prompt + : "", + promptAt: infrastructureBootstrap ? null : event.ts, toolCalls: [], }; break; + } case "tool.call": { // No enclosing turn: the recording started mid-turn (a resume attaches diff --git a/packages/harness/src/server/agent-map-auth-wiring.test.ts b/packages/harness/src/server/agent-map-auth-wiring.test.ts index f32a580dd..303b6fedd 100644 --- a/packages/harness/src/server/agent-map-auth-wiring.test.ts +++ b/packages/harness/src/server/agent-map-auth-wiring.test.ts @@ -133,20 +133,7 @@ describe("coding-agent authorization boundary", () => { }); expect(mutation.status).toBe(401); - const forgedPlanner = await fetch( - `${baseUrl}/api/projects/forged-project/planner-sessions`, - { - method: "POST", - headers: { - "Content-Type": "application/json", - "X-Harness-Token": ingestToken, - }, - body: JSON.stringify({ mode: "fresh" }), - }, - ); - expect(forgedPlanner.status).toBe(401); - - const forgedGenericRole = await fetch(`${baseUrl}/api/sessions`, { + const forgedGenericAuthority = await fetch(`${baseUrl}/api/sessions`, { method: "POST", headers: { "Content-Type": "application/json", @@ -155,17 +142,14 @@ describe("coding-agent authorization boundary", () => { body: JSON.stringify({ cwd: projectRoot, harness: "claude-code", - planning: { - identity: { - projectId: "forged-project", - sessionId: "forged-session", - userId: "forged-user", - role: "map-planner", - }, + projectAuthority: { + projectId: "forged-project", + sessionId: "forged-session", + userId: "forged-user", }, }), }); - expect(forgedGenericRole.status).toBe(401); + expect(forgedGenericAuthority.status).toBe(401); const legitimateLaunch = await fetch( `${baseUrl}/?uiToken=${encodeURIComponent(server.uiToken)}`, diff --git a/packages/harness/src/server/agent-map-mcp-wiring.test.ts b/packages/harness/src/server/agent-map-mcp-wiring.test.ts index 5346b9314..9b615948f 100644 --- a/packages/harness/src/server/agent-map-mcp-wiring.test.ts +++ b/packages/harness/src/server/agent-map-mcp-wiring.test.ts @@ -408,37 +408,16 @@ it("gives every signed-out project session the same coding prompt and Agent Map loadSystemPrompt, }); - const response = await fetch( - `http://127.0.0.1:${server.port}/api/projects/${projectId}/planner-sessions`, - { - method: "POST", - headers: { - "content-type": "application/json", - "x-harness-token": "boot-token", - }, - body: JSON.stringify({ mode: "fresh", harness: "claude-code" }), - }, - ); - expect(response.status).toBe(201); - const created = (await response.json()) as { - session: { - id: string; - projectBootstrap?: unknown; - planning?: unknown; - agentMapIdentity?: { - projectId: string; - sessionId: string; - userId: string; - }; - }; - }; - expect(created.session.agentMapIdentity).toEqual({ + const created = await server.sessionManager.create({ + cwd: projectRoot, + harness: "claude-code", + }); + expect(created.agentMapIdentity).toEqual({ projectId, - sessionId: created.session.id, + sessionId: created.id, userId: "local:machine-1", }); - expect(created.session.planning).toBeUndefined(); - expect(created.session.projectBootstrap).toBeUndefined(); + expect(created.projectBootstrap).toBeUndefined(); const launchOpts = launches[0]!; const metadata = launchOpts.agentMapMcp; @@ -455,7 +434,7 @@ it("gives every signed-out project session the same coding prompt and Agent Map expect(systemPrompt).toContain(PROJECT_AGENT_PROMPT_APPENDIX); expect(systemPrompt).toContain("plan and implement in the same session"); expect(systemPrompt).toContain("Proceed directly"); - expect(systemPrompt).not.toContain("map-planner"); + expect(systemPrompt).not.toMatch(/map[-]planner/u); expect(systemPrompt).not.toContain("not to implement it yet"); expect(systemPrompt).not.toContain("stop before implementation"); expect(systemPrompt).not.toContain( @@ -554,7 +533,6 @@ it("gives every signed-out project session the same coding prompt and Agent Map sessionId: ordinary.id, userId: "local:machine-1", }); - expect(ordinary.planning).toBeUndefined(); const ordinaryLaunch = launches[1]!; expect(ordinaryLaunch.agentMapMcp).toBeDefined(); const ordinaryPrompt = await fs.readFile( @@ -644,7 +622,7 @@ it("creates one ordinary Plan Agents session for a newly opened project and neve userId: "local:machine-1", }, }); - expect(firstSession?.planning).toBeUndefined(); + expect(firstSession).not.toHaveProperty("planning"); const queuedUserInput = await request(`/sessions/${firstSession!.id}/input`, { method: "POST", @@ -675,153 +653,6 @@ it("creates one ordinary Plan Agents session for a newly opened project and neve expect(launches).toHaveLength(1); }); -it("serializes generic and compatibility input through one durable bootstrap authority", async () => { - const adapter: HarnessAdapter = { - id: "claude-code", - eventSource: "hooks", - doctor: async () => [], - launch: (opts) => ({ command: "bash", args: [], env: {}, cwd: opts.cwd }), - resume: (_id, opts) => ({ - command: "bash", - args: [], - env: {}, - cwd: opts.cwd, - }), - listPastSessions: async () => [], - canResume: async () => true, - }; - const webDir = path.join(root, "web"); - const freshRoot = path.join(root, "shared-input-project"); - await Promise.all([fs.mkdir(webDir), fs.mkdir(freshRoot)]); - await fs.writeFile(path.join(webDir, "index.html"), ""); - server = await startServer({ - port: 0, - bootToken: "boot-token", - telemetryOptIn: false, - identity: null, - machineId: "machine-1", - adapters: { "claude-code": adapter }, - stateRoot: root, - launchDir: projectRoot, - webDir, - autoCreateSession: false, - loadSystemPrompt: async () => "ordinary coding prompt", - }); - const request = (pathname: string, init?: RequestInit) => - fetch(`http://127.0.0.1:${server!.port}/api${pathname}`, { - ...init, - headers: { - "content-type": "application/json", - "x-harness-token": "boot-token", - ...init?.headers, - }, - }); - - expect( - ( - await request("/settings", { - method: "PATCH", - body: JSON.stringify({ recentDirs: [freshRoot, projectRoot] }), - }) - ).status, - ).toBe(200); - const [session] = server.sessionManager.list(); - expect(session).toMatchObject({ - cwd: freshRoot, - projectBootstrap: { bootstrap: { status: "pending" } }, - }); - const projectId = session!.agentMapIdentity!.projectId; - - const [compatibility, generic] = await Promise.all([ - request(`/projects/${projectId}/planner-sessions/${session!.id}/messages`, { - method: "POST", - body: JSON.stringify({ text: "compatibility transport input" }), - }), - request(`/sessions/${session!.id}/input`, { - method: "POST", - body: JSON.stringify({ text: "generic transport input", submit: true }), - }), - ]); - - expect(compatibility.status).toBe(202); - expect(generic.status).toBe(200); - expect( - server.sessionManager.get(session!.id)?.projectBootstrap, - ).toMatchObject({ - bootstrap: { status: "skipped", reason: "user-proceeded" }, - queuedInputIds: [expect.any(String), expect.any(String)], - }); - const queue = JSON.parse( - await fs.readFile( - path.join( - root, - "agent-map", - "project-bootstrap", - session!.id, - "input-queue.json", - ), - "utf8", - ), - ) as { - metadata: { queuedInputIds: string[]; bootstrap: { status: string } }; - inputs: Array<{ id: string; text: string }>; - dispatchingInputId: string | null; - }; - expect(queue.metadata).toMatchObject({ - bootstrap: { status: "skipped" }, - queuedInputIds: [expect.any(String), expect.any(String)], - }); - expect(queue.inputs.map(({ text }) => text).sort()).toEqual([ - "compatibility transport input", - "generic transport input", - ]); - expect(new Set(queue.inputs.map(({ id }) => id)).size).toBe(2); - expect(queue.dispatchingInputId).toBeNull(); - - const keyedPayload = { - text: "one logical input across both transports", - requestId: "bootstrap-input-request-1", - }; - const [keyedCompatibility, keyedGeneric] = await Promise.all([ - request(`/projects/${projectId}/planner-sessions/${session!.id}/messages`, { - method: "POST", - body: JSON.stringify(keyedPayload), - }), - request(`/sessions/${session!.id}/input`, { - method: "POST", - body: JSON.stringify({ ...keyedPayload, submit: true }), - }), - ]); - expect(keyedCompatibility.status).toBe(202); - expect(keyedGeneric.status).toBe(200); - const compatibilityBody = (await keyedCompatibility.json()) as { - receipt: unknown; - }; - const genericBody = (await keyedGeneric.json()) as { receipt: unknown }; - expect(genericBody.receipt).toEqual(compatibilityBody.receipt); - expect(compatibilityBody.receipt).toMatchObject({ - requestId: "bootstrap-input-request-1", - status: "queued", - }); - const keyedQueue = JSON.parse( - await fs.readFile( - path.join( - root, - "agent-map", - "project-bootstrap", - session!.id, - "input-queue.json", - ), - "utf8", - ), - ) as { inputs: Array<{ text: string }> }; - expect( - keyedQueue.inputs.filter( - ({ text }) => text === "one logical input across both transports", - ), - ).toHaveLength(1); -}); - it("does not spawn an automatic duplicate when an explicit first session wins the bootstrap claim", async () => { const launches: LaunchOpts[] = []; const adapter: HarnessAdapter = { diff --git a/packages/harness/src/server/agent-map-proposal-wiring.test.ts b/packages/harness/src/server/agent-map-proposal-wiring.test.ts index 8e62138b9..2ac9a3a05 100644 --- a/packages/harness/src/server/agent-map-proposal-wiring.test.ts +++ b/packages/harness/src/server/agent-map-proposal-wiring.test.ts @@ -25,7 +25,6 @@ it("publishes exactly one accepted proposal delta after durable commit", async ( projectId: "project_00000000-0000-4000-8000-000000000001", userId: "user-1", sessionId: "session-1", - role: "map-planner" as const, }; const request = { schemaVersion: 1 as const, diff --git a/packages/harness/src/server/agent-map.test.ts b/packages/harness/src/server/agent-map.test.ts index b0993c348..7d2d9ddb0 100644 --- a/packages/harness/src/server/agent-map.test.ts +++ b/packages/harness/src/server/agent-map.test.ts @@ -8,27 +8,10 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { AgentMapWorkspaceStore } from "../core/agent-map-workspace-store.js"; import { StudioProjectCatalog } from "../core/studio-project-catalog.js"; import { StudioWorkspacePreferenceStore } from "../core/studio-workspace-preferences.js"; -import { - ProjectBootstrapInputCapacityError, - ProjectBootstrapRequestIdConflictError, - ProjectBootstrapRetryUnavailableError, - type ProjectBootstrapCoordinator, -} from "../core/planner-greeting.js"; -import { - ProjectSessionScopeUnavailableError, - SessionBackgroundInputPreemptedError, - SessionInputIsolationError, - SessionNotReadyError, -} from "../core/session-manager.js"; -import { - ProjectSessionError, - type ProjectSessionService, -} from "../core/planning-session.js"; import type { AgentMapWorkspaceResponse, StudioProjectSummary, } from "../shared/agent-map.js"; -import type { HarnessSession } from "../shared/types.js"; import { createBootTokenMiddleware } from "./auth.js"; import { createAgentMapRouter } from "./agent-map.js"; @@ -48,25 +31,6 @@ describe("createAgentMapRouter", () => { }); async function start(projectLifecycle?: { - projectSessions?: ProjectSessionService; - projectBootstrap?: ProjectBootstrapCoordinator; - submitSessionInput?: ( - sessionId: string, - text: string, - submit: boolean, - requestId?: string, - ) => Promise< - | boolean - | { - ok: boolean; - receipt?: { - requestId: string | null; - inputId: string; - status: "queued" | "submitted" | "uncertain" | "completed"; - acceptedAt: string; - }; - } - >; onProjectCreated?: (project: StudioProjectSummary) => Promise | void; onRootBound?: ( project: StudioProjectSummary, @@ -117,9 +81,6 @@ describe("createAgentMapRouter", () => { isWorkflowScanComplete: () => true, listWorkspaceScopes, ...projectLifecycle, - ...(projectLifecycle && !projectLifecycle.submitSessionInput - ? { submitSessionInput: async () => true } - : {}), }), ); server = app.listen(0); @@ -589,553 +550,4 @@ describe("createAgentMapRouter", () => { expect(await fs.readFile(workspacePath, "utf8")).toBe("{bad-json"); }); - it("keeps legacy planner routes as project-scoped aliases to neutral services", async () => { - let fixtureProjectId = ""; - const plannerSession = { - id: "planner-session-1", - agentSessionId: null, - harness: "codex", - cwd: "/server/private/project", - title: "project", - status: "running", - createdAt: "2026-09-01T00:00:00.000Z", - lastActiveAt: "2026-09-01T00:00:00.000Z", - exitCode: null, - boundWorkflowPath: null, - ready: false, - } as HarnessSession; - const open = vi.fn(async () => ({ - session: plannerSession, - resolution: "created" as const, - })); - const requireOwned = vi.fn(() => plannerSession); - const enqueue = vi.fn(async (_sessionId: string, _text: string) => { - const metadata = { - projectId: fixtureProjectId, - userId: "user-1", - targetSessionId: plannerSession.id, - bootstrap: { - status: "skipped" as const, - reason: "user-proceeded" as const, - }, - queuedInputIds: ["input-1"], - }; - plannerSession.projectBootstrap = metadata; - return metadata; - }); - const retry = vi.fn(async () => { - if (!plannerSession.projectBootstrap) { - throw new Error("missing project bootstrap metadata"); - } - plannerSession.projectBootstrap = { - ...plannerSession.projectBootstrap, - bootstrap: { status: "generating", attemptId: "attempt-2" }, - }; - }); - // This is the one canonical ordinary-session input authority shared with - // POST /sessions/:id/input. The compatibility route must call it even - // while bootstrap owns the durable FIFO; it may not enqueue independently. - const submitSessionInput = vi.fn( - async ( - sessionId: string, - text: string, - _submit: boolean, - requestId?: string, - ) => { - await enqueue(sessionId, text); - return requestId - ? { - ok: true as const, - receipt: { - requestId, - inputId: "input-1", - status: "queued" as const, - acceptedAt: "2026-09-04T00:00:00.000Z", - }, - } - : true; - }, - ); - const fixture = await start({ - projectSessions: { - open, - requireOwned, - } as unknown as ProjectSessionService, - projectBootstrap: { - enqueue, - retry, - } as unknown as ProjectBootstrapCoordinator, - submitSessionInput, - }); - fixtureProjectId = fixture.project.projectId; - plannerSession.agentMapIdentity = { - projectId: fixtureProjectId, - sessionId: plannerSession.id, - userId: "user-1", - }; - plannerSession.projectBootstrap = { - projectId: fixtureProjectId, - targetSessionId: plannerSession.id, - userId: "user-1", - bootstrap: { - status: "failed", - retryable: true, - errorCode: "model_turn_failed", - }, - queuedInputIds: [], - }; - const route = `${fixture.baseUrl}/api/projects/${fixture.project.projectId}/planner-sessions`; - - expect( - ( - await fetch(route, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ mode: "fresh" }), - }) - ).status, - ).toBe(401); - const forged = await fetch(route, { - method: "POST", - headers: { - "content-type": "application/json", - "X-Harness-Token": "test-token", - }, - body: JSON.stringify({ - mode: "fresh", - role: "map-planner", - projectId: fixture.project.projectId, - }), - }); - expect(forged.status).toBe(400); - expect(open).not.toHaveBeenCalled(); - - const valid = await fetch(route, { - method: "POST", - headers: { - "content-type": "application/json", - "X-Harness-Token": "test-token", - }, - body: JSON.stringify({ mode: "fresh", harness: "codex" }), - }); - expect(valid.status).toBe(201); - expect(open).toHaveBeenCalledWith(fixture.project.projectId, { - mode: "fresh", - harness: "codex", - }); - - const message = await fetch(`${route}/${plannerSession.id}/messages`, { - method: "POST", - headers: { - "content-type": "application/json", - "X-Harness-Token": "test-token", - }, - body: JSON.stringify({ - text: "Build a support triage system", - requestId: "request-build-support", - }), - }); - expect(message.status).toBe(202); - expect(await message.json()).toEqual({ - metadata: { - projectId: fixture.project.projectId, - targetSessionId: plannerSession.id, - userId: "user-1", - bootstrap: { status: "skipped", reason: "user-proceeded" }, - queuedInputIds: ["input-1"], - }, - receipt: { - requestId: "request-build-support", - inputId: "input-1", - status: "queued", - acceptedAt: "2026-09-04T00:00:00.000Z", - }, - }); - expect(requireOwned).toHaveBeenCalledWith( - fixture.project.projectId, - plannerSession.id, - ); - expect(enqueue).toHaveBeenCalledWith( - plannerSession.id, - "Build a support triage system", - ); - expect(submitSessionInput).toHaveBeenCalledWith( - plannerSession.id, - "Build a support triage system", - true, - "request-build-support", - ); - - const followUp = await fetch(`${route}/${plannerSession.id}/messages`, { - method: "POST", - headers: { - "content-type": "application/json", - "X-Harness-Token": "test-token", - }, - body: JSON.stringify({ text: "Keep this behind the durable FIFO" }), - }); - expect(followUp.status).toBe(202); - expect(enqueue).toHaveBeenLastCalledWith( - plannerSession.id, - "Keep this behind the durable FIFO", - ); - expect(enqueue).toHaveBeenCalledTimes(2); - expect(submitSessionInput).toHaveBeenCalledTimes(2); - - plannerSession.projectBootstrap = { - ...plannerSession.projectBootstrap!, - bootstrap: { - status: "failed", - retryable: true, - errorCode: "model_turn_failed", - }, - queuedInputIds: [], - }; - - const retryResponse = await fetch( - `${route}/${plannerSession.id}/greeting/retry`, - { - method: "POST", - headers: { - "content-type": "application/json", - "X-Harness-Token": "test-token", - }, - body: "{}", - }, - ); - expect(retryResponse.status).toBe(202); - expect(await retryResponse.json()).toEqual({ - metadata: { - ...plannerSession.projectBootstrap, - bootstrap: { status: "generating", attemptId: "attempt-2" }, - }, - }); - expect(retry).toHaveBeenCalledWith(plannerSession.id); - }); - - it("lets an ordinary project session use the legacy message alias without bootstrap metadata", async () => { - const ordinarySession = { - id: "ordinary-session-1", - agentSessionId: "provider-session-1", - harness: "codex", - cwd: "/server/private/project", - title: "Implementation", - status: "running", - createdAt: "2026-09-01T00:00:00.000Z", - lastActiveAt: "2026-09-01T00:00:00.000Z", - exitCode: null, - boundWorkflowPath: null, - ready: true, - } as HarnessSession; - const requireOwned = vi.fn(async () => ordinarySession); - const submitSessionInput = vi.fn(async () => true); - const fixture = await start({ - projectSessions: { - open: vi.fn(), - requireOwned, - } as unknown as ProjectSessionService, - submitSessionInput, - }); - ordinarySession.agentMapIdentity = { - projectId: fixture.project.projectId, - sessionId: ordinarySession.id, - userId: "user-1", - }; - - const response = await fetch( - `${fixture.baseUrl}/api/projects/${fixture.project.projectId}/planner-sessions/${ordinarySession.id}/messages`, - { - method: "POST", - headers: { - "content-type": "application/json", - "X-Harness-Token": "test-token", - }, - body: JSON.stringify({ text: "Continue the implementation" }), - }, - ); - - expect(response.status).toBe(202); - expect(await response.json()).toEqual({ metadata: null }); - expect(requireOwned).toHaveBeenCalledWith( - fixture.project.projectId, - ordinarySession.id, - ); - expect(submitSessionInput).toHaveBeenCalledTimes(1); - expect(submitSessionInput).toHaveBeenCalledWith( - ordinarySession.id, - "Continue the implementation", - true, - undefined, - ); - }); - - it("dispatches each compatibility request once through the canonical authority", async () => { - const ordinarySession = { - id: "ordinary-session-1", - agentSessionId: "provider-session-1", - harness: "codex", - cwd: "/server/private/project", - title: "Implementation", - status: "running", - createdAt: "2026-09-01T00:00:00.000Z", - lastActiveAt: "2026-09-01T00:00:00.000Z", - exitCode: null, - boundWorkflowPath: null, - ready: true, - } as HarnessSession; - const submitSessionInput = vi.fn(async () => true); - const fixture = await start({ - projectSessions: { - open: vi.fn(), - requireOwned: vi.fn(async () => ordinarySession), - } as unknown as ProjectSessionService, - submitSessionInput, - }); - const route = `${fixture.baseUrl}/api/projects/${fixture.project.projectId}/planner-sessions/${ordinarySession.id}/messages`; - const request = () => - fetch(route, { - method: "POST", - headers: { - "content-type": "application/json", - "X-Harness-Token": "test-token", - }, - body: JSON.stringify({ text: "same visible text" }), - }); - - const responses = await Promise.all([request(), request()]); - - expect(responses.map((response) => response.status)).toEqual([202, 202]); - expect(submitSessionInput).toHaveBeenCalledTimes(2); - for (const call of submitSessionInput.mock.calls) { - expect(call).toEqual([ - ordinarySession.id, - "same visible text", - true, - undefined, - ]); - } - }); - - it("forwards request IDs and returns the same durable conflict semantics through the compatibility alias", async () => { - const ordinarySession = { - id: "bootstrap-session-1", - status: "running", - } as HarnessSession; - const submitSessionInput = vi.fn(async () => { - throw new ProjectBootstrapRequestIdConflictError(); - }); - const fixture = await start({ - projectSessions: { - open: vi.fn(), - requireOwned: vi.fn(async () => ordinarySession), - } as unknown as ProjectSessionService, - submitSessionInput, - }); - - const response = await fetch( - `${fixture.baseUrl}/api/projects/${fixture.project.projectId}/planner-sessions/${ordinarySession.id}/messages`, - { - method: "POST", - headers: { - "content-type": "application/json", - "X-Harness-Token": "test-token", - }, - body: JSON.stringify({ - text: "changed payload", - requestId: "request-reused", - }), - }, - ); - - expect(response.status).toBe(409); - expect(await response.json()).toEqual({ - code: "project_bootstrap_request_id_reused", - error: "project bootstrap request id was reused with different input", - }); - expect(submitSessionInput).toHaveBeenCalledWith( - ordinarySession.id, - "changed payload", - true, - "request-reused", - ); - }); - - it("returns bounded durable-input capacity through the compatibility alias", async () => { - const ordinarySession = { - id: "bootstrap-session-capacity", - status: "running", - } as HarnessSession; - const submitSessionInput = vi.fn(async () => { - throw new ProjectBootstrapInputCapacityError(); - }); - const fixture = await start({ - projectSessions: { - open: vi.fn(), - requireOwned: vi.fn(async () => ordinarySession), - } as unknown as ProjectSessionService, - submitSessionInput, - }); - - const response = await fetch( - `${fixture.baseUrl}/api/projects/${fixture.project.projectId}/planner-sessions/${ordinarySession.id}/messages`, - { - method: "POST", - headers: { - "content-type": "application/json", - "X-Harness-Token": "test-token", - }, - body: JSON.stringify({ - text: "new logical request", - requestId: "request-at-capacity", - }), - }, - ); - - expect(response.status).toBe(409); - expect(await response.json()).toEqual({ - code: "project_bootstrap_input_capacity", - error: "project bootstrap input receipt capacity is temporarily full", - }); - expect(submitSessionInput).toHaveBeenCalledWith( - ordinarySession.id, - "new logical request", - true, - "request-at-capacity", - ); - }); - - it("maps canonical input rejection without retrying compatibility dispatch", async () => { - const ordinarySession = { - id: "ordinary-session-1", - status: "running", - } as HarnessSession; - const submitSessionInput = vi - .fn< - (sessionId: string, text: string, submit: boolean) => Promise - >() - .mockResolvedValueOnce(false) - .mockRejectedValueOnce(new SessionNotReadyError(ordinarySession.id)) - .mockRejectedValueOnce(new SessionBackgroundInputPreemptedError(false)) - .mockRejectedValueOnce(new SessionInputIsolationError()); - const fixture = await start({ - projectSessions: { - open: vi.fn(), - requireOwned: vi.fn(async () => ordinarySession), - } as unknown as ProjectSessionService, - submitSessionInput, - }); - const route = `${fixture.baseUrl}/api/projects/${fixture.project.projectId}/planner-sessions/${ordinarySession.id}/messages`; - const request = () => - fetch(route, { - method: "POST", - headers: { - "content-type": "application/json", - "X-Harness-Token": "test-token", - }, - body: JSON.stringify({ text: "one dispatch" }), - }); - - const missing = await request(); - const unready = await request(); - const concurrent = await request(); - const isolated = await request(); - - expect(missing.status).toBe(404); - expect(unready.status).toBe(409); - expect(await unready.json()).toMatchObject({ code: "SESSION_NOT_READY" }); - expect(concurrent.status).toBe(409); - expect(await concurrent.json()).toEqual({ - code: "SESSION_BACKGROUND_INPUT_PREEMPTED", - error: "background session input was preempted by user input", - }); - expect(isolated.status).toBe(409); - expect(await isolated.json()).toEqual({ - code: "SESSION_INPUT_ISOLATION_REQUIRED", - error: "session input is blocked until the terminal composer is reset", - }); - expect(submitSessionInput).toHaveBeenCalledTimes(4); - }); - - it("rejects foreign planner messages and bounds unavailable retries", async () => { - const requireOwned = vi.fn<() => Promise>(async () => { - throw new ProjectSessionError("forbidden"); - }); - const enqueue = vi.fn(async () => ({}) as never); - const retry = vi.fn(async () => { - throw new ProjectBootstrapRetryUnavailableError(); - }); - const fixture = await start({ - projectSessions: { - open: vi.fn(), - requireOwned, - } as unknown as ProjectSessionService, - projectBootstrap: { - enqueue, - retry, - } as unknown as ProjectBootstrapCoordinator, - }); - const headers = { - "content-type": "application/json", - "X-Harness-Token": "test-token", - }; - const message = await fetch( - `${fixture.baseUrl}/api/projects/${fixture.project.projectId}/planner-sessions/foreign/messages`, - { method: "POST", headers, body: JSON.stringify({ text: "hello" }) }, - ); - expect(message.status).toBe(403); - expect(await message.json()).toMatchObject({ code: "forbidden" }); - expect(enqueue).not.toHaveBeenCalled(); - - const forbiddenRetry = await fetch( - `${fixture.baseUrl}/api/projects/${fixture.project.projectId}/planner-sessions/foreign/greeting/retry`, - { method: "POST", headers, body: "{}" }, - ); - expect(forbiddenRetry.status).toBe(403); - expect(await forbiddenRetry.json()).toMatchObject({ code: "forbidden" }); - expect(retry).not.toHaveBeenCalled(); - - requireOwned.mockResolvedValue({ id: "owned" } as HarnessSession); - const retryResponse = await fetch( - `${fixture.baseUrl}/api/projects/${fixture.project.projectId}/planner-sessions/owned/greeting/retry`, - { method: "POST", headers, body: "{}" }, - ); - expect(retryResponse.status).toBe(409); - expect(await retryResponse.json()).toEqual({ - code: "project_bootstrap_retry_unavailable", - error: "project bootstrap retry is not available", - }); - }); - - it("returns a bounded compatibility error when project scope cannot be revalidated", async () => { - const requireOwned = vi.fn(async () => { - throw new ProjectSessionScopeUnavailableError("ordinary-session"); - }); - const fixture = await start({ - projectSessions: { - open: vi.fn(), - requireOwned, - } as unknown as ProjectSessionService, - projectBootstrap: { - enqueue: vi.fn(), - retry: vi.fn(), - } as unknown as ProjectBootstrapCoordinator, - }); - const response = await fetch( - `${fixture.baseUrl}/api/projects/${fixture.project.projectId}/planner-sessions/ordinary-session/messages`, - { - method: "POST", - headers: { - "content-type": "application/json", - "X-Harness-Token": "test-token", - }, - body: JSON.stringify({ text: "continue" }), - }, - ); - - expect(response.status).toBe(409); - expect(await response.json()).toEqual({ - code: "PROJECT_SESSION_SCOPE_UNAVAILABLE", - error: "the session's Studio project scope could not be revalidated", - }); - }); }); diff --git a/packages/harness/src/server/agent-map.ts b/packages/harness/src/server/agent-map.ts index 71da9901d..cb17497e7 100644 --- a/packages/harness/src/server/agent-map.ts +++ b/packages/harness/src/server/agent-map.ts @@ -1,17 +1,13 @@ import { Router } from "express"; import { z } from "zod"; - import { type AgentMapErrorCode, type AgentMapErrorResponse, type AgentMapWorkspaceResponse, - type PlannerMessageRequest, - type PlannerSessionRequest, type StudioProjectSummary, type StudioWorkspaceSelection, } from "../shared/agent-map.js"; -import { SPAWNABLE_HARNESS_KINDS, type WorkflowInfo } from "../shared/types.js"; -import type { SessionInputSubmissionResult } from "../shared/types.js"; +import type { WorkflowInfo } from "../shared/types.js"; import type { WorkspaceScopeSummary } from "../shared/system-graph.js"; import { samePath } from "../shared/paths.js"; import { @@ -27,24 +23,6 @@ import { StudioWorkspacePreferenceStore, StudioWorkspacePreferenceStoreError, } from "../core/studio-workspace-preferences.js"; -import { ExternalHarnessError } from "../core/errors.js"; -import { - ProjectSessionError, - type ProjectSessionService, -} from "../core/planning-session.js"; -import { - ProjectSessionScopeUnavailableError, - SessionBackgroundInputPreemptedError, - SessionInputIsolationError, - SessionNotReadyError, -} from "../core/session-manager.js"; -import { - ProjectBootstrapDispatchForbiddenError, - ProjectBootstrapInputCapacityError, - ProjectBootstrapRequestIdConflictError, - ProjectBootstrapRetryUnavailableError, - type ProjectBootstrapCoordinator, -} from "../core/planner-greeting.js"; export interface AgentMapRouterOptions { catalog: StudioProjectCatalog; @@ -62,75 +40,12 @@ export interface AgentMapRouterOptions { listWorkspaceScopes: () => | readonly WorkspaceScopeSummary[] | Promise; - projectSessions?: ProjectSessionService; - projectBootstrap?: ProjectBootstrapCoordinator; /** New-project lifecycle hooks; never called by Agent Map reads. */ onProjectCreated?: (project: StudioProjectSummary) => Promise | void; onRootBound?: ( project: StudioProjectSummary, root: string, ) => Promise | void; - /** Neutral ordinary-session input boundary used by rolling aliases. */ - submitSessionInput?: ( - sessionId: string, - text: string, - submit: boolean, - requestId?: string, - ) => Promise; -} - -const plannerSessionSchema = z - .object({ - mode: z.enum(["resume-or-create", "fresh"]), - harness: z.enum(SPAWNABLE_HARNESS_KINDS).optional(), - theme: z.enum(["light", "dark"]).optional(), - }) - .strict() satisfies z.ZodType; - -const plannerMessageSchema = z - .object({ - text: z.string().min(1).max(100_000), - requestId: z.string().min(1).max(200).optional(), - }) - .strict() satisfies z.ZodType; - -function sendProjectSessionError( - res: import("express").Response, - error: unknown, -): boolean { - if ( - error instanceof SessionNotReadyError || - error instanceof ExternalHarnessError || - error instanceof SessionBackgroundInputPreemptedError || - error instanceof SessionInputIsolationError - ) { - res.status(409).json({ code: error.code, error: error.message }); - return true; - } - if (error instanceof ProjectBootstrapDispatchForbiddenError) { - res.status(403).json({ code: error.code, error: error.message }); - return true; - } - if ( - error instanceof ProjectBootstrapRequestIdConflictError || - error instanceof ProjectBootstrapInputCapacityError - ) { - res.status(409).json({ code: error.code, error: error.message }); - return true; - } - if (error instanceof ProjectSessionScopeUnavailableError) { - res.status(409).json({ code: error.code, error: error.message }); - return true; - } - if (!(error instanceof ProjectSessionError)) return false; - const status = - error.code === "project_not_found" || error.code === "session_not_found" - ? 404 - : error.code === "forbidden" - ? 403 - : 409; - res.status(status).json({ code: error.code, error: error.message }); - return true; } const ERROR_MESSAGES: Record = { @@ -446,107 +361,5 @@ export function createAgentMapRouter(options: AgentMapRouterOptions): Router { } }); - /** @deprecated Rolling alias to ordinary project-session open; remove in SAP-3152. */ - router.post( - "/projects/:projectId/planner-sessions", - async (req, res, next) => { - if (!options.projectSessions) { - res.status(501).json({ error: "Project sessions are unavailable" }); - return; - } - const parsed = plannerSessionSchema.safeParse(req.body); - if (!parsed.success) { - res.status(400).json({ error: "Invalid planner session request" }); - return; - } - try { - const result = await options.projectSessions.open( - req.params.projectId, - parsed.data, - ); - res.status(result.resolution === "created" ? 201 : 200).json(result); - } catch (error) { - if (!sendProjectSessionError(res, error)) next(error); - } - }, - ); - - /** @deprecated Rolling alias to ordinary project-session input; remove in SAP-3152. */ - router.post( - "/projects/:projectId/planner-sessions/:sessionId/messages", - async (req, res, next) => { - if (!options.projectSessions || !options.submitSessionInput) { - res.status(501).json({ error: "Project sessions are unavailable" }); - return; - } - const parsed = plannerMessageSchema.safeParse(req.body); - if (!parsed.success) { - res.status(400).json({ error: "Invalid planner message" }); - return; - } - try { - const session = await options.projectSessions.requireOwned( - req.params.projectId, - req.params.sessionId, - ); - const submitted = await options.submitSessionInput( - req.params.sessionId, - parsed.data.text, - true, - parsed.data.requestId, - ); - const result = - typeof submitted === "boolean" ? { ok: submitted } : submitted; - if (!result.ok) { - res - .status(404) - .json({ error: "Project session has no live process" }); - return; - } - res.status(202).json({ - metadata: session.projectBootstrap ?? null, - ...(typeof submitted !== "boolean" && - submitted.ok && - submitted.receipt - ? { receipt: submitted.receipt } - : {}), - }); - } catch (error) { - if (!sendProjectSessionError(res, error)) next(error); - } - }, - ); - - /** @deprecated Rolling alias; remove after persisted clients migrate in SAP-3152. */ - router.post( - "/projects/:projectId/planner-sessions/:sessionId/greeting/retry", - async (req, res, next) => { - if (!options.projectSessions || !options.projectBootstrap) { - res.status(501).json({ error: "Project bootstrap is unavailable" }); - return; - } - if (Object.keys((req.body ?? {}) as object).length > 0) { - res.status(400).json({ error: "Invalid greeting retry request" }); - return; - } - try { - const session = await options.projectSessions.requireOwned( - req.params.projectId, - req.params.sessionId, - ); - if (!session.projectBootstrap) { - throw new ProjectBootstrapRetryUnavailableError(); - } - await options.projectBootstrap.retry(req.params.sessionId); - res.status(202).json({ - metadata: session.projectBootstrap, - }); - } catch (error) { - if (error instanceof ProjectBootstrapRetryUnavailableError) { - res.status(409).json({ code: error.code, error: error.message }); - } else if (!sendProjectSessionError(res, error)) next(error); - } - }, - ); return router; } diff --git a/packages/harness/src/server/index.ts b/packages/harness/src/server/index.ts index c0490218d..1e75455c8 100644 --- a/packages/harness/src/server/index.ts +++ b/packages/harness/src/server/index.ts @@ -200,12 +200,12 @@ import { StudioWorkspacePreferenceStore } from "../core/studio-workspace-prefere import { isProjectSessionDispatchAuthorized, localProjectPrincipal, - ProjectSessionService, -} from "../core/planning-session.js"; +} from "../core/project-session.js"; +import { legacyProjectSessionStateRoot } from "../core/project-session-legacy-migration.js"; import { ProjectBootstrapCoordinator, ProjectBootstrapCoordinatorClosedError, -} from "../core/planner-greeting.js"; +} from "../core/project-bootstrap.js"; import { IngestCredentialRegistry } from "../core/ingest-credentials.js"; import { createStaticRouter } from "./static.js"; import { createTerminalWebSocketHandler } from "./terminal-ws.js"; @@ -3322,7 +3322,7 @@ export const startServer = async ( }; projectBootstrap = new ProjectBootstrapCoordinator({ root: statePaths.projectBootstrap, - legacyRoot: statePaths.plannerSessions, + legacyStateRoot: legacyProjectSessionStateRoot(statePaths.root), sessionManager, canDispatch: (session) => isProjectSessionDispatchAuthorized({ @@ -3389,14 +3389,6 @@ export const startServer = async ( console.error("[harness] project bootstrap registration failed"); }); } - const projectSessions = new ProjectSessionService({ - catalog: studioProjectCatalog, - sessionManager, - userId: identity?.userId ?? null, - currentUserId: () => projectUserId, - machineId, - defaultHarness: options.defaultHarnessKind ?? "claude-code", - }); sessionManager.onStatusChange((session, { runtimeEpoch }) => { void projectBootstrap!.onSessionStatus(session, runtimeEpoch).catch(() => { console.error("[harness] project bootstrap status transition failed"); @@ -3687,8 +3679,6 @@ export const startServer = async ( listWorkflows: () => workflowsCache, isWorkflowScanComplete, listWorkspaceScopes: () => studioWorkspaceScopeCatalog.list(), - projectSessions, - projectBootstrap, onProjectCreated: async (project) => { const userId = localProjectPrincipal(projectUserId, machineId); await scheduleBootstrapProjects( @@ -3721,7 +3711,6 @@ export const startServer = async ( : null; await ensureProjectFirstSession(project.projectId, launchRoot ?? root); }, - submitSessionInput, }), ); app.use( diff --git a/packages/harness/src/server/ingest.test.ts b/packages/harness/src/server/ingest.test.ts index 9ac1356f9..60fb89373 100644 --- a/packages/harness/src/server/ingest.test.ts +++ b/packages/harness/src/server/ingest.test.ts @@ -9,7 +9,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { normalizeHookEvent } from "../core/collector/normalizer.js"; import { createSeqCounter } from "../core/collector/seq.js"; import { createEventStore } from "../core/collector/store.js"; -import { ProjectBootstrapCoordinator } from "../core/planner-greeting.js"; +import { ProjectBootstrapCoordinator } from "../core/project-bootstrap.js"; import { createSessionRecordReader } from "../core/session-record.js"; import type { SessionManager } from "../core/session-manager.js"; import type { AnalyticsEvent, HarnessSession } from "../shared/types.js"; @@ -807,11 +807,11 @@ describe("createIngestRouter", () => { start({ decorateEvent: (event) => ({ ...event, - payload: { ...event.payload, plannerOrigin: "infrastructure" }, + payload: { ...event.payload, projectBootstrapOrigin: "infrastructure" }, }), projectTelemetryEvent: (event) => ({ ...event, - payload: { planner: true, origin: event.payload.plannerOrigin }, + payload: { bootstrap: true, origin: event.payload.projectBootstrapOrigin }, }), }); @@ -824,10 +824,10 @@ describe("createIngestRouter", () => { await vi.waitFor(() => expect(stored).toHaveLength(1)); expect(stored[0].payload).toMatchObject({ prompt: "private control prompt", - plannerOrigin: "infrastructure", + projectBootstrapOrigin: "infrastructure", }); expect(enqueued[0].payload).toEqual({ - planner: true, + bootstrap: true, origin: "infrastructure", }); expect(JSON.stringify(enqueued[0])).not.toContain("private control prompt"); diff --git a/packages/harness/src/server/rest.test.ts b/packages/harness/src/server/rest.test.ts index accd36016..32b3a5d78 100644 --- a/packages/harness/src/server/rest.test.ts +++ b/packages/harness/src/server/rest.test.ts @@ -540,7 +540,7 @@ describe("createRestRouter", () => { expect(onSessionCreated).not.toHaveBeenCalled(); }); - it("rejects role and project spoofing on generic session creation", async () => { + it("rejects project-authority spoofing on generic session creation", async () => { const sessionManager = fakeSessionManager(); start({ sessionManager }); @@ -550,7 +550,7 @@ describe("createRestRouter", () => { body: JSON.stringify({ cwd: "/tmp/proj", harness: "codex", - role: "map-planner", + authority: "forged", projectId: "forged-project", }), }); @@ -1000,29 +1000,19 @@ describe("createRestRouter", () => { expect(res.status).toBe(400); }); - it("accepts ordinary input for a former planner session without a role-specific 409", async () => { - const planner = exitedSession({ - id: "planner-1", + it("accepts ordinary input for a project session without a role-specific 409", async () => { + const projectSession = exitedSession({ + id: "project-session-1", agentMapIdentity: { projectId: "project-1", - sessionId: "planner-1", + sessionId: "project-session-1", userId: "user-1", }, - planning: { - identity: { - projectId: "project-1", - sessionId: "planner-1", - userId: "user-1", - role: "map-planner", - }, - greeting: { status: "pending" }, - queuedInputIds: [], - }, }); - const sessionManager = fakeSessionManager([planner]); + const sessionManager = fakeSessionManager([projectSession]); start({ sessionManager }); - const res = await fetch(`${baseUrl}/sessions/planner-1/input`, { + const res = await fetch(`${baseUrl}/sessions/project-session-1/input`, { method: "POST", headers: { ...TOKEN_HEADER, "content-type": "application/json" }, body: JSON.stringify({ text: "bypass" }), @@ -1030,7 +1020,7 @@ describe("createRestRouter", () => { expect(res.status).toBe(200); expect(await res.json()).toEqual({ ok: true }); expect(sessionManager.submitInput).toHaveBeenCalledWith( - planner.id, + projectSession.id, "bypass", true, ); @@ -1245,42 +1235,32 @@ describe("createRestRouter", () => { }); describe("POST /sessions/:id/resume — error class → HTTP status mapping", () => { - it("resumes a former planner through the ordinary endpoint without a role-specific 409", async () => { - const planner = exitedSession({ - id: "planner-1", + it("resumes a project session through the ordinary endpoint without a role-specific 409", async () => { + const projectSession = exitedSession({ + id: "project-session-1", agentMapIdentity: { projectId: "project-1", - sessionId: "planner-1", + sessionId: "project-session-1", userId: "user-1", }, - planning: { - identity: { - projectId: "project-1", - sessionId: "planner-1", - userId: "user-1", - role: "map-planner", - }, - greeting: { status: "delivered", messageId: "message-1" }, - queuedInputIds: [], - }, }); - const sessionManager = fakeSessionManager([planner]); + const sessionManager = fakeSessionManager([projectSession]); (sessionManager.resume as ReturnType).mockResolvedValue({ - ...planner, + ...projectSession, status: "running", }); start({ sessionManager }); - const res = await fetch(`${baseUrl}/sessions/planner-1/resume`, { + const res = await fetch(`${baseUrl}/sessions/project-session-1/resume`, { method: "POST", headers: TOKEN_HEADER, }); expect(res.status).toBe(200); expect(await res.json()).toMatchObject({ - id: planner.id, + id: projectSession.id, status: "running", }); - expect(sessionManager.resume).toHaveBeenCalledWith(planner.id); + expect(sessionManager.resume).toHaveBeenCalledWith(projectSession.id); }); it("404s when resume() throws UnknownSessionError (class-based dispatch, not string match)", async () => { @@ -1736,30 +1716,19 @@ describe("createRestRouter", () => { expect(sessionManager.resume).toHaveBeenCalledWith(existing.id); }); - it("reuses a former planner owner through ordinary adopt without duplicating its record", async () => { - const planner = exitedSession({ - id: "planner-existing", + it("reuses an existing owner through ordinary adopt without duplicating its record", async () => { + const existingOwner = exitedSession({ + id: "project-session-existing", agentSessionId: body.agentSessionId, agentMapIdentity: { projectId: "foreign-project", - sessionId: "planner-existing", + sessionId: "project-session-existing", userId: "foreign-user", }, - planning: { - identity: { - projectId: "foreign-project", - sessionId: "planner-existing", - userId: "foreign-user", - role: "map-planner", - }, - greeting: { status: "delivered", messageId: "message-1" }, - queuedInputIds: [], - }, }); - const original = structuredClone(planner.planning); - const sessionManager = fakeSessionManager([planner]); + const sessionManager = fakeSessionManager([existingOwner]); (sessionManager.resume as ReturnType).mockResolvedValue({ - ...planner, + ...existingOwner, status: "running", }); const canResume = vi.fn(async () => true); @@ -1774,39 +1743,26 @@ describe("createRestRouter", () => { expect(res.status).toBe(200); expect(await res.json()).toMatchObject({ - id: planner.id, + id: existingOwner.id, status: "running", }); expect(canResume).toHaveBeenCalledWith(body.agentSessionId, body.cwd); expect(sessionManager.registerHistorical).not.toHaveBeenCalled(); - expect(sessionManager.resume).toHaveBeenCalledWith(planner.id); - expect(sessionManager.get("planner-existing")?.planning).toEqual( - original, - ); + expect(sessionManager.resume).toHaveBeenCalledWith(existingOwner.id); }); - it("rejects a durable rotated provider alias independently of its former role", async () => { - const planner = exitedSession({ - id: "planner-rotated", + it("rejects a durable rotated provider alias independently of project context", async () => { + const projectSession = exitedSession({ + id: "project-session-rotated", agentSessionId: "vendor-new", - planning: { - identity: { - projectId: "project-1", - sessionId: "planner-rotated", - userId: "user-1", - role: "map-planner", - }, - greeting: { status: "delivered", messageId: "message-1" }, - queuedInputIds: [], - }, }); - const sessionManager = fakeSessionManager([planner]); + const sessionManager = fakeSessionManager([projectSession]); ( sessionManager.getAgentSessionOwner as unknown as ReturnType< typeof vi.fn > ).mockImplementation((agentSessionId: string) => - agentSessionId === body.agentSessionId ? planner : undefined, + agentSessionId === body.agentSessionId ? projectSession : undefined, ); ( sessionManager.isAgentSessionIdentityReserved as unknown as ReturnType< diff --git a/packages/harness/src/shared/agent-map-codec.test.ts b/packages/harness/src/shared/agent-map-codec.test.ts index 17560ef21..6dac7d878 100644 --- a/packages/harness/src/shared/agent-map-codec.test.ts +++ b/packages/harness/src/shared/agent-map-codec.test.ts @@ -66,11 +66,7 @@ describe("Agent Map persisted/public codecs", () => { "unknown operation", (value: any) => (value.history[0].operation.kind = "execute"), ], - [ - "spoofed role", - (value: any) => - (value.history[0].actor.role = "map-planner"), - ], + ["spoofed authority", (value: any) => (value.history[0].actor.scope = "foreign")], [ "nested extra field", (value: any) => (value.history[0].operation.node.privatePath = "/secret"), diff --git a/packages/harness/src/shared/agent-map-codec.ts b/packages/harness/src/shared/agent-map-codec.ts index d4e5a2562..c056e4d83 100644 --- a/packages/harness/src/shared/agent-map-codec.ts +++ b/packages/harness/src/shared/agent-map-codec.ts @@ -364,31 +364,6 @@ export function parseProposalActor(value: unknown): ProposalActor { return { userId: value.userId, sessionId: value.sessionId }; } -export interface LegacyE2ProposalActor { - userId: string; - sessionId: string; - role: "map-planner" | "agent-builder"; - assignment: - | { kind: "planned"; agentId: string } - | { kind: "unplanned" } - | null; -} - -/** Frozen decoder used only by the direct deployed-E2 migration. */ -export function parseLegacyE2ProposalActor(value: unknown): LegacyE2ProposalActor { - if (!isRecord(value) || !hasExactKeys(value, ["userId", "sessionId", "role", "assignment"]) || - !isAgentMapBoundedText(value.userId, 256) || !isAgentMapBoundedText(value.sessionId, 256)) - throw new Error("invalid legacy Agent Map actor"); - if (value.role === "map-planner" && value.assignment === null) - return structuredClone(value) as unknown as LegacyE2ProposalActor; - if (value.role !== "agent-builder" || !isRecord(value.assignment) || - (value.assignment.kind === "planned" - ? !hasExactKeys(value.assignment, ["kind", "agentId"]) || !isAgentMapBoundedText(value.assignment.agentId, 256) - : value.assignment.kind !== "unplanned" || !hasExactKeys(value.assignment, ["kind"]))) - throw new Error("invalid legacy Agent Map actor"); - return structuredClone(value) as unknown as LegacyE2ProposalActor; -} - export function parseMapChangeProposal( value: unknown, projectId?: string, diff --git a/packages/harness/src/shared/agent-map-legacy-migration.test.ts b/packages/harness/src/shared/agent-map-legacy-migration.test.ts new file mode 100644 index 000000000..eb0c81de3 --- /dev/null +++ b/packages/harness/src/shared/agent-map-legacy-migration.test.ts @@ -0,0 +1,57 @@ +import { readFile } from "node:fs/promises"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +import { describe, expect, it } from "vitest"; + +import { parseLegacyE2ProposalActor } from "./agent-map-legacy-migration.js"; + +describe("deployed E2 actor migration isolation", () => { + it("accepts both persisted E2 actor shapes and rejects unknown authority", () => { + expect( + parseLegacyE2ProposalActor({ + userId: "user-1", + sessionId: "session-1", + role: "map-planner", + assignment: null, + }), + ).toMatchObject({ userId: "user-1", sessionId: "session-1" }); + expect( + parseLegacyE2ProposalActor({ + userId: "user-1", + sessionId: "session-1", + role: "agent-builder", + assignment: { kind: "unplanned" }, + }), + ).toMatchObject({ userId: "user-1", sessionId: "session-1" }); + expect(() => + parseLegacyE2ProposalActor({ + userId: "user-1", + sessionId: "session-1", + role: "administrator", + assignment: null, + }), + ).toThrow("invalid legacy Agent Map actor"); + }); + + it("is referenced by the aggregate migration and no live service module", async () => { + const shared = dirname(fileURLToPath(import.meta.url)); + const core = join(shared, "..", "core"); + const aggregateMigration = await readFile( + join(core, "agent-map-aggregate-migration.ts"), + "utf8", + ); + expect(aggregateMigration).toContain("parseLegacyE2ProposalActor"); + + for (const live of [ + "agent-map-proposal-service.ts", + "agent-map-version.ts", + "build-plan-service.ts", + "subsession-coordinator.ts", + ]) { + await expect(readFile(join(core, live), "utf8")).resolves.not.toContain( + "parseLegacyE2ProposalActor", + ); + } + }); +}); diff --git a/packages/harness/src/shared/agent-map-legacy-migration.ts b/packages/harness/src/shared/agent-map-legacy-migration.ts new file mode 100644 index 000000000..c71f4b820 --- /dev/null +++ b/packages/harness/src/shared/agent-map-legacy-migration.ts @@ -0,0 +1,58 @@ +import { isAgentMapBoundedText } from "./agent-map-codec.js"; + +const isRecord = (value: unknown): value is Record => + typeof value === "object" && value !== null && !Array.isArray(value); + +const hasExactKeys = ( + value: Record, + keys: readonly string[], +): boolean => { + const actual = Object.keys(value).sort(); + const expected = [...keys].sort(); + return ( + actual.length === expected.length && + actual.every((key, index) => key === expected[index]) + ); +}; + +export interface LegacyE2ProposalActor { + userId: string; + sessionId: string; + role: "map-planner" | "agent-builder"; + assignment: + | { kind: "planned"; agentId: string } + | { kind: "unplanned" } + | null; +} + +/** + * Frozen decoder reachable only from the one deployed-E2 aggregate migration. + * Its retired role fields are discarded immediately after validation. + */ +export function parseLegacyE2ProposalActor( + value: unknown, +): LegacyE2ProposalActor { + if ( + !isRecord(value) || + !hasExactKeys(value, ["userId", "sessionId", "role", "assignment"]) || + !isAgentMapBoundedText(value.userId, 256) || + !isAgentMapBoundedText(value.sessionId, 256) + ) { + throw new Error("invalid legacy Agent Map actor"); + } + if (value.role === "map-planner" && value.assignment === null) { + return structuredClone(value) as unknown as LegacyE2ProposalActor; + } + if ( + value.role !== "agent-builder" || + !isRecord(value.assignment) || + (value.assignment.kind === "planned" + ? !hasExactKeys(value.assignment, ["kind", "agentId"]) || + !isAgentMapBoundedText(value.assignment.agentId, 256) + : value.assignment.kind !== "unplanned" || + !hasExactKeys(value.assignment, ["kind"])) + ) { + throw new Error("invalid legacy Agent Map actor"); + } + return structuredClone(value) as unknown as LegacyE2ProposalActor; +} diff --git a/packages/harness/src/shared/agent-map.ts b/packages/harness/src/shared/agent-map.ts index c75cc12f1..818ae5412 100644 --- a/packages/harness/src/shared/agent-map.ts +++ b/packages/harness/src/shared/agent-map.ts @@ -333,22 +333,6 @@ export type RoleNeutralMapOperationRecord = Readonly<{ acceptedAt: string; }>; -/** - * @deprecated Persisted rolling-compatibility metadata only. Live Agent Map - * authority uses {@link ProjectAgentSession}; role and assignment must never - * participate in authorization or capability composition. - */ -export type PlanningSessionIdentity = - | (SessionPrincipal & { role: "map-planner" }) - | (SessionPrincipal & { - role: "agent-builder"; - assignment: { kind: "planned"; agentId: string }; - }) - | (SessionPrincipal & { - role: "agent-builder"; - assignment: { kind: "unplanned" }; - }); - /** Live proposal attribution is the same role-neutral project actor vocabulary. */ export type ProposalActor = ProjectAgentActorRef; @@ -426,20 +410,6 @@ export type ProjectBootstrapState = reason: "user-proceeded" | "map-not-empty"; }; -/** @deprecated Persisted planner-era bootstrap error vocabulary. */ -export type PlannerGreetingErrorCode = ProjectBootstrapErrorCode; - -/** @deprecated Persisted planner-era bootstrap state. */ -export type PlannerGreetingState = - | Exclude - | { status: "skipped"; reason: "user-proceeded" }; - -export interface PlannerSessionMetadata { - identity: Extract; - greeting: PlannerGreetingState; - queuedInputIds: string[]; -} - /** * Lifecycle context for the one automatic map seed owned by a newly created * project. It is deliberately separate from ProjectAgentSession authority. @@ -524,88 +494,3 @@ export type ProjectBootstrapLifecycleEvent = errorCode: "delivery_uncertain"; queueDepth: number; }; - -export interface PlannerQueuedInput { - id: string; - sessionId: string; - text: string; - acceptedAt: string; -} - -export interface PlannerSessionRequest { - mode: "resume-or-create" | "fresh"; - harness?: import("./types.js").HarnessKind; - theme?: import("./types.js").UiTheme; -} - -export interface PlannerSessionResponse { - session: import("./types.js").HarnessSession; - resolution: "created" | "live" | "resumed" | "rehydrated"; -} - -export interface PlannerMessageRequest { - text: string; - /** Optional idempotency key while the durable bootstrap FIFO owns input. */ - requestId?: string; -} - -/** - * @deprecated Rolling planner-route response. The route now delegates to the - * neutral project-bootstrap coordinator and never recreates planner identity. - */ -export interface PlannerSessionMetadataResponse { - metadata: ProjectBootstrapMetadata | null; - /** Present only when the durable bootstrap FIFO handled this request. */ - receipt?: ProjectBootstrapInputReceipt; -} - -/** - * Content-free planner lifecycle telemetry. Callers may persist these fields, - * but must never add prompts, assistant text, local paths, or provider errors. - */ -export type PlannerLifecycleEvent = - | { - name: "planner_session.created" | "planner_session.resumed"; - projectId: StudioProjectId; - sessionId: string; - resolution: PlannerSessionResponse["resolution"]; - } - | { - name: "planner_greeting.attempted" | "planner_greeting.retried"; - projectId: StudioProjectId; - sessionId: string; - attemptId: string; - queueDepth: number; - } - | { - name: "planner_greeting.delivered"; - projectId: StudioProjectId; - sessionId: string; - attemptId: string; - queueDepth: number; - } - | { - name: "planner_greeting.failed"; - projectId: StudioProjectId; - sessionId: string; - attemptId?: string; - errorCode: PlannerGreetingErrorCode; - retryable: boolean; - queueDepth: number; - } - | { - name: "planner_greeting.skipped"; - projectId: StudioProjectId; - sessionId: string; - attemptId?: string; - reason: "user-proceeded"; - queueDepth: number; - } - | { - name: "planner_session.input_delivery_uncertain"; - projectId: StudioProjectId; - sessionId: string; - inputId: string; - errorCode: "delivery_uncertain"; - queueDepth: number; - }; diff --git a/packages/harness/src/shared/build-plan-codec.test.ts b/packages/harness/src/shared/build-plan-codec.test.ts index 4431e16d1..88cdccf85 100644 --- a/packages/harness/src/shared/build-plan-codec.test.ts +++ b/packages/harness/src/shared/build-plan-codec.test.ts @@ -66,7 +66,7 @@ describe("neutral build plan codecs", () => { const parsed = parseProjectBuildPlanVersion(input, projectId); expect(parsed).toEqual(input); expect(parsed).not.toBe(input); - expect(() => parseProjectBuildPlanVersion({ ...input, role: "map-planner" }, projectId)).toThrow(/invalid/u); + expect(() => parseProjectBuildPlanVersion({ ...input, authority: "forged" }, projectId)).toThrow(/invalid/u); expect(() => parseProjectBuildPlanVersion(input, "project_foreign")).toThrow(/cross-project/u); }); @@ -100,7 +100,7 @@ describe("neutral build plan codecs", () => { const withSemantic = { ...base, semanticDigest: computeAgentBriefSemanticDigest(base) }; const brief = { ...withSemantic, recordDigest: computeAgentBriefRecordDigest(withSemantic) } as AgentBriefVersion; expect(parseAgentBriefVersion(brief, projectId)).toEqual(brief); - const roleActor = { ...brief, authoredBy: { ...actor, role: "agent-builder" } }; - expect(() => parseAgentBriefVersion(roleActor, projectId)).toThrow(/actor/u); + const authorityActor = { ...brief, authoredBy: { ...actor, authority: "forged" } }; + expect(() => parseAgentBriefVersion(authorityActor, projectId)).toThrow(/actor/u); }); }); diff --git a/packages/harness/src/shared/types.ts b/packages/harness/src/shared/types.ts index 9ac32504c..daa043c52 100644 --- a/packages/harness/src/shared/types.ts +++ b/packages/harness/src/shared/types.ts @@ -214,11 +214,6 @@ export interface HarnessSession { * answer the blocking prompt themselves. */ ready: boolean; - /** - * @deprecated Legacy planner bootstrap metadata accepted only for rolling - * migration. It is never consulted for prompt, tool, sandbox, or authority. - */ - planning?: import("./agent-map.js").PlannerSessionMetadata; /** Durable lifecycle state for a new project's one automatic map seed. */ projectBootstrap?: import("./agent-map.js").ProjectBootstrapMetadata; /** Server-authored, path-free identity used only to revalidate MCP scope. */ @@ -863,20 +858,7 @@ export type AnalyticsEventType = | "project_bootstrap.failed" | "project_bootstrap.preempted" | "project_bootstrap.skipped" - | "project_bootstrap.input_delivery_uncertain" - | "planner_session.created" - | "planner_session.resumed" - | "planner_session.input_delivery_uncertain" - /** @deprecated Compatibility-only; ordinary project sessions use project bootstrap events. */ - | "planner_greeting.attempted" - /** @deprecated Compatibility-only; ordinary project sessions use project bootstrap events. */ - | "planner_greeting.delivered" - /** @deprecated Compatibility-only; ordinary project sessions use project bootstrap events. */ - | "planner_greeting.failed" - /** @deprecated Compatibility-only; ordinary project sessions use project bootstrap events. */ - | "planner_greeting.skipped" - /** @deprecated Compatibility-only; ordinary project sessions use project bootstrap events. */ - | "planner_greeting.retried"; + | "project_bootstrap.input_delivery_uncertain"; /** * The normalized event — the shape that (with opt-in) is batched to the diff --git a/packages/harness/web/e2e/new-session-composer.spec.ts b/packages/harness/web/e2e/new-session-composer.spec.ts index 1a8857b1b..4994aac31 100644 --- a/packages/harness/web/e2e/new-session-composer.spec.ts +++ b/packages/harness/web/e2e/new-session-composer.spec.ts @@ -39,7 +39,6 @@ const sessionEvidence = ( injectInputCalls: number; injectedSessionId: string | null; injectedText: string; - openPlannerSessionCalls: number; }> => page.evaluate(() => { const testState = ( @@ -48,7 +47,6 @@ const sessionEvidence = ( createSessionCalls?: unknown[]; injectInputCalls?: unknown[]; lastInjectInput?: { id?: string; req?: { text?: string } }; - openPlannerSessionCalls?: unknown[]; }; } ).__HARNESS_TEST__; @@ -61,7 +59,6 @@ const sessionEvidence = ( injectInputCalls: testState?.injectInputCalls?.length ?? 0, injectedSessionId: testState?.lastInjectInput?.id ?? null, injectedText: testState?.lastInjectInput?.req?.text ?? "", - openPlannerSessionCalls: testState?.openPlannerSessionCalls?.length ?? 0, }; }); @@ -119,7 +116,6 @@ test("Enter keeps a new-agent prompt in its exact session while the project map const before = await sessionEvidence(page); expect(before.activeSessionId).toBeNull(); expect(before.createSessionCalls).toBe(0); - expect(before.openPlannerSessionCalls).toBe(0); await page.getByTestId("rail-create-new").click(); const idea = "Build a sales outreach agent."; @@ -173,7 +169,6 @@ test("Enter keeps a new-agent prompt in its exact session while the project map const evidence = await sessionEvidence(page); expect(evidence.createSessionCalls).toBe(before.createSessionCalls + 1); - expect(evidence.openPlannerSessionCalls).toBe(before.openPlannerSessionCalls); expect(evidence.injectedSessionId).not.toBeNull(); expect(evidence.injectedSessionId).not.toBe("sess-competing-plan-agents"); expect(evidence.activeSessionId).toBe(evidence.injectedSessionId); @@ -225,7 +220,6 @@ test("returning to an in-progress standalone session does not restore the projec await expect(page.locator(".rail-workflows")).toBeVisible(); const before = await sessionEvidence(page); expect(before.createSessionCalls).toBe(0); - expect(before.openPlannerSessionCalls).toBe(0); await page.getByTestId("rail-create-new").click(); const idea = "Build a revisit guard agent."; @@ -264,9 +258,6 @@ test("returning to an in-progress standalone session does not restore the projec expect((await sessionEvidence(page)).activeSessionId).not.toBe(awaySessionId); await page.waitForTimeout(500); const afterReturn = await sessionEvidence(page); - expect(afterReturn.openPlannerSessionCalls).toBe( - beforeReturn.openPlannerSessionCalls, - ); await expect( page .getByTestId("workspace-group-acme-app/projects/build-revisit-guard") diff --git a/packages/harness/web/e2e/agent-map-planning.spec.ts b/packages/harness/web/e2e/project-map-navigation.spec.ts similarity index 97% rename from packages/harness/web/e2e/agent-map-planning.spec.ts rename to packages/harness/web/e2e/project-map-navigation.spec.ts index e37aaaf49..880cb931e 100644 --- a/packages/harness/web/e2e/agent-map-planning.spec.ts +++ b/packages/harness/web/e2e/project-map-navigation.spec.ts @@ -19,7 +19,6 @@ interface NavigationEvidence { activeSessionId: string | null; createSessionCalls: number; injectInputCalls: number; - openPlannerSessionCalls: number; } async function navigationEvidence(page: Page): Promise { @@ -30,7 +29,6 @@ async function navigationEvidence(page: Page): Promise { __HARNESS_TEST__?: { createSessionCalls?: unknown[]; injectInputCalls?: unknown[]; - openPlannerSessionCalls?: unknown[]; }; } ).__HARNESS_TEST__; @@ -38,7 +36,6 @@ async function navigationEvidence(page: Page): Promise { activeSessionId: activeSession, createSessionCalls: state?.createSessionCalls?.length ?? 0, injectInputCalls: state?.injectInputCalls?.length ?? 0, - openPlannerSessionCalls: state?.openPlannerSessionCalls?.length ?? 0, }; }, active); } @@ -109,7 +106,6 @@ test.describe("SAP-3148 project Agent Map navigation", () => { expect(after).toMatchObject({ createSessionCalls: before.createSessionCalls, injectInputCalls: before.injectInputCalls, - openPlannerSessionCalls: before.openPlannerSessionCalls, }); await expect(page.getByTestId("session-tab-sess-boot")).toHaveCount(0); expect( @@ -141,7 +137,6 @@ test.describe("SAP-3148 project Agent Map navigation", () => { expect(after).toMatchObject({ createSessionCalls: before.createSessionCalls, injectInputCalls: before.injectInputCalls, - openPlannerSessionCalls: before.openPlannerSessionCalls, }); }); @@ -167,7 +162,6 @@ test.describe("SAP-3148 project Agent Map navigation", () => { activeSessionId: "sess-boot", createSessionCalls: 0, injectInputCalls: 0, - openPlannerSessionCalls: 0, }); }); @@ -346,13 +340,9 @@ test.describe("SAP-3148 project Agent Map navigation", () => { changes: { name: "Campaign Marketing" }, }, ], - // E2's persisted attribution codec remains unchanged in SAP-3148; - // the UI deliberately projects it as one neutral project agent. actor: { userId: "user_mock", sessionId: "builder_mock", - role: "agent-builder", - assignment: { kind: "unplanned" }, }, acceptedAt: new Date().toISOString(), }, diff --git a/packages/harness/web/src/lib/api.test.ts b/packages/harness/web/src/lib/api.test.ts index 1e0a13e5e..dec7d1555 100644 --- a/packages/harness/web/src/lib/api.test.ts +++ b/packages/harness/web/src/lib/api.test.ts @@ -493,66 +493,6 @@ describe("RealApi.getSystemGraph", () => { }); }); -describe("RealApi planner mutations", () => { - afterEach(() => { - vi.unstubAllGlobals(); - }); - - it("retains the authoritative metadata returned by send and greeting retry", async () => { - if (isMockMode()) return; - vi.stubGlobal("window", { - __HARNESS__: { token: "test-token" }, - location: { search: "" }, - }); - const accepted = { - projectId: "project-1", - targetSessionId: "planner-1", - userId: "user-1", - bootstrap: { status: "skipped" as const, reason: "user-proceeded" }, - queuedInputIds: ["input-1"], - }; - const retrying = { - ...accepted, - bootstrap: { - status: "generating" as const, - attemptId: "attempt-2", - }, - queuedInputIds: [], - }; - const fetchMock = vi - .fn() - .mockResolvedValueOnce( - new Response(JSON.stringify({ metadata: accepted }), { status: 202 }), - ) - .mockResolvedValueOnce( - new Response(JSON.stringify({ metadata: retrying }), { status: 202 }), - ); - vi.stubGlobal("fetch", fetchMock); - - const api = createApi(); - await expect( - api.sendPlannerMessage("project-1", "planner-1", { text: "hello" }), - ).resolves.toEqual({ metadata: accepted }); - await expect( - api.retryPlannerGreeting("project-1", "planner-1"), - ).resolves.toEqual({ metadata: retrying }); - - expect(fetchMock).toHaveBeenNthCalledWith( - 1, - "/api/projects/project-1/planner-sessions/planner-1/messages", - expect.objectContaining({ - method: "POST", - body: JSON.stringify({ text: "hello" }), - }), - ); - expect(fetchMock).toHaveBeenNthCalledWith( - 2, - "/api/projects/project-1/planner-sessions/planner-1/greeting/retry", - expect.objectContaining({ method: "POST", body: "{}" }), - ); - }); -}); - describe("progressiveLeasingRun", () => { const at = (elapsed: number) => progressiveLeasingRun("exec-mock-prod-1", elapsed); diff --git a/packages/harness/web/src/lib/api.ts b/packages/harness/web/src/lib/api.ts index 46225cee5..897ecbc5f 100644 --- a/packages/harness/web/src/lib/api.ts +++ b/packages/harness/web/src/lib/api.ts @@ -48,11 +48,6 @@ import type { AcceptedProposalDelta, AgentMapWorkspaceResponse, MapOperation, - PlannerMessageRequest, - PlannerSessionMetadataResponse, - PlannerSessionRequest, - PlannerSessionResponse, - ProjectBootstrapMetadata, PutStudioCurrentWorkspaceRequest, StudioCurrentWorkspaceResponse, StudioProjectId, @@ -380,21 +375,6 @@ export interface HarnessApi { projectId: StudioProjectId, selection: StudioWorkspaceSelection, ): Promise; - openPlannerSession( - projectId: StudioProjectId, - request: PlannerSessionRequest, - ): Promise; - /** @deprecated Rolling alias into ordinary project-session input. */ - sendPlannerMessage( - projectId: StudioProjectId, - sessionId: string, - request: PlannerMessageRequest, - ): Promise; - /** @deprecated Compatibility-only; ordinary project sessions use project bootstrap. */ - retryPlannerGreeting( - projectId: StudioProjectId, - sessionId: string, - ): Promise; /** Revisioned local dependency projection for one server-issued workspace key. */ getSystemGraph( workspaceKey: WorkspaceKey, @@ -689,37 +669,6 @@ class RealApi implements HarnessApi { return parseStudioCurrentWorkspaceResponse(value, projectId); } - openPlannerSession( - projectId: StudioProjectId, - request: PlannerSessionRequest, - ): Promise { - return this.request( - `/api/projects/${encodeURIComponent(projectId)}/planner-sessions`, - { method: "POST", body: JSON.stringify(request) }, - ); - } - - async sendPlannerMessage( - projectId: StudioProjectId, - sessionId: string, - request: PlannerMessageRequest, - ): Promise { - return this.request( - `/api/projects/${encodeURIComponent(projectId)}/planner-sessions/${encodeURIComponent(sessionId)}/messages`, - { method: "POST", body: JSON.stringify(request) }, - ); - } - - async retryPlannerGreeting( - projectId: StudioProjectId, - sessionId: string, - ): Promise { - return this.request( - `/api/projects/${encodeURIComponent(projectId)}/planner-sessions/${encodeURIComponent(sessionId)}/greeting/retry`, - { method: "POST", body: "{}" }, - ); - } - async getSystemGraph( workspaceKey: WorkspaceKey, options: { refresh?: boolean } = {}, @@ -1926,8 +1875,6 @@ function goldenAgentMapFixture( const actor = { userId, sessionId, - role: "map-planner" as const, - assignment: null, }; const delta: AcceptedProposalDelta = { schemaVersion: 1, @@ -2074,9 +2021,6 @@ export class MockApi implements HarnessApi { this.fresh || this.noLiveSessions ? [] : MOCK_SESSIONS.map((session) => ({ ...session })); - /** Live planner records are mutable mock state, unlike the fixed history - * fixtures. They exercise the same record-refetch path as the real server. */ - private plannerSessionRecords = new Map(); private workflowsStore: WorkflowInfo[] = this.fresh ? [] : [ @@ -2572,338 +2516,6 @@ export class MockApi implements HarnessApi { return { ...current, selection, repaired: !valid }; } - async openPlannerSession( - projectId: StudioProjectId, - request: PlannerSessionRequest, - ): Promise { - if (typeof window !== "undefined") { - const win = window as unknown as { - __HARNESS_TEST__?: Record; - }; - const previous = - (win.__HARNESS_TEST__?.openPlannerSessionCalls as - | unknown[] - | undefined) ?? []; - win.__HARNESS_TEST__ = { - ...(win.__HARNESS_TEST__ ?? {}), - openPlannerSessionCalls: [...previous, { projectId, request }], - }; - } - const failure = - typeof window === "undefined" - ? null - : new URLSearchParams(window.location.search).get("mockPlanner"); - if (failure === "error") { - throw new ApiError( - 503, - "Project session service is unavailable", - "Project session service is unavailable", - ); - } - if (failure === "unauthorized") { - throw new ApiError( - 403, - "Project session is not available", - "Project session is not available", - ); - } - const existing = this.sessions - .filter( - (session) => - session.status !== "exited" && - session.agentMapIdentity?.projectId === projectId && - session.agentMapIdentity.userId === "user_mock", - ) - .sort((left, right) => - right.lastActiveAt.localeCompare(left.lastActiveAt), - )[0]; - if (request.mode === "resume-or-create" && existing) { - return { session: existing, resolution: "live" }; - } - const root = [...this.studioProjectIds.entries()].find( - ([, id]) => id === projectId, - )?.[0]; - if (!root) { - throw new ApiError( - 404, - "Studio project not found", - "Studio project not found", - ); - } - const session = await this.createSession({ - cwd: root, - harness: request.harness ?? "claude-code", - ...(request.theme ? { theme: request.theme } : {}), - }); - const greetingFixture = - typeof window === "undefined" - ? null - : new URLSearchParams(window.location.search).get("mockGreeting"); - session.agentMapIdentity = { - projectId, - sessionId: session.id, - userId: "user_mock", - }; - session.projectBootstrap = { - projectId, - targetSessionId: session.id, - userId: "user_mock", - bootstrap: - greetingFixture === "generating" - ? { status: "generating", attemptId: "attempt_mock" } - : greetingFixture === "failed" - ? { - status: "failed", - retryable: true, - errorCode: "model_turn_failed", - } - : { - status: "delivered", - messageId: "message_mock_greeting", - }, - queuedInputIds: [], - }; - const now = new Date().toISOString(); - this.plannerSessionRecords.set(session.id, { - harnessSessionId: session.id, - mergedSessionIds: [session.id], - agentSessionId: session.agentSessionId, - harness: session.harness, - cwd: session.cwd, - startedAt: now, - endedAt: null, - turns: - session.projectBootstrap.bootstrap.status === "delivered" - ? [ - { - index: 1, - prompt: null, - promptAt: null, - toolCalls: [], - assistantText: - "I inspected the available project context and kept the shared Agent Map honest. What would you like to build?", - model: "mock-project-agent", - usage: null, - completedAt: now, - incomplete: false, - }, - ] - : [], - turnCount: 0, - eventCount: - session.projectBootstrap.bootstrap.status === "delivered" ? 2 : 0, - reconstructed: true, - archivedAt: null, - limitations: [], - }); - return { session, resolution: "created" }; - } - - async sendPlannerMessage( - projectId: StudioProjectId, - sessionId: string, - request: PlannerMessageRequest, - ): Promise { - const session = this.sessions.find( - (candidate) => candidate.id === sessionId, - ); - const identity = session?.agentMapIdentity; - if ( - !session || - identity?.projectId !== projectId || - identity.sessionId !== sessionId || - identity.userId !== "user_mock" - ) { - throw new ApiError( - 403, - "Forbidden project session", - "Forbidden project session", - ); - } - const inputId = `input_mock_${Date.now()}`; - const bootstrap = session.projectBootstrap; - const shouldQueue = Boolean( - bootstrap && - bootstrap.bootstrap.status !== "delivered" && - bootstrap.bootstrap.status !== "skipped", - ); - if (bootstrap && shouldQueue) { - session.projectBootstrap = { - ...bootstrap, - bootstrap: { status: "skipped", reason: "user-proceeded" }, - queuedInputIds: [...bootstrap.queuedInputIds, inputId], - }; - } - await this.injectInput(sessionId, { text: request.text }); - const accepted = session.projectBootstrap - ? structuredClone(session.projectBootstrap) - : null; - const project = this.studioProjects()?.find( - (candidate) => candidate.projectId === projectId, - ); - const goldenFixtureEnabled = - typeof window !== "undefined" && - new URLSearchParams(window.location.search).get("mockAgentMapGolden") === - "1"; - setTimeout( - () => { - const current = this.sessions.find( - (candidate) => candidate.id === sessionId, - ); - const record = this.plannerSessionRecords.get(sessionId); - if (!current || !record) return; - const completedAt = new Date().toISOString(); - const turns = [ - ...record.turns, - { - index: record.turns.length + 1, - prompt: request.text, - promptAt: completedAt, - toolCalls: [], - assistantText: - "I’ll keep the shared Agent Map current where this work changes project architecture, and proceed directly where the request is already build-ready.", - model: "mock-project-agent", - usage: null, - completedAt, - incomplete: false, - }, - ]; - this.plannerSessionRecords.set(sessionId, { - ...record, - turns, - turnCount: record.turnCount + 1, - eventCount: record.eventCount + 2, - }); - if (shouldQueue && current.projectBootstrap) { - current.projectBootstrap = { - ...current.projectBootstrap, - queuedInputIds: current.projectBootstrap.queuedInputIds.filter( - (candidate) => candidate !== inputId, - ), - }; - } - void import("./events").then(({ publishMockBusMessage }) => { - if (goldenFixtureEnabled && !this.agentMapSnapshots.has(projectId)) { - if (!project) return; - const fixture = goldenAgentMapFixture( - project, - new Date().toISOString(), - identity.userId, - sessionId, - ); - this.agentMapSnapshots.set(projectId, fixture.snapshot); - publishMockBusMessage({ - type: "agent-map.proposal.changed", - delta: fixture.delta, - }); - } - publishMockBusMessage({ type: "session.status", session: current }); - publishMockBusMessage({ - type: "session.record.changed", - harnessSessionId: sessionId, - }); - }); - }, - goldenFixtureEnabled ? 0 : 250, - ); - return { metadata: accepted }; - } - - async retryPlannerGreeting( - projectId: StudioProjectId, - sessionId: string, - ): Promise { - const session = this.sessions.find( - (candidate) => candidate.id === sessionId, - ); - const identity = session?.agentMapIdentity; - if ( - !session || - identity?.projectId !== projectId || - identity.sessionId !== sessionId || - identity.userId !== "user_mock" - ) { - throw new ApiError( - 403, - "Forbidden project session", - "Forbidden project session", - ); - } - const retryFailure = - typeof window === "undefined" - ? null - : new URLSearchParams(window.location.search).get("mockGreetingRetry"); - if (retryFailure === "error") { - throw new ApiError( - 503, - "Greeting retry is temporarily unavailable", - "Greeting retry is temporarily unavailable", - ); - } - const metadata = session.projectBootstrap; - if ( - !metadata || - metadata.bootstrap.status !== "failed" || - !metadata.bootstrap.retryable || - metadata.queuedInputIds.length > 0 - ) { - throw new ApiError( - 409, - "Project bootstrap retry is not available", - "Project bootstrap retry is not available", - ); - } - session.projectBootstrap = { - ...metadata, - bootstrap: { status: "generating", attemptId: "attempt_mock_retry" }, - }; - const retrying: ProjectBootstrapMetadata = structuredClone( - session.projectBootstrap, - ); - setTimeout(() => { - const current = this.sessions.find( - (candidate) => candidate.id === sessionId, - ); - const record = this.plannerSessionRecords.get(sessionId); - if (!current?.projectBootstrap || !record) return; - const completedAt = new Date().toISOString(); - current.projectBootstrap = { - ...current.projectBootstrap, - bootstrap: { - status: "delivered", - messageId: "message_mock_greeting_retry", - }, - }; - this.plannerSessionRecords.set(sessionId, { - ...record, - turns: [ - ...record.turns, - { - index: record.turns.length + 1, - prompt: null, - promptAt: null, - toolCalls: [], - assistantText: - "I inspected the available project context and kept the shared Agent Map honest. What would you like to build?", - model: "mock-project-agent", - usage: null, - completedAt, - incomplete: false, - }, - ], - eventCount: record.eventCount + 2, - }); - void import("./events").then(({ publishMockBusMessage }) => { - publishMockBusMessage({ type: "session.status", session: current }); - publishMockBusMessage({ - type: "session.record.changed", - harnessSessionId: sessionId, - }); - }); - }, 250); - return { metadata: retrying }; - } - async getSystemGraph( workspaceKey: WorkspaceKey, options: { refresh?: boolean } = {}, @@ -3263,9 +2875,7 @@ export class MockApi implements HarnessApi { await delay(); // Null for an id with no fixture — the same "nothing recorded" answer the // real client returns for a 404, so the empty state is exercised too. - return ( - this.plannerSessionRecords.get(id) ?? MOCK_SESSION_RECORDS[id] ?? null - ); + return MOCK_SESSION_RECORDS[id] ?? null; } async resumeSession(id: string): Promise { diff --git a/packages/harness/web/src/lib/use-harness-state.ts b/packages/harness/web/src/lib/use-harness-state.ts index 12950cf4a..65c51c426 100644 --- a/packages/harness/web/src/lib/use-harness-state.ts +++ b/packages/harness/web/src/lib/use-harness-state.ts @@ -24,11 +24,7 @@ import type { TemplateDetailView, TemplateListResponse, } from "@shared/types"; -import type { - PlannerSessionRequest, - PlannerSessionResponse, - StudioProjectId, -} from "@shared/agent-map"; +import type { StudioProjectId } from "@shared/agent-map"; import { ApiError, @@ -190,12 +186,6 @@ export interface HarnessStateHook { /** A past session's reconstructed transcript (null when nothing was * recorded for it). Stable identity — safe as an effect dependency. */ sessionRecord: (id: string) => Promise; - /** @deprecated Rolling client alias that opens an ordinary project session - * through the bounded planner-named HTTP route. */ - openPlannerSession: ( - projectId: StudioProjectId, - request: PlannerSessionRequest, - ) => Promise; resumeSession: (harnessSessionId: string) => Promise; /** * Portable continue: a fresh session in `cwd`, seeded with our own @@ -1210,45 +1200,6 @@ export function useHarnessState(): HarnessStateHook { return [...(workflowProjectionOrder.current() ?? workflowsRef.current)]; }, [workflowProjectionOrder]); - /** One session projection for REST mutations and bus updates alike. */ - const upsertSession = useCallback((next: HarnessSession): void => { - setState((prev) => { - if (!prev) return prev; - const sessions = prev.sessions.some((session) => session.id === next.id) - ? prev.sessions.map((session) => - session.id === next.id ? next : session, - ) - : [...prev.sessions, next]; - return { ...prev, sessions }; - }); - }, []); - - const openPlannerSession = useCallback( - async ( - projectId: StudioProjectId, - request: PlannerSessionRequest, - ): Promise => { - const response = await api.openPlannerSession(projectId, request); - // A launch can emit session.status before its HTTP response crosses the - // wire. Preserve that newer full-session projection when it is already - // present; still insert the response if no bus-backed row exists. - if (!sessionStatusRevisions.current.has(response.session.id)) { - upsertSession(response.session); - } else { - setState((prev) => { - if (!prev) return prev; - return prev.sessions.some( - (session) => session.id === response.session.id, - ) - ? prev - : { ...prev, sessions: [...prev.sessions, response.session] }; - }); - } - return response; - }, - [upsertSession], - ); - useEffect(() => { return subscribeEvents( (message) => { @@ -2394,7 +2345,6 @@ export function useHarnessState(): HarnessStateHook { getTemplate, getWorkflowInputContract, sessionRecord, - openPlannerSession, resumeSession, rehydrateSession, resumeFromHistory, diff --git a/scripts/agent-studio-terminology-allowlist.json b/scripts/agent-studio-terminology-allowlist.json index 77555a501..625c10d3b 100644 --- a/scripts/agent-studio-terminology-allowlist.json +++ b/scripts/agent-studio-terminology-allowlist.json @@ -544,5 +544,69 @@ "pattern": "^/api/workflows/:id/secrets(?:/(?:import|flush|:key))?$", "occurrences": 5, "reason": "Studio localhost API routes remain stable for existing clients, and these sit beside the existing /api/workflows/:id/deploy family." + }, + { + "id": "legacy-proposal-actor-decoder", + "rule": "unified-agent-model", + "path": "packages/harness/src/shared/agent-map-legacy-migration.ts", + "pattern": "map-planner|agent-builder", + "occurrences": 2, + "reason": "Read-only E2 proposal-history decoder; live writes use ProjectAgentActorRef and the migration module is reachability-tested." + }, + { + "id": "legacy-proposal-actor-decoder-tests", + "rule": "unified-agent-model", + "path": "packages/harness/src/shared/agent-map-legacy-migration.test.ts", + "pattern": "map-planner|agent-builder", + "occurrences": 2, + "reason": "Exact fixtures prove both retired E2 actor shapes migrate and cannot reach live proposal services." + }, + { + "id": "legacy-agent-map-aggregate-fixture", + "rule": "unified-agent-model", + "path": "packages/harness/src/core/agent-map-aggregate-migration.test.ts", + "pattern": "map-planner|agent-builder", + "occurrences": 2, + "reason": "Migration fixture proves deployed E2 history becomes role-neutral without data loss." + }, + { + "id": "legacy-project-bootstrap-fixture", + "rule": "unified-agent-model", + "path": "packages/harness/src/core/project-bootstrap.test.ts", + "pattern": "map-planner", + "occurrences": 1, + "reason": "Migration fixture proves a durable pre-upgrade input FIFO is normalized without losing input or replaying bootstrap." + }, + { + "id": "legacy-session-migration-fixtures", + "rule": "unified-agent-model", + "path": "packages/harness/src/core/session-manager.test.ts", + "pattern": "map-planner|agent-builder", + "occurrences": 3, + "reason": "Persisted-session fixtures prove valid metadata migrates and malformed or conflicting authority remains safely preserved." + }, + { + "id": "legacy-bootstrap-event-decoder", + "rule": "unified-agent-model", + "path": "packages/harness/src/core/project-session-legacy-migration.ts", + "pattern": "plannerOrigin", + "occurrences": 1, + "reason": "Read-only decoder for the infrastructure bootstrap marker written into durable prompt events by released pre-unification builds." + }, + { + "id": "legacy-bootstrap-event-folding-fixture", + "rule": "unified-agent-model", + "path": "packages/harness/src/core/session-record.test.ts", + "pattern": "plannerOrigin", + "occurrences": 1, + "reason": "Released-event fixture proves the private infrastructure bootstrap prompt never becomes a human transcript turn after upgrade." + }, + { + "id": "legacy-bootstrap-event-ledger", + "rule": "unified-agent-model", + "path": "docs/plans/agent-studio-plan-first-agent-map/sap-3152-cutover-ledger.md", + "pattern": "plannerOrigin", + "occurrences": 1, + "reason": "The cutover register names the exact retained persisted-event key and documents its decoder-only compatibility boundary." } ] diff --git a/scripts/agent-studio-terminology-check.mjs b/scripts/agent-studio-terminology-check.mjs index 9c4a25ce0..4606f5c66 100644 --- a/scripts/agent-studio-terminology-check.mjs +++ b/scripts/agent-studio-terminology-check.mjs @@ -36,22 +36,49 @@ const SCANNED_EXTENSIONS = new Set([ ...TEXT_EXTENSIONS, ".json", ]); -const SKIPPED_PATH_PARTS = new Set([ +const ALWAYS_SKIPPED_PATH_PARTS = new Set([ + "dist", + "node_modules", + "release", +]); +const WORKFLOW_SKIPPED_PATH_PARTS = new Set([ "__fixtures__", "__snapshots__", "__tests__", - "dist", "e2e", - "node_modules", - "release", "test", "tests", ]); const TEST_FILE_RE = /(?:^|\/)[^/]+\.(?:spec|test)\.[cm]?[jt]sx?$/; -const WORKFLOW_TOKEN_RE = /workflows?/giu; +const TERMINOLOGY_RULES = [ + { + id: "workflow", + label: "Human-readable Workflow terminology", + regex: /workflows?/giu, + appliesTo: (sourcePath) => { + const normalized = toPosix(sourcePath); + const parts = normalized.split("/"); + return ( + !normalized.startsWith("docs/") && + !normalized.startsWith(".changeset/") && + !parts.some((part) => WORKFLOW_SKIPPED_PATH_PARTS.has(part)) && + !TEST_FILE_RE.test(normalized) + ); + }, + }, + { + id: "unified-agent-model", + label: "Retired project-agent authority terminology", + regex: + /map-planner|agent-builder|PlanningSessionIdentity|planning-readonly|BuilderPlanningSubmission|planning_result_submit|implementationEligible|source-not-confirmed|BUILDER_BOOTSTRAP_|assertPlanner|forbidden_role|plannerOrigin/giu, + appliesTo: () => true, + }, +]; const STATIC_TARGETS = [ ".github/workflows/desktop-release.yml", + ".changeset", + "docs", "package.json", "packages/agent-core/package.json", "packages/agent-core/src", @@ -70,6 +97,7 @@ const STATIC_TARGETS = [ "packages/harness/web/index.html", "packages/harness/web/public", "packages/harness/web/src", + "packages/harness/web/e2e", "packages/harness-desktop/electron-builder.yml", "packages/harness-desktop/package.json", "packages/harness-desktop/src", @@ -83,8 +111,7 @@ function toPosix(value) { function isScannable(relativePath) { const normalized = toPosix(relativePath); const parts = normalized.split("/"); - if (parts.some((part) => SKIPPED_PATH_PARTS.has(part))) return false; - if (TEST_FILE_RE.test(normalized)) return false; + if (parts.some((part) => ALWAYS_SKIPPED_PATH_PARTS.has(part))) return false; return SCANNED_EXTENSIONS.has(path.extname(normalized).toLowerCase()); } @@ -104,7 +131,7 @@ async function collectFiles(rootDir, relativeTarget) { for (const entry of entries) { const child = path.join(relativeTarget, entry.name); if (entry.isDirectory()) { - if (!SKIPPED_PATH_PARTS.has(entry.name)) + if (!ALWAYS_SKIPPED_PATH_PARTS.has(entry.name)) files.push(...(await collectFiles(rootDir, child))); } else if (entry.isFile() && isScannable(child)) { files.push(toPosix(child)); @@ -381,11 +408,21 @@ function compileAllowlist(entries) { return entries.map((entry, index) => { if (!entry || typeof entry !== "object") throw new Error(`allowlist entry ${index + 1} is not an object`); - const { id, path: entryPath, pattern, reason, occurrences } = entry; + const { + id, + rule = "workflow", + path: entryPath, + pattern, + reason, + occurrences, + } = entry; if (typeof id !== "string" || id.trim() === "") throw new Error(`allowlist entry ${index + 1} has no id`); if (ids.has(id)) throw new Error(`duplicate allowlist id: ${id}`); ids.add(id); + if (!TERMINOLOGY_RULES.some((candidate) => candidate.id === rule)) { + throw new Error(`allowlist entry ${id} has unknown rule ${rule}`); + } if (typeof entryPath !== "string" || entryPath.trim() === "") { throw new Error(`allowlist entry ${id} has no exact path`); } @@ -405,7 +442,7 @@ function compileAllowlist(entries) { `allowlist entry ${id} must declare a positive occurrence count`, ); } - return { ...entry, regex: new RegExp(pattern, "giu"), used: 0 }; + return { ...entry, rule, regex: new RegExp(pattern, "giu"), used: 0 }; }); } @@ -445,12 +482,15 @@ export function auditSources({ sources, allowlist = [] }) { for (const source of sources) { for (const segment of sourceSegments(source)) { - WORKFLOW_TOKEN_RE.lastIndex = 0; - for (const match of segment.value.matchAll(WORKFLOW_TOKEN_RE)) { + for (const rule of TERMINOLOGY_RULES) { + if (!rule.appliesTo(source.path)) continue; + rule.regex.lastIndex = 0; + for (const match of segment.value.matchAll(rule.regex)) { const tokenStart = match.index; const tokenEnd = tokenStart + match[0].length; const allowed = compiled.find( (entry) => + entry.rule === rule.id && entry.path === source.path && patternCovers(entry, segment.value, tokenStart, tokenEnd), ); @@ -463,9 +503,11 @@ export function auditSources({ sources, allowlist = [] }) { path: source.path, line: position.line, column: position.column, + rule: rule.id, token: match[0], context: segment.jsonPath ?? contextAt(segment.value, tokenStart), }); + } } } } @@ -502,10 +544,10 @@ export async function auditRepository({ function formatFailure(result) { const lines = []; if (result.violations.length > 0) { - lines.push("Human-readable Workflow terminology found:"); + lines.push("Disallowed Agent Studio terminology found:"); for (const violation of result.violations) { lines.push( - ` ${violation.path}:${violation.line}:${violation.column} ${violation.token} — ${violation.context}`, + ` [${violation.rule}] ${violation.path}:${violation.line}:${violation.column} ${violation.token} — ${violation.context}`, ); } } @@ -513,7 +555,7 @@ function formatFailure(result) { lines.push("Stale terminology allowlist entries found:"); for (const entry of result.unusedAllowlist) { lines.push( - ` ${entry.id} — ${entry.path} / ${entry.pattern} (expected ${entry.occurrences}, matched ${entry.used})`, + ` ${entry.id} [${entry.rule}] — ${entry.path} / ${entry.pattern} (expected ${entry.occurrences}, matched ${entry.used})`, ); } } diff --git a/scripts/agent-studio-terminology-check.test.mjs b/scripts/agent-studio-terminology-check.test.mjs index 32d7876c1..82b3571b4 100644 --- a/scripts/agent-studio-terminology-check.test.mjs +++ b/scripts/agent-studio-terminology-check.test.mjs @@ -12,9 +12,10 @@ function source(content, kind = "code", sourcePath = fixturePath) { return { path: sourcePath, kind, content }; } -function allowed(id, pattern, occurrences = 1) { +function allowed(id, pattern, occurrences = 1, rule = "workflow") { return { id, + rule, path: fixturePath, pattern, occurrences, @@ -209,6 +210,39 @@ describe("Agent Studio terminology guard", () => { assert.equal(result.unusedAllowlist[0].occurrences, 2); }); + it("rejects retired project-agent authority terms in code, tests, and prose", () => { + const result = auditSources({ + sources: [ + source('export const authority = "map-planner";'), + source('it("never becomes planning-readonly", () => {});', "code", "packages/harness/src/example.test.ts"), + source("A BuilderPlanningSubmission authorizes coding.", "text", "docs/example.md"), + source('export const retiredEventKey = "plannerOrigin";', "code", "packages/harness/src/legacy-event.ts"), + ], + }); + + assert.deepEqual( + result.violations.map(({ rule, token }) => [rule, token]), + [ + ["unified-agent-model", "map-planner"], + ["unified-agent-model", "planning-readonly"], + ["unified-agent-model", "BuilderPlanningSubmission"], + ["unified-agent-model", "plannerOrigin"], + ], + ); + }); + + it("scopes retained migration literals to the exact rule, path, and count", () => { + const result = auditSources({ + sources: [source('export const retired = "agent-builder";')], + allowlist: [ + allowed("retired-migration", "agent-builder", 1, "unified-agent-model"), + ], + }); + + assert.deepEqual(result.violations, []); + assert.deepEqual(result.unusedAllowlist, []); + }); + it("keeps the repository-owned scope and allowlist in sync", async () => { const result = await auditRepository(); @@ -218,6 +252,8 @@ describe("Agent Studio terminology guard", () => { result.files.includes("packages/harness-desktop/src/preload/desktop.mts"), ); assert.ok(result.files.includes("packages/harness/web/src/styles.css")); + assert.ok(result.files.includes("packages/harness/web/e2e/project-map-navigation.spec.ts")); + assert.ok(result.files.some((file) => file.startsWith(".changeset/"))); assert.deepEqual(result.violations, []); assert.deepEqual(result.unusedAllowlist, []); });