From ec2210e4f887b600d43fcb3f44b6b949af908636 Mon Sep 17 00:00:00 2001 From: Dotta Date: Fri, 9 Oct 2026 16:46:05 -0500 Subject: [PATCH] feat: give tasks durable project-optional workspaces Separate task organization from filesystem ownership. Add channel defaults, workspace and repository tools, safe admission transitions, and sparse native workspace persistence. Co-Authored-By: Paperclip --- doc/DATABASE.md | 9 + doc/DEVELOPING.md | 9 + doc/SPEC-implementation.md | 11 + doc/SPEC.md | 7 + doc/observability.md | 20 +- doc/plans/2026-10-09-task-workspaces.md | 127 +++ .../src/command-managed-runtime.ts | 14 + .../adapter-utils/src/execution-target.ts | 11 + .../adapter-utils/src/git-workspace-sync.ts | 52 +- .../src/sandbox-managed-runtime.ts | 177 +++- .../src/workspace-checkpoint.test.ts | 146 +++ .../adapter-utils/src/workspace-checkpoint.ts | 153 +++ .../src/workspace-restore-merge.ts | 2 +- .../adapter-utils/src/workspace-seed-cache.ts | 66 ++ .../db/src/migrations/0324_needy_wong.sql | 49 + ...{0319_snapshot.json => 0324_snapshot.json} | 939 +++++++++++++++++- packages/db/src/migrations/meta/_journal.json | 7 + packages/db/src/schema/chat_channels.ts | 3 + .../execution_workspace_repositories.ts | 30 + .../db/src/schema/execution_workspaces.ts | 2 +- packages/db/src/schema/index.ts | 2 + packages/db/src/schema/issues.ts | 5 +- .../docs/capability-contract.md | 44 +- .../generated/capability/capabilities.yaml | 90 +- .../capability/capability-contract.md | 4 +- .../capability/semantic-tool-contracts.json | 2 +- .../generated/semantic-action-catalog.json | 230 +++++ .../evals/native-execution-seeded.json | 2 +- .../paperclip-runner/protocol/manifest.json | 2 +- .../check-capability-inventory.test.mjs | 2 +- .../scripts/lib/capability-inventory.mjs | 2 +- .../spec/capability/capabilities.yaml | 144 +-- .../spec/capability/protocol-coverage.json | 78 +- .../src/catalog/reconciliation.test.ts | 14 +- .../catalog/semantic-action-catalog.test.ts | 2 +- .../src/catalog/semantic-action-catalog.ts | 8 + .../src/catalog/semantic-action-types.ts | 5 + .../src/drivers/acpx/permission-policy.ts | 2 +- .../src/eval/workflow-evals.test.ts | 6 +- .../src/generated/capability-contract.ts | 4 +- .../src/protocol-actions/get-workspace.ts | 73 ++ .../src/protocol-actions/index.ts | 9 + .../src/protocol-actions/list-workspaces.ts | 76 ++ .../protocol-actions/prepare-repository.ts | 123 +++ .../src/protocol-actions/select-workspace.ts | 142 +++ .../src/semantic-tools/discovery.ts | 4 +- .../src/semantic-tools/dispatcher.ts | 4 + .../src/semantic-tools/paperclip-discovery.ts | 2 +- .../src/semantic-tools/semantic-tools.test.ts | 4 +- .../src/semantic-tools/types.ts | 4 + packages/plugins/sdk/src/types.ts | 4 +- packages/shared/src/index.ts | 5 + packages/shared/src/types/chat-channels.ts | 11 + packages/shared/src/types/index.ts | 2 + packages/shared/src/types/issue.ts | 4 + packages/shared/src/types/task-workspace.ts | 17 + .../shared/src/types/workspace-runtime.ts | 8 +- .../shared/src/validators/chat-channels.ts | 10 +- packages/shared/src/validators/index.ts | 4 + packages/shared/src/validators/issue.ts | 3 + .../shared/src/validators/task-workspace.ts | 15 + .../src/validators/workspace-repository.ts | 12 + .../chat-channels.integration.test.ts | 33 + .../__tests__/chat-execution-defaults.test.ts | 32 + .../email-channels.integration.test.ts | 16 + .../execution-workspace-reopen.test.ts | 27 +- .../execution-workspaces-service.test.ts | 38 +- server/src/__tests__/file-resources.test.ts | 32 + .../heartbeat-project-repositories.test.ts | 11 +- .../heartbeat-workspace-busy.test.ts | 9 +- .../heartbeat-workspace-session.test.ts | 26 +- .../issue-closed-workspace-routes.test.ts | 1 + .../managed-repository-checkout.test.ts | 54 + .../native-workspace-sync-history.test.ts | 36 +- .../task-workspace-admission.test.ts | 37 + .../src/__tests__/task-workspace-api.test.ts | 39 + .../__tests__/task-workspace-binding.test.ts | 138 +++ ...workspace-repositories-persistence.test.ts | 133 +++ .../task-workspace-repositories.test.ts | 34 + .../__tests__/task-workspace-tools.test.ts | 23 + server/src/app.ts | 2 + server/src/redaction.ts | 2 + server/src/routes/execution-workspaces.ts | 4 +- server/src/routes/issues.ts | 28 +- server/src/routes/task-workspaces.ts | 48 + server/src/services/chat-channels.ts | 61 +- .../src/services/chat-execution-defaults.ts | 42 + server/src/services/company-deletion.ts | 4 + server/src/services/email-channels.ts | 18 + .../services/execution-workspace-policy.ts | 18 + .../execution-workspace-repositories.ts | 194 ++++ server/src/services/execution-workspaces.ts | 157 ++- server/src/services/heartbeat.ts | 503 ++++++---- server/src/services/heartbeat/workspaces.ts | 230 +---- server/src/services/issues.ts | 111 +-- .../services/managed-repository-checkout.ts | 160 +++ .../native-runtime/native-chat-workspace.ts | 50 +- .../native-runtime/native-run-trace.test.ts | 28 +- .../native-runtime/native-run-trace.ts | 30 +- .../native-session-executor.test.ts | 6 +- .../native-runtime/native-session-executor.ts | 10 +- .../native-runtime/native-workspace-sync.ts | 76 +- .../paperclip-runner-tool-authority.ts | 12 +- server/src/services/projects.ts | 20 +- server/src/services/runtime-service-env.ts | 18 + server/src/services/task-workspace-tools.ts | 38 + .../src/services/workspace-file-resources.ts | 10 +- server/src/services/workspace-runtime.ts | 23 +- skills/paperclip/SKILL.md | 6 + .../paperclip/references/issue-workspaces.md | 16 +- ui/src/api/chatEndpoints.ts | 4 +- .../ProjectWorkspaceSummaryCard.tsx | 4 +- .../components/ProjectWorkspacesContent.tsx | 17 +- .../issue-properties/IssueProperties.tsx | 2 +- ui/src/pages/ExecutionWorkspaceDetail.tsx | 8 +- ui/src/pages/Workspaces.test.tsx | 12 + ui/src/pages/Workspaces.tsx | 16 +- ui/src/pages/apps/chat/ChatEndpointDetail.tsx | 33 +- .../apps/chat/ChatExecutionDefaults.test.tsx | 40 + .../pages/apps/chat/ChatExecutionDefaults.tsx | 107 ++ 120 files changed, 4984 insertions(+), 921 deletions(-) create mode 100644 doc/plans/2026-10-09-task-workspaces.md create mode 100644 packages/adapter-utils/src/workspace-checkpoint.test.ts create mode 100644 packages/adapter-utils/src/workspace-checkpoint.ts create mode 100644 packages/adapter-utils/src/workspace-seed-cache.ts create mode 100644 packages/db/src/migrations/0324_needy_wong.sql rename packages/db/src/migrations/meta/{0319_snapshot.json => 0324_snapshot.json} (98%) create mode 100644 packages/db/src/schema/execution_workspace_repositories.ts create mode 100644 packages/paperclip-runner/src/protocol-actions/get-workspace.ts create mode 100644 packages/paperclip-runner/src/protocol-actions/list-workspaces.ts create mode 100644 packages/paperclip-runner/src/protocol-actions/prepare-repository.ts create mode 100644 packages/paperclip-runner/src/protocol-actions/select-workspace.ts create mode 100644 packages/shared/src/types/task-workspace.ts create mode 100644 packages/shared/src/validators/task-workspace.ts create mode 100644 packages/shared/src/validators/workspace-repository.ts create mode 100644 server/src/__tests__/chat-execution-defaults.test.ts create mode 100644 server/src/__tests__/managed-repository-checkout.test.ts create mode 100644 server/src/__tests__/task-workspace-admission.test.ts create mode 100644 server/src/__tests__/task-workspace-api.test.ts create mode 100644 server/src/__tests__/task-workspace-binding.test.ts create mode 100644 server/src/__tests__/task-workspace-repositories-persistence.test.ts create mode 100644 server/src/__tests__/task-workspace-repositories.test.ts create mode 100644 server/src/__tests__/task-workspace-tools.test.ts create mode 100644 server/src/routes/task-workspaces.ts create mode 100644 server/src/services/chat-execution-defaults.ts create mode 100644 server/src/services/execution-workspace-repositories.ts create mode 100644 server/src/services/managed-repository-checkout.ts create mode 100644 server/src/services/runtime-service-env.ts create mode 100644 server/src/services/task-workspace-tools.ts create mode 100644 ui/src/pages/apps/chat/ChatExecutionDefaults.test.tsx create mode 100644 ui/src/pages/apps/chat/ChatExecutionDefaults.tsx diff --git a/doc/DATABASE.md b/doc/DATABASE.md index c6e9c5474d3..8b15cff8db0 100644 --- a/doc/DATABASE.md +++ b/doc/DATABASE.md @@ -735,3 +735,12 @@ Use the same root client for in-process writers. New queue insertion paths must register before writing, or they can remain unseen until the next startup or another notification. Tests should verify both the producer and its outer transaction boundary. + + +### Task workspace records (2026-10-09) + +Migration 0321 makes `execution_workspaces.project_id` nullable and changes its project reference to `ON DELETE RESTRICT`: source-project policy cannot disappear while a workspace still depends on it. `issues.execution_workspace_id` remains the sole active task binding. Versioned selection/pending-selection JSON and a binding revision record intent and protect transitions; they are not additional active bindings. + +`execution_workspace_repositories` is company-scoped inventory beneath an execution workspace. It records repository identity, contained relative path, requested ref, pinned commit, preparation state and idempotency receipts. Credentials are resolved at use and are never stored in this inventory. Connection/destination `execution_defaults` JSON contains optional typed project/workspace choices, never arbitrary host paths or commands. + +No migration scans or copies existing agent home directories. Keep admitted native descriptors and recovery references until compatible controllers finish their persistence obligations. See [the architecture rollout contract](plans/2026-10-09-task-workspaces.md). diff --git a/doc/DEVELOPING.md b/doc/DEVELOPING.md index 26ad54fa183..117cfd61819 100644 --- a/doc/DEVELOPING.md +++ b/doc/DEVELOPING.md @@ -2079,3 +2079,12 @@ Chromium. The test starts its own loopback Vite server and mocks API responses; it needs no running Paperclip instance or provider credentials. The same spec lives in the default `test:e2e` discovery tree, so the existing Chrome CI shards run it on pull requests. + + +### Workspace feature checks (2026-10-09) + +Task files, personal agent state (`AGENT_HOME`), and provider home are distinct. New filesystem tasks without an explicit source use a stable company/task directory. Debug workspace placement through the task workspace API rather than inferring it from organizational project or agent identity. + +Focused coverage lives in `task-workspace-binding.test.ts`, `task-workspace-repositories*.test.ts`, `task-workspace-tools.test.ts`, channel integration tests, and adapter-utils checkpoint/native persistence tests. Full handoff still requires the repository typecheck, test, and build commands. Remote transport fixtures establish protocol behavior; they do not qualify production provider throughput. + +Channel defaults can be configured in Apps connection settings and per destination. They affect new conversations only. A queued selection or repository request is applied on the next normal admission; configuration changes do not interrupt a live run. See [the architecture](plans/2026-10-09-task-workspaces.md). diff --git a/doc/SPEC-implementation.md b/doc/SPEC-implementation.md index f9f5bb5e0d9..83a486d1cf8 100644 --- a/doc/SPEC-implementation.md +++ b/doc/SPEC-implementation.md @@ -2115,3 +2115,14 @@ authentication, replay, document-sized limits, inspection, and deletion semantic Company Settings → General can configure one shared API-key connection for optional internal decisions. V1 supports OpenAI Decisions and Jev through OpenRouter. Companies start unconfigured; background sponsorship defaults on when configured, while an explicit off setting persists. Human and agent calls retain current responsible-user, connection audience, resource, and agent installation checks. Only explicitly registered internal background features can use company sponsorship. The internal service provides local-only availability and bounded, reauthorized execution. It records metadata-only invocation history and independent fractional service charges in the existing cost ledger, applying company and applicable agent/project budgets under the accounting lock. Unknown dispatch charges retain reservations until audited resolution. Settings/testing require connection management permission; history uses existing cost visibility. See [decision-models.md](decision-models.md) for the contract, supported models, accounting, privacy, and endpoints. + + +### Task workspace ownership (2026-10-09) + +A task's organizational project and filesystem workspace are independent. New tasks that need a filesystem and have no selected/configured workspace get a company/task directory, not the agent home. Plain tasks need neither Git nor a project. Existing bindings and admitted roots remain authoritative; changing project does not move files. Explicit workspace selections apply at a subsequent normal admission after prior execution and persistence obligations settle. + +Chat connection and destination settings may supply project/workspace defaults. Destination fields override connection fields; omitted values inherit and null clears the inherited choice. Resolve and authorize these defaults once for a new conversation, then retain the resulting task intent. Existing conversations do not change when settings change. Trust confinement still takes precedence. + +Agents inspect/select workspaces and request contained repository preparation through ordinary authorized APIs. Repository preparation is idempotent and may require the next admission; it never claims a host-only clone is available in an already-running remote session. Simultaneous tasks may still explicitly share one physical local/SSH folder. Remote sharing requires actual placement and coherent persistence capability. + +See [the layer architecture](plans/2026-10-09-task-workspaces.md) for resolution rules, module ownership, persistence versions, rollout, and verification. diff --git a/doc/SPEC.md b/doc/SPEC.md index 3608565f43f..ef52466d7fe 100644 --- a/doc/SPEC.md +++ b/doc/SPEC.md @@ -754,3 +754,10 @@ authentication, replay, document-sized limits, inspection, and deletion semantic ### Managed decision models A company may configure a shared decision model for optional Paperclip features. The instance owns credential resolution, authorization, budget admission, and attributable service charges. Company-sponsored background use is enabled by default during configuration; explicit opt-out persists. User and agent requests keep their own access boundaries and cannot become sponsored background requests after denial. Availability is a cheap local capability check, and metadata-only request history makes service usage inspectable. The implemented V1 contract is in [decision-models.md](decision-models.md). + + +### Task workspace ownership (2026-10-09) + +Organizational projects, task files, personal agent home, and provider home are separate concepts. App and chat tasks share one workspace policy: preserve a binding or explicit configured folder, otherwise allocate a stable task directory. A repository is optional and can be prepared without creating a project. Optional connection and destination defaults feed the same task policy; they do not grant permissions or relocate existing conversations. + +Agents select authorized workspace/repository intent while the harness owns paths, preparation, persistence and safe transitions. Intentional simultaneous sharing of one physical local/SSH folder remains supported. Provider sessions keep their admitted root through completion and recovery. See [SPEC-implementation.md](SPEC-implementation.md) and [the layer architecture](plans/2026-10-09-task-workspaces.md) for the versioned contracts and rollout constraints. diff --git a/doc/observability.md b/doc/observability.md index 56c550161bc..9898171d343 100644 --- a/doc/observability.md +++ b/doc/observability.md @@ -163,14 +163,17 @@ Those sections follow below. ## Native Runner Trace Spans Paperclip Runner task runs emit a single foldable OpenTelemetry trace. This is -the native-run trace schema version `2`. `task.run` is the only full-run root; +the native-run trace schema version `3`. `task.provider_session` is the native +provider lifecycle root; it includes preparation and provider execution, but +ends before outer workspace persistence. The heartbeat-owned local run-log +`task.run.measured` records terminal elapsed time including durable copyback; every other native span carries a real OpenTelemetry parent context rather than only a descriptive `parentName` field. The canonical lifecycle is: ```text -task.run +task.provider_session ├── heartbeat.queue ├── task.prepare │ ├── environment.startup @@ -252,10 +255,11 @@ and outcome. Native child attributes use the bounded `paperclip.native.span.` prefix and a closed key allowlist; values are limited to finite numbers, booleans, or short strings. Commands, arguments, environment values, paths, output, credentials, and raw identifiers are discarded by the -trace helper. `task.run.measured` remains in the local run log for compatibility -but is not exported as a second full-width OTel span. +trace helper. `task.provider_session.measured` records that same interval in the +local run log, without a duplicate full-width OTel span. The separate heartbeat +`task.run.measured` event is the authoritative end-to-end duration. Persisted `run.performance.span` events retain the v1 run-log schema and include -`traceSchemaVersion: 2` so local tooling can distinguish the hierarchy. +`traceSchemaVersion: 3` so local tooling can distinguish the hierarchy. Like every span in this document, native-run spans are opt-in. When `OTEL_EXPORTER_OTLP_ENDPOINT` is unset, the tracer remains a no-op; the local @@ -1013,16 +1017,16 @@ spans. A run-time (`sandbox.agentSession.sendInput`, `sandbox.agentSession.pollOutput`, `sandbox.callbackBridge.relayRequest`, or `sandbox.agentProcess`). Each run-time wrapper span parents to the live run span (`agent.turn` during the turn, -`task.run` otherwise). With no active trace context the exec span opens +`task.provider_session` otherwise). With no active trace context the exec span opens unparented. `sandbox.agentProcess` wraps the persistent streamed agent process. The process-session bridge launches it during `bridge.process-session`, so it opens -under `task.run` — no turn has started yet. It therefore overlaps the sibling +under `task.provider_session` — no turn has started yet. It therefore overlaps the sibling `agent.turn` rather than nesting under it or dangling off the short-lived bring-up step. The span ends when the process settles or when the bridge tears down, whichever comes first. The bridge tears down before the run root span ends, so -the span never outlives `task.run` even when the process lingers past teardown +the span never outlives `task.provider_session` even when the process lingers past teardown (the sandbox `execute` has no cancel, so a lingering process cannot be forced to resolve). diff --git a/doc/plans/2026-10-09-task-workspaces.md b/doc/plans/2026-10-09-task-workspaces.md new file mode 100644 index 00000000000..529289a8841 --- /dev/null +++ b/doc/plans/2026-10-09-task-workspaces.md @@ -0,0 +1,127 @@ +# Selected architecture — 2026-10-09 + +This design addresses PAP-171: a short agent turn paid minutes of workspace hydration and copyback because a projectless chat task inherited a large agent fallback directory. It covers app/API and chat tasks, including optional channel defaults, independent project and filesystem choices, explicit physical sharing, and deterministic harness preparation. Two architecture workers received the same full plan and grounding. Both candidates and an independent review used the inherited parent model; this is not cross-model consensus. + +## Caller contract first + +App/API/chat issue creation supplies typed workspace intent to the same workspace owner. Chat supplies optional defaults and authenticated source context, never paths or credentials. Task creation is transactional and cheap. Run preparation resolves an already-bound workspace or deterministically creates a company/task directory; provider, budget, run ownership and terminal disposition stay with heartbeat. The workspace owner does not become a second heartbeat orchestrator. + +Agent tools inspect workspace, discover existing authorized resources, prepare a repository, and explicitly select a workspace. Callers choose intent, never host shell commands or arbitrary destination paths. A repository checkout is optional and never requires a project. Root changes apply only at a safe subsequent admission; existing provider roots are immutable. Ordinary project edits do not move files. + +## Synthesis decision + +Base A retains issues.executionWorkspaceId as the single active binding and execution-workspace service as the mutation owner. Reject B's replacement binding/realization tables: existing environment leases and native descriptors already own physical and recovery facts. Graft B's narrow provider-facing context, durable settlement by run ID (reload accepted result/source evidence), explicit retention references, atomic repository subtree publication and project-deletion safety. Keep source-project authorization distinct from organizational task project. + +Parent scores: A 4/4/4/4/4; B 4/3/4/4/2 on ownership, preserved invariants, origin/tool parity, sync improvement, migration feasibility. Cross-judge independently chose A with the same scores. Neither candidate is implemented unchanged: their broad prepareRun/settleRun facade must not move unrelated execution orchestration into the workspace module. + +## State and boundaries + +- `issues.executionWorkspaceId`: canonical active binding. Workspace selection/revision and pending intent are typed/versioned task fields, not a second binding. Atomic issue lock/CAS and idempotent mutation receipts protect transitions. +- `execution_workspaces`: filesystem ownership/lifecycle and optional source project. A task-owned workspace has stable company/task identity, independent of agent. Existing rows/paths retain identity. Preserve historical privacy-source intersection. Project deletion cannot cascade needed workspace/recovery state; retain the source policy tombstone or reject deletion until explicit lifecycle cleanup. +- Repository inventory: one company-scoped child table for verified repository identity, relative path, pinned ref/branch, preparation operation/state. No plaintext credentials. Unique contained destination and source intent suppress concurrent duplicate preparation. Resolve symbolic/default ref once; another ref never silently switches a dirty checkout. +- Existing environment orchestration/leases: machine/provider placement and physical ownership. Workspace service asks it to realize selected intent. Reuse sharedWorkspaceKey/canonical paths; same logical ID does not imply same machine. Provider sessions and credentials remain agent/run scoped. +- Existing native sync/descriptors: admitted run baseline, exact source retention, immutable manifests/seeds and durable completion. Generic manifest/transport code stays in adapter-utils. AGENT_HOME retains its separate store and policy. +- Run lifetime and disposition remain owned by heartbeat. Workspace timings wrap actual filesystem work; provider turn is a child interval. Native admission ordering is unchanged: durable ownership/input before corresponding external effects, never reconstruct an admitted root from fresh defaults. +- Routes and runner authority translate/authenticate and call the domain owner. Share validators and generate tool descriptors from the canonical protocol action definitions. No parallel tool-only policy implementation. + +## Resolution and transition rules + +1. Already-admitted run: use immutable recorded input/recovery evidence. +2. New admission: eligible pending explicit selection, only after prior process and persistence obligations settle. +3. Otherwise current durable binding. +4. Unbound task: explicit selection > eligible parent inheritance > snapshotted resource/endpoint defaults > configured project policy > explicit configured adapter cwd > task directory. + +Explicit invalid/unauthorized choices fail with actionable errors, never fall back to home. Existing session cwd becomes a compatibility binding only with verified provenance. No eager scan/copy/migration of legacy homes. Workspace-free providers remain workspace-free. Existing low-trust/native-chat confinement stays authoritative and cannot be relaxed by defaults. + +Typed selection vocabulary: task_directory; existing execution workspace; configured project source with shared or managed-isolated policy. Operator cwd compatibility is internal and cannot be submitted by an ordinary agent. New repository paths use existing `.paperclip-repositories/` namespace; existing root checkouts retain their paths. + +Channel defaults use optional properties (inherit), explicit null (clear), concrete value (override). Resource override beats endpoint. Defaults are captured once at task creation and reauthorized on use. Changing/deleting/revoking configuration cannot relocate existing tasks. Project choice and workspace choice are independent; choosing a source does not silently assign organizational project. Endpoints without configuration work normally. + +Active root selection stores pending intent and returns next-normal-admission, without interrupting or scheduling a new model turn. If atomic safe nested acquisition/capture is supported, repository preparation may add a new contained subtree to the active root; otherwise it records preparation for next admission with explicit capability result. Never claim a host-only checkout is available in the running remote environment. + +## Sharing and durability + +Preserve simultaneous local/SSH tasks in an explicitly configured identical physical folder, including current local/SSH handling of legacy serialize settings. Administrative Git/checkout/restore actions retain target-scoped locks; ordinary provider work is concurrent. Closing one task cannot remove configured shared files or release another participant's resource. + +Concurrent remote sharing requires proven common physical placement, separate session authority and coherent snapshot capability. Unsupported new concurrent sharing returns a typed unavailable result; do not instantiate separate clones and label them shared. Already-admitted descriptors remain recoverable unchanged. Local authoritative roots need no replica copyback; SSH retains its actual existing persistence contract. + +Sparse filesystem checkpoints use disk-backed full manifests, changed bytes/deletions, safe-link and existing ignore semantics, and separate nested Git history. Capture still enumerates and hashes files with stable-read validation; a metadata hash cache is deferred. No-change sends zero file-content payload where capability is supported; manifest metadata still transfers; unsupported transport uses explicit confined full fallback. Reuse immutable durable seed generations, not only warm sandbox contents; reuse still verifies archive digests. Existing run references pin seeds/deltas until all admitted runs/recovery obligations release them. Retain the final host rescan because the run ownership fence does not exclude external writers to shared folders; do not reuse a stale post-merge manifest. + +Seed recovery deliberately uses **no delta chain** in this implementation. An unchanged generation reuses its verified content-addressed immutable full seed; a changed generation creates a fresh full seed. Sparse output transfer is separate from seed recovery. This revises the sketch's 16-delta compaction proposal: composing recovery generations would add a new failure surface before the simpler cache has been qualified. Existing references retain old seeds; no automatic retention/deletion policy is introduced. Changed input generations still pay full snapshot creation. New cache entries are reconstructed and checked against the admitted baseline before publication, so concurrent writes cannot label an archive as the wrong generation; this adds cold verification work. A corrupted cache entry remains a miss and falls back to a fresh seed; automatic cache repair and garbage collection are deferred. The existing Git seed contract captures the selected branch/HEAD, not unrelated refs. + +Ordinary settlement remains host-durable before successful completion. Preserve export-only recovery and exact-source lease retention. Preserve the specific unsafe-export omission exception and its run-log-only diagnostic. Do not change global merge conflict semantics as a side effect. + +## Delivery and ownership + +The core implementation owns execution-workspace/task binding schema + shared selection contracts, workspace domain/resolver, issues/heartbeat integration and nullable-project read models. The persistence implementation owns adapter-utils/native sync/trace helpers, exposes integration hooks to core rather than concurrent heartbeat editing. The repository implementation owns acquisition service, inventory schema and routes/runner catalogs, coordinating domain interfaces with core. The integration layer owns channel defaults/config API/UI, documentation, migration generation and final integration/verification. All workers are in the same checkout; do not revert others. + +All schema edits land before one centrally generated migration. No handwritten snapshots. Upgrade is additive; old immutable inputs and descriptors remain readable. New selections/checkpoint versions must not be admitted by old controllers: deploy schema first, drain only local test processes if needed, activate new admissions after all serving controllers support them. Rolling back application must disable new admissions, retain new state and finish/recover already-admitted work on compatible controllers; no destructive down migration. + +## Acceptance + +Targeted tests cover app/chat parity, task directory without Git, reassignment, organization change without relocation, explicit configured-folder concurrency/lifecycle, privacy intersection, repository permission/idempotency/ref conflicts, pending root changes without interruption, accepted-result recovery, unsafe-export exception, old descriptors, and no access to unrelated home contents. Persistence fixtures prove unchanged zero payload and one-file/deletion changes where supported, seed reuse and corrupted/stale fallback. Timings include delayed finalization. UI settings/errors/projectless links work with token-only styling. Run full typecheck/test:run/build before handoff; distinguish fixture proof from unperformed live provider qualification. + +## Phase status + +- Ground: complete. +- Sketch: complete, A selected with stated grafts. +- Agree: user explicitly chose architecture then implementation; no additional checkpoint. +- Implement: code complete; targeted checks pass, broad-suite qualification remains incomplete (see verification record). +- Scrap: conditional; two repeated boundary workarounds require redesign rather than silent escape hatches. + + +## Layer plan and public usage + +| Layer | Responsibility | Contract | +| --- | --- | --- | +| Shared types and database | Durable task intent, one active binding, optional source project, repository inventory | `TaskWorkspaceSelection`; existing `issues.executionWorkspaceId` | +| Workspace domain | Authorize selection, queue safe transitions, bind stable task folder, retain source privacy | `validateSelection`, `inspectTaskWorkspace`, `selectTaskWorkspace`, `applyPendingTaskWorkspaceSelection` | +| Task creation | Resolve explicit/parent/channel intent without making filesystem side effects | `workspaceSelection` on create; source marked internally | +| Channels | Merge connection/destination defaults, check sender and agent access, snapshot once | `executionDefaults: { projectId?, workspace? }` | +| Repository preparation | Validate repository/ref, authorize credentials, publish contained checkout idempotently | Repository request queues for next normal admission | +| Runner tools | Discover authorized choices and submit intent | Get/list/select workspace; prepare repository | +| Harness | Determine cwd, prepare queued repositories before baseline, enforce trust/placement policy | Existing admission and environment ownership fences | +| Filesystem persistence | Stable manifests, sparse output, verified seed reuse, durable settlement | Backward-readable native descriptors; new descriptor version for nested repositories | +| UI | Optional defaults and projectless workspace navigation | Existing Apps settings and Workspaces surfaces | + +Example task creation: + +```ts +await issues.create(companyId, { + title: "Write the report", + projectId: null, + workspaceSelection: { kind: "task_directory" }, +}); +``` + +A task can later select an authorized existing workspace with its current binding revision and an idempotency key. The response states that the selection applies on the next normal admission; it does not restart the current process. A repository request similarly records preparation intent when the active provider root cannot safely accept a new checkout. Project assignment alone never requests this transition. + +Example channel defaults: + +```json +{ + "executionDefaults": { + "projectId": null, + "workspace": { "kind": "task_directory" } + } +} +``` + +A destination may override either field independently. Omitted fields inherit the connection value; `null` clears that field's inherited choice. A cleared workspace choice allows the ordinary project/task resolution path. Changing defaults affects new conversations only. Saving a destination default does not overwrite its independently managed enabled state. + +## Verification and rollout limits + +Tests must distinguish local fixture proof from live provider qualification. The implementation adds database and protocol tests, compatibility tests for old descriptors, plain-directory and nested-repository persistence fixtures, and sparse/checkpoint/cache tests. A passing fixture does not prove Daytona or SSH production throughput. PAP-171's original run is never interrupted or modified as part of this work. + +Deploy schema before admitting the new state. Native descriptor v3 requires compatible recovery controllers; mixed-version controllers must not claim new admissions. Rollback retains the schema and immutable seeds and routes existing v3 obligations to compatible controllers. Do not delete legacy home content, existing workspace directories, or accepted-result recovery evidence. + +### Local verification record — 2026-10-09 + +- Full `pnpm -r typecheck` and `pnpm build` passed. A final direct server `tsc --noEmit` passed after the last server corrections. The full build preceded those final corrections; it was not repeated. +- Generated runner contracts, migration validation, module boundaries, token gates, and `git diff --check` passed. +- Focused checks passed for workspace binding/admission/reopen, concurrent local/SSH sharing, task-folder isolation, channel defaults and email admission, projectless UI, and immutable recovery behavior. Final repository lifecycle batch: 19/19; file browsing: 38/38; sparse checkpoints/cache: 7/7; Git/history: 69/69; tracing: 11/11; executor timing: 2/2. +- `pnpm test:run` was attempted and stopped with exit 130 after roughly an hour of serial server tests and repeated fixture timeouts. Its observed failures were 54 chat cases, one executor timing case, and 23 runtime-service cases. This is **not** a passing full-suite result. The run also began before the final source edits, so it does not qualify the frozen final tree. +- A fresh retry passed 53/54 previously failing chat cases; the remaining Slack admission case exceeded its short asynchronous observation window. After one database-setup timeout, the final isolated retry passed with the original assertions and a larger setup-hook budget (1 passed, 1,145 skipped; `chat-slash-final.log`). Thus all 54 cases passed across fresh retries, not in one clean full run. The executor timing cases both pass in fresh processes. Slow local listener lookups were independently measured at 3.5–6 seconds each; do not interpret the broader runtime failures as proven product regressions or proven baseline failures without further isolation. +- No live SSH/Daytona performance qualification or staging deployment was performed. Clean full-suite qualification on the frozen tree remains a release prerequisite. + +Detailed local logs are retained in `/tmp/paperclip-workspace-architect/` (`build.log`, `typecheck-final.log`, `tests.log`, `chat-failures-retry.log`, `repository-lifecycle-tests.log`, and `file-resources-final.log`). Worker verification notes are in `core-progress.md`, `repository-progress.md`, and `sync-progress.md` there. diff --git a/packages/adapter-utils/src/command-managed-runtime.ts b/packages/adapter-utils/src/command-managed-runtime.ts index b46b2476f1c..fc90be94300 100644 --- a/packages/adapter-utils/src/command-managed-runtime.ts +++ b/packages/adapter-utils/src/command-managed-runtime.ts @@ -1,3 +1,4 @@ +import type { WorkspaceCheckpointMetrics } from "./workspace-checkpoint.js"; import { promises as fs } from "node:fs"; import { randomUUID } from "node:crypto"; import os from "node:os"; @@ -542,8 +543,13 @@ export async function prepareCommandManagedRuntime(input: { syncWorkspace?: boolean; workspaceInboundMode?: WorkspaceInboundMode; workspaceDurableSeed?: WorkspaceDurableSeedPaths; + /** Probe bounded remote manifest capture; unsupported hosts keep full transfer. */ + workspaceCheckpoint?: boolean; + onWorkspaceCheckpoint?: (metrics: WorkspaceCheckpointMetrics) => Promise; + workspaceSeedCacheDirectory?: string; workspaceBaseline?: DirectorySnapshot; workspaceGitSnapshot?: GitWorkspaceSnapshot | null; + workspaceRepositories?: NonNullable; workspaceExclude?: string[]; /** Plain persistent directories include all files, independent of Git and task cache exclusions. */ workspaceFileMode?: "all"; @@ -610,8 +616,12 @@ export async function prepareCommandManagedRuntime(input: { syncWorkspace: input.syncWorkspace, workspaceInboundMode: input.workspaceInboundMode, workspaceDurableSeed: input.workspaceDurableSeed, + workspaceCheckpoint: input.workspaceCheckpoint, + onWorkspaceCheckpoint: input.onWorkspaceCheckpoint, + workspaceSeedCacheDirectory: input.workspaceSeedCacheDirectory, workspaceBaseline: input.workspaceBaseline, workspaceGitSnapshot: input.workspaceGitSnapshot, + workspaceRepositories: input.workspaceRepositories, workspaceExclude: mergeRuntimeExcludes(input.workspaceExclude), workspaceFileMode: input.workspaceFileMode, preserveAbsentOnRestore: input.preserveAbsentOnRestore, @@ -655,8 +665,12 @@ export async function prepareCommandManagedRuntime(input: { syncWorkspace: input.syncWorkspace, workspaceInboundMode: input.workspaceInboundMode, workspaceDurableSeed: input.workspaceDurableSeed, + workspaceCheckpoint: input.workspaceCheckpoint, + onWorkspaceCheckpoint: input.onWorkspaceCheckpoint, + workspaceSeedCacheDirectory: input.workspaceSeedCacheDirectory, workspaceBaseline: input.workspaceBaseline, workspaceGitSnapshot: input.workspaceGitSnapshot, + workspaceRepositories: input.workspaceRepositories, workspaceExclude: mergeRuntimeExcludes(input.workspaceExclude), workspaceFileMode: input.workspaceFileMode, preserveAbsentOnRestore: input.preserveAbsentOnRestore, diff --git a/packages/adapter-utils/src/execution-target.ts b/packages/adapter-utils/src/execution-target.ts index c70c02d7b91..581e8fb879d 100644 --- a/packages/adapter-utils/src/execution-target.ts +++ b/packages/adapter-utils/src/execution-target.ts @@ -1,3 +1,4 @@ +import type { WorkspaceCheckpointMetrics } from "./workspace-checkpoint.js"; import fs from "node:fs/promises"; import { execFile } from "node:child_process"; import { promisify } from "node:util"; @@ -267,6 +268,7 @@ export interface PreparedAdapterExecutionTargetRuntime { workspaceSyncSnapshot: { baseline: DirectorySnapshot; gitSnapshot: GitWorkspaceSnapshot | null; + repositories?: NonNullable; } | null; cleanupWorkspaceSnapshot?(): Promise; restoreWorkspace(onProgress?: RuntimeProgressSink): Promise; @@ -1433,8 +1435,13 @@ export async function prepareAdapterExecutionTargetRuntime(input: { syncWorkspace?: boolean; workspaceInboundMode?: WorkspaceInboundMode; workspaceDurableSeed?: WorkspaceDurableSeedPaths; + /** Probe bounded remote manifest capture; unsupported hosts keep full transfer. */ + workspaceCheckpoint?: boolean; + onWorkspaceCheckpoint?: (metrics: WorkspaceCheckpointMetrics) => Promise; + workspaceSeedCacheDirectory?: string; workspaceBaseline?: DirectorySnapshot; workspaceGitSnapshot?: GitWorkspaceSnapshot | null; + workspaceRepositories?: NonNullable; workspaceExclude?: string[]; /** Plain persistent directories include all files, independent of Git and task cache exclusions. */ workspaceFileMode?: "all"; @@ -1518,8 +1525,12 @@ export async function prepareAdapterExecutionTargetRuntime(input: { syncWorkspace: input.syncWorkspace, workspaceInboundMode: input.workspaceInboundMode, workspaceDurableSeed: input.workspaceDurableSeed, + workspaceCheckpoint: input.workspaceCheckpoint, + onWorkspaceCheckpoint: input.onWorkspaceCheckpoint, + workspaceSeedCacheDirectory: input.workspaceSeedCacheDirectory, workspaceBaseline: input.workspaceBaseline, workspaceGitSnapshot: input.workspaceGitSnapshot, + workspaceRepositories: input.workspaceRepositories, workspaceExclude: input.workspaceExclude, workspaceFileMode: input.workspaceFileMode, preserveAbsentOnRestore: input.preserveAbsentOnRestore, diff --git a/packages/adapter-utils/src/git-workspace-sync.ts b/packages/adapter-utils/src/git-workspace-sync.ts index fcd6bd763dd..207a98e92ed 100644 --- a/packages/adapter-utils/src/git-workspace-sync.ts +++ b/packages/adapter-utils/src/git-workspace-sync.ts @@ -15,6 +15,8 @@ export interface GitCommandResult { export interface GitWorkspaceSnapshot { headCommit: string; branchName: string | null; + /** Selected remote captured with the Git generation; omitted on legacy descriptors. */ + originUrl?: string | null; overlayPaths: WorkspacePaths; deletedPaths: WorkspacePaths; ignoredPaths: WorkspacePaths; @@ -168,6 +170,33 @@ export function workspaceSnapshotTimeoutMs(): number { return Number.isFinite(configured) && configured >= 1000 ? Math.min(configured, 86_400_000) : 30 * 60_000; } +export async function readManagedWorkspaceRepositories(localDir: string, options: { signal?: AbortSignal } = {}): Promise> { + const repositories: NonNullable = []; + try { + + const root = path.join(localDir, PROJECT_REPOSITORIES_DIR); + const rootStat = await fs.lstat(root).catch((error: NodeJS.ErrnoException) => { + if (error.code === "ENOENT") return null; + throw error; + }); + if (rootStat) { + if (!rootStat.isDirectory() || rootStat.isSymbolicLink()) throw new Error("Invalid project repositories directory"); + for await (const entry of await fs.opendir(root)) { + if (!entry.isDirectory() || !/^[a-zA-Z0-9_-]+$/.test(entry.name)) throw new Error("Invalid project repository directory"); + const relative = `${PROJECT_REPOSITORIES_DIR}/${entry.name}`; + const snapshot = await readGitWorkspaceSnapshot(path.join(localDir, relative), false, options); + if (!snapshot) throw new Error(`Project repository is not a Git checkout: ${relative}`); + repositories.push({ path: relative, snapshot }); + } + } + + return repositories; + } catch (error) { + for (const repo of repositories) await disposeGitWorkspaceSnapshot(repo.snapshot); + throw error; + } +} + export async function readGitWorkspaceSnapshot(localDir: string, includeRepositories = true, options: { signal?: AbortSignal } = {}): Promise { const repositories: NonNullable = []; // Only repository discovery may report an ordinary directory. A failed @@ -205,23 +234,7 @@ export async function readGitWorkspaceSnapshot(localDir: string, includeReposito options.signal?.addEventListener("abort", abort, { once: true }); if (options.signal?.aborted) abort(); try { - if (includeRepositories) { - const root = path.join(localDir, PROJECT_REPOSITORIES_DIR); - const rootStat = await fs.lstat(root).catch((error: NodeJS.ErrnoException) => { - if (error.code === "ENOENT") return null; - throw error; - }); - if (rootStat) { - if (!rootStat.isDirectory() || rootStat.isSymbolicLink()) throw new Error("Invalid project repositories directory"); - for await (const entry of await fs.opendir(root)) { - if (!entry.isDirectory() || !/^[a-zA-Z0-9_-]+$/.test(entry.name)) throw new Error("Invalid project repository directory"); - const relative = `${PROJECT_REPOSITORIES_DIR}/${entry.name}`; - const snapshot = await readGitWorkspaceSnapshot(path.join(localDir, relative), false, options); - if (!snapshot) throw new Error(`Project repository is not a Git checkout: ${relative}`); - repositories.push({ path: relative, snapshot }); - } - } - } + if (includeRepositories) repositories.push(...await readManagedWorkspaceRepositories(localDir, options)); const scan = async (args: string[], operation: string, category: string) => { const parser = new WorkspaceNulParser((record) => { @@ -267,6 +280,7 @@ export async function readGitWorkspaceSnapshot(localDir: string, includeReposito const branch = (settled[1] as PromiseFulfilledResult).value.stdout.trim(); const snapshot: GitWorkspaceSnapshot = { headCommit: head, branchName: branch && branch !== "HEAD" ? branch : null, + originUrl: await readSanitizedOriginRemoteUrl(localDir), overlayPaths: writer.paths("overlay"), deletedPaths: writer.paths("deleted"), ignoredPaths: writer.paths("ignored"), ...(repositories.length ? { repositories } : {}), }; @@ -573,7 +587,7 @@ export async function readSanitizedOriginRemoteUrl(localDir: string): Promise { +export async function copyCloneTree(source: string, target: string): Promise { await fs.mkdir(target, { recursive: true }); for await (const entry of await fs.opendir(source)) { const from = path.join(source, entry.name); @@ -594,7 +608,7 @@ export async function withShallowGitWorkspaceClone( const cloneDir = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-git-workspace-")); const tempRef = `refs/paperclip/git-sync/import/${randomUUID()}`; try { - const originUrl = await readSanitizedOriginRemoteUrl(input.localDir); + const originUrl = input.snapshot.originUrl !== undefined ? input.snapshot.originUrl : await readSanitizedOriginRemoteUrl(input.localDir); await runLocalGit(input.localDir, ["update-ref", tempRef, input.snapshot.headCommit], { timeout: 10_000, maxBuffer: 16 * 1024, diff --git a/packages/adapter-utils/src/sandbox-managed-runtime.ts b/packages/adapter-utils/src/sandbox-managed-runtime.ts index c059b4535b6..4ee9df6876a 100644 --- a/packages/adapter-utils/src/sandbox-managed-runtime.ts +++ b/packages/adapter-utils/src/sandbox-managed-runtime.ts @@ -1,3 +1,6 @@ +import type { WorkspaceCheckpointMetrics } from "./workspace-checkpoint.js"; +import { workspaceSeedGeneration, readWorkspaceSeedGeneration, publishWorkspaceSeedGeneration } from "./workspace-seed-cache.js"; +import { writeCheckpointBaseline, workspaceCheckpointScript, readCheckpointSnapshot, validateCheckpointPayload } from "./workspace-checkpoint.js"; import { execFile as execFileCallback } from "node:child_process"; import { createHash, randomUUID } from "node:crypto"; import { @@ -6,7 +9,7 @@ import { promises as fs, } from "node:fs"; import os from "node:os"; -import { workspacePaths, workspacePathMatcher, writeWorkspacePaths, isPathManifest, type WorkspacePaths } from "./workspace-manifest.js"; +import { createWorkspaceManifest, WorkspaceManifestMap, workspacePaths, workspacePathMatcher, writeWorkspacePaths, isPathManifest, type WorkspacePaths } from "./workspace-manifest.js"; import path from "node:path"; import { promisify } from "node:util"; import { @@ -20,6 +23,8 @@ import { type GitWorkspaceSnapshot, integrateImportedGitHead, readGitWorkspaceSnapshot, + readManagedWorkspaceRepositories, + copyCloneTree, disposeGitWorkspaceSnapshot, ReferencedSourceIgnoreScanLimitExceededError, readReferencedSourceGitIgnoredPaths, @@ -29,6 +34,7 @@ import { } from "./git-workspace-sync.js"; import { captureDirectorySnapshot, + directorySnapshotSha256, disposeDirectorySnapshot, selectDirectorySnapshot, mergeDirectoryWithBaseline, @@ -574,6 +580,7 @@ export interface PreparedSandboxManagedRuntime { workspaceSyncSnapshot: { baseline: DirectorySnapshot; gitSnapshot: GitWorkspaceSnapshot | null; + repositories?: NonNullable; } | null; cleanupWorkspaceSnapshot(): Promise; restoreWorkspace(onProgress?: RuntimeProgressSink): Promise; @@ -788,7 +795,7 @@ async function copyDurableSeedArchive(input: { ) { throw new Error("workspace_durable_seed_digest_mismatch"); } - await fs.copyFile(input.sourcePath, input.targetPath); + await fs.copyFile(input.sourcePath, input.targetPath, fsConstants.COPYFILE_FICLONE); } async function persistDurableSeedArchive(input: { @@ -806,7 +813,7 @@ async function persistDurableSeedArchive(input: { `.${path.basename(input.targetPath)}.${randomUUID()}.tmp`, ); try { - await fs.copyFile(input.sourcePath, temporary); + await fs.copyFile(input.sourcePath, temporary, fsConstants.COPYFILE_FICLONE); await fs.chmod(temporary, 0o600); await fs.rename(temporary, input.targetPath); } finally { @@ -1104,9 +1111,14 @@ export async function prepareSandboxManagedRuntime(input: { /** Selects authoritative host staging, exact durable-seed replay, or no-overwrite adoption. */ workspaceInboundMode?: WorkspaceInboundMode; workspaceDurableSeed?: WorkspaceDurableSeedPaths; + /** Probe bounded remote manifest capture; unsupported hosts keep full transfer. */ + workspaceCheckpoint?: boolean; + onWorkspaceCheckpoint?: (metrics: WorkspaceCheckpointMetrics) => Promise; + workspaceSeedCacheDirectory?: string; /** Durable snapshots supplied when reconstructing an interrupted restore. */ workspaceBaseline?: DirectorySnapshot; workspaceGitSnapshot?: GitWorkspaceSnapshot | null; + workspaceRepositories?: NonNullable; workspaceExclude?: string[]; /** Plain persistent directories include all files, independent of Git and task cache exclusions. */ workspaceFileMode?: "all"; @@ -1201,10 +1213,25 @@ export async function prepareSandboxManagedRuntime(input: { readGitWorkspaceSnapshot(input.workspaceLocalDir), ) : null; + const repositories = gitSnapshot ? (gitSnapshot.repositories ?? []) : input.workspaceRepositories ?? (syncWorkspace && input.workspaceFileMode !== "all" ? await readManagedWorkspaceRepositories(input.workspaceLocalDir) : []); + let managedIgnored: Awaited> | null = null; + let ignoredPaths = gitSnapshot?.ignoredPaths; + if (!gitSnapshot && repositories.length) { + managedIgnored = await createWorkspaceManifest(); + for (const repo of repositories) { + for (const relative of workspacePaths(repo.snapshot.ignoredPaths)) managedIgnored.add("ignored", `${repo.path}/${relative}`); + for (const relative of workspacePaths(repo.snapshot.deletedPaths)) managedIgnored.add("deleted", `${repo.path}/${relative}`); + } + ignoredPaths = managedIgnored.paths("ignored"); + } + const deletedPaths = gitSnapshot?.deletedPaths ?? managedIgnored?.paths("deleted") ?? []; + managedIgnored?.close(); let baselineSnapshot: DirectorySnapshot | null = null; const cleanupWorkspaceSnapshot = async () => { await disposeDirectorySnapshot(baselineSnapshot); await disposeGitWorkspaceSnapshot(gitSnapshot); + if (!gitSnapshot) for (const repo of repositories) await disposeGitWorkspaceSnapshot(repo.snapshot); + if (managedIgnored) await fs.rm(path.dirname(managedIgnored.filePath), { recursive: true, force: true }); }; try { // A selected subfolder has no cloneable Git snapshot, but its parent @@ -1231,7 +1258,6 @@ export async function prepareSandboxManagedRuntime(input: { input.workspaceExclude, gitIgnoredExcludes, ); - const repositories = gitSnapshot?.repositories ?? []; const workspaceRestoreExclude = mergeExcludes(restoreExclude, repositories.map((repo) => repo.path)); // The baseline "before" snapshot: a recursive walk of the whole workspace that // `lstat`s every entry and SHA-256-hashes every file's bytes. This is the @@ -1242,12 +1268,18 @@ export async function prepareSandboxManagedRuntime(input: { (await runStepSpan("snapshot.baseline", () => captureDirectorySnapshot(input.workspaceLocalDir, { exclude: restoreExclude, - ignoredPaths: gitSnapshot?.ignoredPaths, + ignoredPaths, diskBacked: true, }), ))) : null; + const seedGeneration = input.workspaceSeedCacheDirectory && baselineSnapshot && workspaceInboundMode !== "durable_seed" + ? workspaceSeedGeneration(baselineSnapshot, gitSnapshot, repositories) : null; + const cachedSeed = seedGeneration + ? await readWorkspaceSeedGeneration(input.workspaceSeedCacheDirectory!, seedGeneration) : null; + const replaySeed = workspaceInboundMode === "durable_seed" ? input.workspaceDurableSeed : cachedSeed; + // Every inbound staging step delegates to the provider through `client.syncIn`: // the orchestrator no longer inlines `writeFile`+`run` or chooses a transport, // and there is no `usesCustomProvision` native-diversion gate. `syncIn` is @@ -1396,38 +1428,30 @@ export async function prepareSandboxManagedRuntime(input: { // `runtimeRootDir` (`.paperclip-runtime/`). The git extract // wipes the target tree EXCEPT `.paperclip-runtime`, so the overlay tar, // which sits under `.paperclip-runtime`, survives to run its own extract. - if (gitSnapshot) { + if (gitSnapshot || repositories.length) { await emitRuntimeStatus(input.onRuntimeProgress, "git_sync", "Syncing git history to environment"); const gitTarPath = path.join(tempDir, "git-workspace.tar"); const remoteGitTar = path.posix.join( runtimeRootDir, "git-workspace-upload.tar", ); - if (workspaceInboundMode === "durable_seed") { - const durableGitArchive = - input.workspaceDurableSeed?.gitArchivePath; + if (replaySeed) { + const durableGitArchive = replaySeed.gitArchivePath; if (!durableGitArchive) { throw new Error("workspace_durable_seed_git_missing"); } await copyDurableSeedArchive({ sourcePath: durableGitArchive, targetPath: gitTarPath, - expectedSha256: input.workspaceDurableSeed?.gitArchiveSha256, + expectedSha256: replaySeed.gitArchiveSha256, }); } else { - await withShallowGitWorkspaceClone( - { - localDir: input.workspaceLocalDir, - snapshot: gitSnapshot, - }, - async (cloneDir) => { - await createTarballFromDirectory({ - localDir: cloneDir, - archivePath: gitTarPath, - exclude: [".paperclip-runtime"], - }); - }, - ); + const archive = async (cloneDir: string) => createTarballFromDirectory({ localDir: cloneDir, archivePath: gitTarPath, exclude: [".paperclip-runtime"] }); + if (gitSnapshot) await withShallowGitWorkspaceClone({ localDir: input.workspaceLocalDir, snapshot: gitSnapshot }, archive); + else await withTempDir("paperclip-managed-repositories-", async (cloneDir) => { + for (const repo of repositories) await withShallowGitWorkspaceClone({ localDir: path.join(input.workspaceLocalDir, repo.path), snapshot: repo.snapshot }, async (child) => copyCloneTree(child, path.join(cloneDir, repo.path))); + await archive(cloneDir); + }); if (input.workspaceDurableSeed?.gitArchivePath) { await persistDurableSeedArchive({ sourcePath: gitTarPath, @@ -1435,6 +1459,7 @@ export async function prepareSandboxManagedRuntime(input: { }); } } + if (cachedSeed && input.workspaceDurableSeed?.gitArchivePath) await persistDurableSeedArchive({ sourcePath: gitTarPath, targetPath: input.workspaceDurableSeed.gitArchivePath }); workspaceFiles.push({ sourcePath: gitTarPath, targetPath: remoteGitTar, @@ -1457,30 +1482,30 @@ export async function prepareSandboxManagedRuntime(input: { // the preserved names first. The extract runs AFTER the git extract. await emitRuntimeStatus(input.onRuntimeProgress, "config_sync", "Syncing workspace to environment"); const workspaceTarPath = path.join(tempDir, "workspace.tar"); - if (workspaceInboundMode === "durable_seed") { + if (replaySeed) { await copyDurableSeedArchive({ - sourcePath: input.workspaceDurableSeed!.workspaceArchivePath, + sourcePath: replaySeed.workspaceArchivePath, targetPath: workspaceTarPath, expectedSha256: - input.workspaceDurableSeed!.workspaceArchiveSha256, + replaySeed.workspaceArchiveSha256, }); } else { - const workspaceArchiveDir = gitSnapshot + const workspaceArchiveDir = gitSnapshot || repositories.length ? path.join(tempDir, "workspace-overlay") : input.workspaceLocalDir; - if (gitSnapshot) { + if (gitSnapshot || repositories.length) { await copySelectedWorkspaceEntries({ sourceRoot: workspaceRoot!, targetDir: workspaceArchiveDir, - relativePaths: gitSnapshot.overlayPaths, - ignoredPaths: gitSnapshot.ignoredPaths, + relativePaths: gitSnapshot?.overlayPaths ?? (baselineSnapshot!.entries instanceof WorkspaceManifestMap ? baselineSnapshot!.entries.manifest : [...baselineSnapshot!.entries.keys()]), + ignoredPaths, exclude: workspaceArchiveExclude, }); } await createTarballFromDirectory({ localDir: workspaceArchiveDir, archivePath: workspaceTarPath, - exclude: gitSnapshot ? undefined : workspaceArchiveExclude, + exclude: gitSnapshot || repositories.length ? undefined : workspaceArchiveExclude, }); if (input.workspaceDurableSeed) { await persistDurableSeedArchive({ @@ -1489,6 +1514,7 @@ export async function prepareSandboxManagedRuntime(input: { }); } } + if (cachedSeed && input.workspaceDurableSeed) await persistDurableSeedArchive({ sourcePath: workspaceTarPath, targetPath: input.workspaceDurableSeed.workspaceArchivePath }); const remoteWorkspaceTar = path.posix.join( runtimeRootDir, "workspace-upload.tar", @@ -1504,20 +1530,48 @@ export async function prepareSandboxManagedRuntime(input: { command: buildWorkspaceTarExtractCommand({ workspaceRemoteDir, remoteTar: remoteWorkspaceTar, - wipeExceptNames: gitSnapshot ? null : [...preservedNames], + wipeExceptNames: gitSnapshot || repositories.length ? null : [...preservedNames], }), }); // 3. Optional remove-deleted-paths command runs LAST, after both extracts. - if (gitSnapshot && (isPathManifest(gitSnapshot.deletedPaths) ? gitSnapshot.deletedPaths.count : gitSnapshot.deletedPaths.length) > 0) { + if ((isPathManifest(deletedPaths) ? deletedPaths.count : deletedPaths.length) > 0) { const deletedManifest = path.join(tempDir, "deleted.nul"); const remoteDeletedManifest = path.posix.join(runtimeRootDir, "deleted.nul"); - await writeWorkspacePaths(gitSnapshot.deletedPaths, deletedManifest); + await writeWorkspacePaths(deletedPaths, deletedManifest); workspaceFiles.push({ sourcePath: deletedManifest, targetPath: remoteDeletedManifest, kind: "file", access: "rw", writablePath: runtimeRootDir }); workspacePostUploadCommands.push({ command: buildRemoveDeletedPathsCommand({ remoteDir: workspaceRemoteDir, manifestPath: remoteDeletedManifest }) }); } workspaceUploadBytes += (await fs.stat(workspaceTarPath)).size; }); + if (seedGeneration && !cachedSeed && input.workspaceDurableSeed) { + const seed = input.workspaceDurableSeed; + // A digest proves archive integrity, not that a live source stayed + // equal to the baseline while tar was reading it. Reconstruct the + // exact seed and verify its selected contents before labeling it a + // reusable generation. A mismatch leaves ordinary per-run recovery + // intact, but never publishes a mislabeled reusable seed. + await runStepSpan("seed.verify", async () => { + const verifiedRoot = path.join(tempDir, "verify-seed"); + if (gitSnapshot || repositories.length) await extractTarballToDirectory({ archivePath: path.join(tempDir, "git-workspace.tar"), localDir: verifiedRoot }); + await extractTarballToDirectory({ archivePath: path.join(tempDir, "workspace.tar"), localDir: verifiedRoot }); + for (const relative of workspacePaths(deletedPaths)) { + let parent = verifiedRoot; + for (const segment of relative.split("/").slice(0, -1)) { + parent = path.join(parent, segment); + const stat = await fs.lstat(parent).catch((error: NodeJS.ErrnoException) => { if (error.code === "ENOENT") return null; throw error; }); + if (stat && !stat.isDirectory()) return; // Do not follow archive links during cache verification. + } + await fs.rm(path.join(verifiedRoot, relative), { recursive: true, force: true }); + } + const verified = await captureDirectorySnapshot(verifiedRoot, { exclude: baselineSnapshot!.exclude, ignoredPaths: baselineSnapshot!.ignoredPaths, diskBacked: true }); + try { + if (directorySnapshotSha256(verified) === directorySnapshotSha256(baselineSnapshot!)) { + await publishWorkspaceSeedGeneration(input.workspaceSeedCacheDirectory!, seedGeneration, { ...seed, gitArchivePath: gitSnapshot || repositories.length ? seed.gitArchivePath : null }); + } + } finally { await disposeDirectorySnapshot(verified); } + }); + } if (!stageWorkspace) return; // One confined `syncIn` for the whole merged workspace file set. The confine @@ -1714,7 +1768,7 @@ export async function prepareSandboxManagedRuntime(input: { additionalSourceFailures, workspaceSyncSnapshot: syncWorkspace && baselineSnapshot - ? { baseline: baselineSnapshot, gitSnapshot } + ? { baseline: baselineSnapshot, gitSnapshot, ...(!gitSnapshot && repositories.length ? { repositories } : {}) } : null, cleanupWorkspaceSnapshot, restoreWorkspace: async (onProgress?: RuntimeProgressSink) => { @@ -1764,6 +1818,8 @@ export async function prepareSandboxManagedRuntime(input: { adapterKey: input.adapterKey, workspaceLocalDir: localDir, workspaceInboundMode: "adopt_remote", + workspaceCheckpoint: input.workspaceCheckpoint, + onWorkspaceCheckpoint: input.onWorkspaceCheckpoint, workspaceGitSnapshot: repository.snapshot, workspaceExclude: nestedExclude, workspaceBaseline: await selectDirectorySnapshot(baselineSnapshot!, { @@ -1779,6 +1835,7 @@ export async function prepareSandboxManagedRuntime(input: { let importedRef: string | null = null; let importedHead: string | null = null; let remoteWorkspaceStatus = "dirty"; + let checkpointSnapshot: DirectorySnapshot | undefined; try { if (gitSnapshot) { await emitRuntimeStatus(input.onRuntimeProgress, "export", "Exporting git changes from environment"); @@ -1871,7 +1928,51 @@ export async function prepareSandboxManagedRuntime(input: { await emitRuntimeStatus(input.onRuntimeProgress, "restore", "Restoring workspace from environment"); const extractedDir = path.join(tempDir, "workspace"); - if (nativeSyncOut) { + let sparseSupported = false; + if (input.workspaceCheckpoint && nativeSyncOut) { + sparseSupported = await input.client.run( + `node --input-type=module -e ${shellQuote("import {DatabaseSync} from 'node:sqlite'; new DatabaseSync(':memory:').close()")}`, + { timeoutMs: 15_000 }, + ).then(() => true, () => false); + } + if (input.workspaceCheckpoint && !sparseSupported) await input.onWorkspaceCheckpoint?.({ mode: "full", scannedEntries: null, changedFiles: null, payloadBytes: null }).catch(() => undefined); + if (sparseSupported) { + const checkpointBaseline = repositories.length === 0 ? baselineSnapshot! : await selectDirectorySnapshot(baselineSnapshot!, { + omit: repositories.map((repo) => repo.path), exclude: workspaceRestoreExclude, ignoredPaths, + }); + try { + const inputManifest = path.join(tempDir, "checkpoint-baseline.sqlite"); + const helper = path.join(tempDir, "checkpoint.mjs"); + await writeCheckpointBaseline(checkpointBaseline, inputManifest); + await fs.writeFile(helper, workspaceCheckpointScript()); + const remoteRoot = path.posix.join(runtimeRootDir, `checkpoint-${randomUUID()}`); + const remoteManifest = path.posix.join(remoteRoot, "baseline.sqlite"); + const remoteHelper = path.posix.join(remoteRoot, "checkpoint.mjs"); + const remoteOutput = path.posix.join(remoteRoot, "output"); + const inbound: SandboxSyncOperation[] = [{ operationId: nextSyncOperationId(), files: [ + { sourcePath: inputManifest, targetPath: remoteManifest, kind: "file" }, + { sourcePath: helper, targetPath: remoteHelper, kind: "file" }, + ] }]; + assertSyncOperationsConfined(inbound, { sourceRoots: [tempDir], targetRoots: [runtimeRootDir] }); + await input.client.syncIn!(inbound); + try { + await input.client.run(["node", remoteHelper, workspaceRemoteDir, remoteManifest, remoteOutput, JSON.stringify(workspaceRestoreExclude)].map(shellQuote).join(" "), { timeoutMs: input.spec.timeoutMs }); + const output = path.join(tempDir, "checkpoint"); + const outbound: SandboxSyncOperation[] = [{ operationId: nextSyncOperationId(), files: [ + { sourcePath: remoteOutput, targetPath: output, kind: "directory" }, + ] }]; + assertSyncOperationsConfined(outbound, { sourceRoots: [runtimeRootDir], targetRoots: [tempDir] }); + await withWorkspaceRestoreStep("workspace_transfer", () => input.client.syncOut!(outbound)); + checkpointSnapshot = readCheckpointSnapshot(path.join(output, "manifest.sqlite"), checkpointBaseline); + await fs.rename(path.join(output, "payload"), extractedDir); + const metrics = await validateCheckpointPayload({ baseline: checkpointBaseline, snapshot: checkpointSnapshot, payload: extractedDir }); + await input.onWorkspaceCheckpoint?.(metrics).catch(() => undefined); + } catch (error) { + if (error instanceof Error && error.message.includes("Unsafe workspace archive symlink")) Object.assign(error, { code: "WORKSPACE_RESTORE_UNSAFE_ARCHIVE" }); + throw error; + } finally { await input.client.remove(remoteRoot).catch(() => undefined); } + } finally { if (checkpointBaseline !== baselineSnapshot) await disposeDirectorySnapshot(checkpointBaseline); } + } else if (nativeSyncOut) { // Native outbound: the provider materializes the sandbox workspace into // a fresh host directory. It is a clean destroy-then-replace into a // temp dir the orchestrator just created, so it maps exactly to a @@ -1935,12 +2036,13 @@ export async function prepareSandboxManagedRuntime(input: { const gitHeadToIntegrate = importedHead; await withWorkspaceRestoreStep("directory_merge", async () => { const mergeBaseline = repositories.length === 0 ? baselineSnapshot! : await selectDirectorySnapshot(baselineSnapshot!, { - omit: repositories.map((repo) => repo.path), exclude: workspaceRestoreExclude, ignoredPaths: gitSnapshot?.ignoredPaths, + omit: repositories.map((repo) => repo.path), exclude: workspaceRestoreExclude, ignoredPaths, }); try { await mergeDirectoryWithBaseline({ baseline: mergeBaseline, sourceDir: extractedDir, + ...(checkpointSnapshot ? { snapshots: { source: checkpointSnapshot } } : {}), targetDir: input.workspaceLocalDir, beforeApply: gitHeadToIntegrate ? async () => { @@ -1963,6 +2065,7 @@ export async function prepareSandboxManagedRuntime(input: { } finally { if (mergeBaseline !== baselineSnapshot) await disposeDirectorySnapshot(mergeBaseline); } }); } finally { + await disposeDirectorySnapshot(checkpointSnapshot ?? null); await emitRuntimeStatus(input.onRuntimeProgress, "finalize", "Finalizing workspace"); if (importedRef) { await withWorkspaceRestoreStep("git_ref_cleanup", () => deleteLocalGitRef({ localDir: input.workspaceLocalDir, ref: importedRef! })); diff --git a/packages/adapter-utils/src/workspace-checkpoint.test.ts b/packages/adapter-utils/src/workspace-checkpoint.test.ts new file mode 100644 index 00000000000..5d8378a1fd8 --- /dev/null +++ b/packages/adapter-utils/src/workspace-checkpoint.test.ts @@ -0,0 +1,146 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { captureDirectorySnapshot, disposeDirectorySnapshot, mergeDirectoryWithBaseline } from "./workspace-restore-merge.js"; +import { writeCheckpointBaseline, workspaceCheckpointScript, readCheckpointSnapshot, validateCheckpointPayload } from "./workspace-checkpoint.js"; +import { publishWorkspaceSeedGeneration, readWorkspaceSeedGeneration, workspaceSeedGeneration } from "./workspace-seed-cache.js"; +const execute = promisify(execFile); +const roots: string[] = []; +afterEach(async () => { vi.unstubAllEnvs(); await Promise.all(roots.splice(0).map((root) => fs.rm(root, { recursive: true, force: true }))); }); +async function fixture() { + const temp = await fs.mkdtemp(path.join(os.tmpdir(), "checkpoint-test-")); roots.push(temp); vi.stubEnv("PAPERCLIP_HOME", path.join(temp, "home")); + const root = path.join(temp, "root"); await fs.mkdir(root); + await fs.writeFile(path.join(root, "unchanged"), "retained"); + await fs.writeFile(path.join(root, "deleted"), "delete me"); + await fs.mkdir(path.join(root, "cache")); await fs.writeFile(path.join(root, "cache", "ignored"), "private"); + const baseline = await captureDirectorySnapshot(root, { diskBacked: true, exclude: ["cache"] }); + const manifest = path.join(temp, "baseline.sqlite"); await writeCheckpointBaseline(baseline, manifest); + const script = path.join(temp, "checkpoint.mjs"); await fs.writeFile(script, workspaceCheckpointScript()); + const capture = async () => { + const output = path.join(temp, "output"); + await execute(process.execPath, [script, root, manifest, output, JSON.stringify(baseline.exclude)]); + const snapshot = readCheckpointSnapshot(path.join(output, "manifest.sqlite"), baseline); + const payload = path.join(output, "payload"); + const metrics = await validateCheckpointPayload({ baseline, snapshot, payload }); + return { snapshot, payload, metrics, receipt: JSON.parse(await fs.readFile(path.join(output, "receipt.json"), "utf8")) }; + }; + return { temp, root, baseline, capture }; +} +describe("workspace sparse checkpoint", () => { + it("enumerates unchanged files but transfers zero workspace payload", async () => { + const f = await fixture(); const result = await f.capture(); + expect(result.receipt).toMatchObject({ files: 0, bytes: 0, scanned: 2 }); + expect(result.metrics).toEqual({ mode: "sparse", scannedEntries: 2, changedFiles: 0, payloadBytes: 0 }); + expect(await fs.readdir(result.payload)).toEqual([]); + expect([...result.snapshot.entries]).toEqual([...f.baseline.entries]); + await disposeDirectorySnapshot(result.snapshot); await disposeDirectorySnapshot(f.baseline); + }); + it("merges one changed file, deletion, unusual names and a safe link without copying unchanged files", async () => { + const f = await fixture(); const host = path.join(f.temp, "host"); await fs.cp(f.root, host, { recursive: true }); + const name = "new\nfile"; await fs.writeFile(path.join(f.root, name), "changed"); + await fs.unlink(path.join(f.root, "deleted")); await fs.symlink(name, path.join(f.root, "link")); + const result = await f.capture(); expect(result.receipt.files).toBe(1); expect(result.receipt.bytes).toBe(7); + await mergeDirectoryWithBaseline({ baseline: f.baseline, sourceDir: result.payload, targetDir: host, snapshots: { source: result.snapshot } }); + expect(await fs.readFile(path.join(host, name), "utf8")).toBe("changed"); + expect(await fs.readFile(path.join(host, "unchanged"), "utf8")).toBe("retained"); + expect(await fs.readlink(path.join(host, "link"))).toBe(name); + await expect(fs.stat(path.join(host, "deleted"))).rejects.toMatchObject({ code: "ENOENT" }); + expect(await fs.readFile(path.join(host, "cache", "ignored"), "utf8")).toBe("private"); + await disposeDirectorySnapshot(f.baseline); + }); + it("rejects escaping links and changed payloads before merge", async () => { + const f = await fixture(); await fs.symlink("../../secret", path.join(f.root, "escape")); + await expect(f.capture()).rejects.toThrow("Unsafe workspace archive symlink"); + await disposeDirectorySnapshot(f.baseline); + }); + it("replays a checkpoint after interruption and preserves a concurrently edited deletion", async () => { + const f = await fixture(); const host = path.join(f.temp, "host"); await fs.cp(f.root, host, { recursive: true }); + await fs.writeFile(path.join(f.root, "new"), "remote"); await fs.unlink(path.join(f.root, "deleted")); + await fs.writeFile(path.join(host, "deleted"), "host edit"); + const result = await f.capture(); + await mergeDirectoryWithBaseline({ baseline: f.baseline, sourceDir: result.payload, targetDir: host, snapshots: { source: result.snapshot } }); + const replay = readCheckpointSnapshot(path.join(f.temp, "output", "manifest.sqlite"), f.baseline); + await validateCheckpointPayload({ baseline: f.baseline, snapshot: replay, payload: result.payload }); + await mergeDirectoryWithBaseline({ baseline: f.baseline, sourceDir: result.payload, targetDir: host, snapshots: { source: replay } }); + expect(await fs.readFile(path.join(host, "deleted"), "utf8")).toBe("host edit"); + expect(await fs.readFile(path.join(host, "new"), "utf8")).toBe("remote"); + await disposeDirectorySnapshot(f.baseline); + }); + it("rejects bytes changed after capture before applying the manifest", async () => { + const f = await fixture(); await fs.writeFile(path.join(f.root, "new"), "remote"); + const result = await f.capture(); await fs.writeFile(path.join(result.payload, "new"), "tampered"); + await expect(validateCheckpointPayload({ baseline: f.baseline, snapshot: result.snapshot, payload: result.payload })).rejects.toThrow("payload mismatch"); + await disposeDirectorySnapshot(result.snapshot); await disposeDirectorySnapshot(f.baseline); + }); + it("reuses durable seed generations and rejects corruption after run cleanup", async () => { + const f = await fixture(); const cache = path.join(f.temp, "cache-seeds"); + const archive = path.join(f.temp, "seed.tar"); await fs.writeFile(archive, "archive"); + const generation = workspaceSeedGeneration(f.baseline, null); + await publishWorkspaceSeedGeneration(cache, generation, { workspaceArchivePath: archive }); + await fs.unlink(archive); + const cached = await readWorkspaceSeedGeneration(cache, generation); expect(cached).not.toBeNull(); + expect(await fs.readFile(cached!.workspaceArchivePath, "utf8")).toBe("archive"); + await fs.writeFile(cached!.workspaceArchivePath, "corrupted"); + expect(await readWorkspaceSeedGeneration(cache, generation)).toBeNull(); + await disposeDirectorySnapshot(f.baseline); + }); +}); + +it("uses sparse native transfer end-to-end, including replay and unsupported-runtime fallback", async () => { + const { prepareSandboxManagedRuntime } = await import("./sandbox-managed-runtime.js"); + const f = await fixture(); await disposeDirectorySnapshot(f.baseline); + const remote = path.join(f.temp, "remote"); + const cache = path.join(f.temp, "seeds"); + let supportsCheckpoint = true; + let payloadBytes = -1; + let mutateDuringPack = false; + const run = async (command: string) => { + if (!supportsCheckpoint && command.startsWith("node --input-type=module")) throw new Error("node unavailable"); + await execute("sh", ["-c", command]); + }; + const sync = async (operations: import("./sandbox-managed-runtime.js").SandboxSyncOperation[]) => { + for (const operation of operations) { + for (const file of operation.files) { + await fs.mkdir(path.dirname(file.targetPath), { recursive: true }); + await fs.cp(file.sourcePath, file.targetPath, { recursive: true, verbatimSymlinks: true, + filter: (source) => !file.exclude?.some((exclude) => path.relative(file.sourcePath, source) === exclude) }); + if (file.sourcePath.endsWith("/output")) { + payloadBytes = JSON.parse(await fs.readFile(path.join(file.targetPath, "receipt.json"), "utf8")).bytes; + } + } + for (const command of operation.postUploadCommands ?? []) await run(command.command); + } + return { operations: operations.map((op) => ({ operationId: op.operationId, filesTransferred: 0, bytesTransferred: 0 })) }; + }; + const client = { makeDir: (dir: string) => fs.mkdir(dir, { recursive: true }).then(() => {}), + writeFile: (file: string, data: ArrayBuffer) => fs.writeFile(file, Buffer.from(data)), readFile: (file: string) => fs.readFile(file), + listFiles: (dir: string) => fs.readdir(dir), remove: (file: string) => fs.rm(file, { force: true, recursive: true }), run, + syncIn: sync, syncOut: sync }; + const prepare = () => prepareSandboxManagedRuntime({ spec: { provider: "test", sandboxId: "test", remoteCwd: remote, timeoutMs: 30000, apiKey: null, transport: "sandbox" }, + adapterKey: "test", client, workspaceLocalDir: f.root, workspaceCheckpoint: true, + runtimeSpan: async (name, work) => { + if (mutateDuringPack && name === "pack") await fs.writeFile(path.join(f.root, "unchanged"), "concurrent change"); + return work(); + }, + workspaceSeedCacheDirectory: cache, workspaceDurableSeed: { workspaceArchivePath: path.join(f.temp, "run-seed.tar") } }); + const first = await prepare(); await first.restoreWorkspace(); expect(payloadBytes).toBe(0); + const generation = (await fs.readdir(cache))[0]!; + await fs.writeFile(path.join(cache, generation, "workspace.tar"), "corrupt cache"); + const second = await prepare(); + await fs.writeFile(path.join(remote, "new"), "changed"); await fs.unlink(path.join(remote, "deleted")); + await second.restoreWorkspace(); expect(payloadBytes).toBe(7); + expect(await fs.readFile(path.join(f.root, "new"), "utf8")).toBe("changed"); + await expect(fs.stat(path.join(f.root, "deleted"))).rejects.toMatchObject({ code: "ENOENT" }); + supportsCheckpoint = false; + const third = await prepare(); await fs.writeFile(path.join(remote, "new"), "fallback"); + await third.restoreWorkspace(); expect(await fs.readFile(path.join(f.root, "new"), "utf8")).toBe("fallback"); + const existingGenerations = await fs.readdir(cache); + mutateDuringPack = true; + const raced = await prepare(); + expect(await fs.readdir(cache)).toEqual(existingGenerations); + expect((await fs.stat(path.join(f.temp, "run-seed.tar"))).size).toBeGreaterThan(0); + await raced.cleanupWorkspaceSnapshot(); +}, 30000); diff --git a/packages/adapter-utils/src/workspace-checkpoint.ts b/packages/adapter-utils/src/workspace-checkpoint.ts new file mode 100644 index 00000000000..9a83d2ebdad --- /dev/null +++ b/packages/adapter-utils/src/workspace-checkpoint.ts @@ -0,0 +1,153 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import { DatabaseSync } from "node:sqlite"; +import { WorkspaceManifestWriter, WorkspaceManifestMap, workspacePaths } from "./workspace-manifest.js"; +import { shouldExcludePath, excludePatternMatches, isRelativePathOrDescendant } from "./exclude-patterns.js"; +import { openDirectorySnapshot, type DirectorySnapshot } from "./workspace-restore-merge.js"; + +/** Transfer metadata is disk-backed on both sides. No file list or payload is + * buffered in a command argument, JSON array or controller heap. */ +export async function writeCheckpointBaseline(snapshot: DirectorySnapshot, filePath: string): Promise { + const writer = new WorkspaceManifestWriter(filePath); + try { + for (const [relative, entry] of snapshot.entries) writer.add("baseline", relative, JSON.stringify(entry)); + if (snapshot.ignoredPaths) for (const relative of workspacePaths(snapshot.ignoredPaths)) writer.add("ignored", relative); + writer.close(); + } catch (error) { writer.close(false); throw error; } +} + +// This function is serialized as the provider helper. Keep dependencies explicit +// in workspaceCheckpointScript: the same path policy functions run on both ends. +async function checkpointMain( + fs: typeof import("node:fs/promises"), path: typeof import("node:path"), + DatabaseSync: typeof import("node:sqlite").DatabaseSync, + createHash: typeof import("node:crypto").createHash, + createReadStream: typeof import("node:fs").createReadStream, + shouldExcludePath: (relative: string, exclude: readonly string[]) => boolean, +) { + const [rootArg, baselinePath, outputDir, exclusions] = process.argv.slice(2); + const root = await fs.realpath(rootArg!); + const exclude: string[] = JSON.parse(exclusions!); + await fs.mkdir(outputDir!, { recursive: true }); + const payload = path.join(outputDir!, "payload"); + await fs.mkdir(payload); + const baseline = new DatabaseSync(baselinePath!, { readOnly: true, allowExtension: false }); + baseline.exec("PRAGMA trusted_schema=OFF; PRAGMA cache_size=-1024; PRAGMA temp_store=FILE; PRAGMA mmap_size=0;"); + const lookup = baseline.prepare("SELECT value FROM records WHERE category='baseline' AND path=?"); + const ignored = baseline.prepare("SELECT 1 FROM records WHERE category='ignored' AND path=?"); + const result = new DatabaseSync(path.join(outputDir!, "manifest.sqlite")); + const space = await fs.statfs(outputDir!, { bigint: true }); + const available = space.bavail * space.bsize - 256n * 1024n * 1024n; + if (available <= 0n) throw new Error("Workspace checkpoint storage is below its free-space reserve"); + result.exec(`PRAGMA max_page_count=${Math.min(Number(available / 4n / 4096n), 4_294_967_294)}`); + result.exec("PRAGMA journal_mode=DELETE; PRAGMA cache_size=-1024; PRAGMA temp_store=FILE; CREATE TABLE records(category TEXT,path TEXT,value TEXT NOT NULL, PRIMARY KEY(category,path)) WITHOUT ROWID; BEGIN;"); + const insert = result.prepare("INSERT INTO records VALUES('baseline',?,?)"); + let files = 0, bytes = 0, scanned = 0; + const fingerprint = (s: Awaited>) => [s.dev, s.ino, s.mode, s.size, s.mtimeMs, s.ctimeMs].join(":"); + const hash = async (file: string) => { + const digest = createHash("sha256"); + for await (const chunk of createReadStream(file)) digest.update(chunk); + return digest.digest("hex"); + }; + const walk = async (relative = "") => { + const directory = path.join(root, relative); + if (await fs.realpath(directory) !== directory) throw new Error("Workspace directory changed during checkpoint capture"); + for await (const child of await fs.opendir(directory)) { + const name = relative ? `${relative}/${child.name}` : child.name; + if (Buffer.byteLength(name) > 65536) throw new Error("Invalid workspace manifest path"); + let ignoredPath = false; + for (let p = name; p; p = p.includes("/") ? p.slice(0, p.lastIndexOf("/")) : "") { + if (ignored.get(p)) { ignoredPath = true; break; } + } + if (ignoredPath || shouldExcludePath(name, exclude)) continue; + const source = path.join(root, name); + const before = await fs.lstat(source); + scanned++; + let entry: { kind: string; hash?: string; mode?: number; target?: string }; + if (before.isDirectory()) entry = { kind: "dir" }; + else if (before.isFile()) entry = { kind: "file", mode: before.mode, hash: await hash(source) }; + else if (before.isSymbolicLink()) { + const target = await fs.readlink(source); + const resolved = path.resolve(path.dirname(source), target); + if (path.isAbsolute(target) || (resolved !== root && !resolved.startsWith(`${root}/`))) throw new Error("Unsafe workspace archive symlink"); + entry = { kind: "symlink", target }; + } else continue; + if (fingerprint(before) !== fingerprint(await fs.lstat(source))) throw new Error("Workspace changed during checkpoint capture"); + const encoded = JSON.stringify(entry); + insert.run(name, encoded); + if (lookup.get(name)?.value !== encoded) { + const destination = path.join(payload, name); + await fs.mkdir(path.dirname(destination), { recursive: true }); + if (entry.kind === "dir") await fs.mkdir(destination, { recursive: true }); + else if (entry.kind === "symlink") await fs.symlink(entry.target!, destination); + else { + await fs.copyFile(source, destination); + await fs.chmod(destination, before.mode); + if (await hash(destination) !== entry.hash || fingerprint(before) !== fingerprint(await fs.lstat(source))) throw new Error("Workspace changed during checkpoint copy"); + files++; bytes += before.size; + } + } + if (before.isDirectory()) await walk(name); + } + }; + try { + await walk(); + result.exec("COMMIT"); + await fs.writeFile(path.join(outputDir!, "receipt.json"), JSON.stringify({ version: 1, files, bytes, scanned })); + } finally { baseline.close(); result.close(); } +} + +export function workspaceCheckpointScript(): string { + // Keep wildcard policy in one source. The private segment predicate is the + // only dependency not exported by exclude-patterns. + return `import fs from 'node:fs/promises';\nimport path from 'node:path';\nimport { DatabaseSync } from 'node:sqlite';\nimport { createHash } from 'node:crypto';\nimport { createReadStream } from 'node:fs';\nconst __name = (fn) => fn;\n${isRelativePathOrDescendant.toString()}\nfunction pathContainsSegmentOrDescendant(relative, segment) { return relative === segment || relative.startsWith(segment + '/') || relative.endsWith('/' + segment) || relative.includes('/' + segment + '/'); }\n${excludePatternMatches.toString()}\n${shouldExcludePath.toString()}\nawait (${checkpointMain.toString()})(fs, path, DatabaseSync, createHash, createReadStream, shouldExcludePath);\n`; +} + +/** Validate every manifest record before allowing a sparse source to authorize + * deletions. SQLite is read-only, extensions and trusted schema are disabled. */ +export function readCheckpointSnapshot(filePath: string, baseline: DirectorySnapshot): DirectorySnapshot { + const db = new DatabaseSync(filePath, { readOnly: true, allowExtension: false }); + let count: number; + try { + db.exec("PRAGMA trusted_schema=OFF; PRAGMA cache_size=-1024; PRAGMA temp_store=FILE; PRAGMA mmap_size=0;"); + count = Number(db.prepare("SELECT count(*) AS count FROM records WHERE category='baseline'").get()!.count); + } finally { db.close(); } + const snapshot = openDirectorySnapshot({ version: 2, exclude: baseline.exclude, ignoredPaths: baseline.ignoredPaths, + entries: { kind: "path_manifest", version: 1, filePath, category: "baseline", count } }); + try { for (const _entry of snapshot.entries) { /* bounded record validation */ } } + catch (error) { (snapshot.entries as WorkspaceManifestMap).close(); throw error; } + return snapshot; +} + +/** The manifest is provider data, not proof that the downloaded payload matches + * it. Verify changed entries before the existing merge engine sees the source. */ +export async function validateCheckpointPayload(input: { baseline: DirectorySnapshot; snapshot: DirectorySnapshot; payload: string }): Promise { + const { captureDirectorySnapshot, disposeDirectorySnapshot } = await import("./workspace-restore-merge.js"); + const actual = await captureDirectorySnapshot(input.payload, { diskBacked: true }); + const equal = (left: unknown, right: unknown) => JSON.stringify(left) === JSON.stringify(right); + let changedFiles = 0, payloadBytes = 0; + try { + for (const [relative, entry] of input.snapshot.entries) { + if (shouldExcludePath(relative, input.baseline.exclude)) throw new Error("Excluded workspace checkpoint entry"); + if (entry.kind === "symlink") { + const target = path.resolve(input.payload, path.dirname(relative), entry.target); + if (path.isAbsolute(entry.target) || (target !== path.resolve(input.payload) && !target.startsWith(`${path.resolve(input.payload)}/`))) { + throw Object.assign(new Error("Unsafe workspace checkpoint symlink"), { code: "WORKSPACE_RESTORE_UNSAFE_ARCHIVE" }); + } + } + if (!equal(entry, input.baseline.entries.get(relative)) && !equal(entry, actual.entries.get(relative))) throw new Error("Workspace checkpoint payload mismatch"); + } + for (const [relative, entry] of actual.entries) { + if (!equal(entry, input.snapshot.entries.get(relative))) throw new Error("Unexpected workspace checkpoint payload"); + if (entry.kind === "file") { changedFiles++; payloadBytes += (await fs.stat(path.join(input.payload, relative))).size; } + } + return { mode: "sparse", scannedEntries: input.snapshot.entries.size, changedFiles, payloadBytes }; + } finally { await disposeDirectorySnapshot(actual); } +} + +export interface WorkspaceCheckpointMetrics { + mode: "sparse" | "full"; + scannedEntries: number | null; + changedFiles: number | null; + payloadBytes: number | null; +} diff --git a/packages/adapter-utils/src/workspace-restore-merge.ts b/packages/adapter-utils/src/workspace-restore-merge.ts index 5865d8b1c5e..c600a682676 100644 --- a/packages/adapter-utils/src/workspace-restore-merge.ts +++ b/packages/adapter-utils/src/workspace-restore-merge.ts @@ -662,7 +662,7 @@ export async function mergeDirectoryWithBaseline(input: { afterApply?: () => Promise; /** Caller holds the target's writer lock and validated an immutable sparse * source. Unchanged entries need no payload and are never copied. */ - snapshots?: { source: DirectorySnapshot; current: DirectorySnapshot }; + snapshots?: { source: DirectorySnapshot; current?: DirectorySnapshot }; }): Promise { const options = { exclude: input.baseline.exclude, ignoredPaths: input.baseline.ignoredPaths, diskBacked: true }; const source = input.snapshots?.source ?? await captureDirectorySnapshot(input.sourceDir, options); diff --git a/packages/adapter-utils/src/workspace-seed-cache.ts b/packages/adapter-utils/src/workspace-seed-cache.ts new file mode 100644 index 00000000000..5ac116b960f --- /dev/null +++ b/packages/adapter-utils/src/workspace-seed-cache.ts @@ -0,0 +1,66 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import { createHash, randomUUID } from "node:crypto"; +import { createReadStream } from "node:fs"; +import { directorySnapshotSha256, type DirectorySnapshot } from "./workspace-restore-merge.js"; +import type { GitWorkspaceSnapshot } from "./git-workspace-sync.js"; +import type { WorkspaceDurableSeedPaths } from "./sandbox-managed-runtime.js"; + +export function workspaceSeedGeneration(baseline: DirectorySnapshot, git: GitWorkspaceSnapshot | null, repositories: NonNullable = []): string { + const identity = (snapshot: GitWorkspaceSnapshot | null): unknown => snapshot && ({ + head: snapshot.headCommit, branch: snapshot.branchName, origin: snapshot.originUrl, + repositories: snapshot.repositories?.map((repo) => ({ path: repo.path, git: identity(repo.snapshot) })), + }); + return createHash("sha256").update("verified-seed-v1\0").update(JSON.stringify([directorySnapshotSha256(baseline), identity(git), repositories.map((repo) => ({ path: repo.path, git: identity(repo.snapshot) }))])).digest("hex"); +} +async function digest(file: string): Promise { + const hash = createHash("sha256"); + for await (const chunk of createReadStream(file)) hash.update(chunk); + return hash.digest("hex"); +} + +/** Cache is durable but never authority: every archive is checked before reuse. + * Admitted runs retain their own hard link, independent of cache cleanup. */ +export async function readWorkspaceSeedGeneration(root: string, generation: string): Promise { + const directory = path.join(root, generation); + try { + for (const item of [root, directory]) { const stat = await fs.lstat(item); if (!stat.isDirectory() || stat.isSymbolicLink()) return null; } + const receipt = path.join(directory, "receipt.json"); + const receiptStat = await fs.lstat(receipt); + if (!receiptStat.isFile() || receiptStat.isSymbolicLink() || receiptStat.size > 4096) return null; + const metadata = JSON.parse(await fs.readFile(receipt, "utf8")); + if (!/^[a-f0-9]{64}$/.test(metadata.workspaceSha256) || (metadata.gitSha256 !== null && !/^[a-f0-9]{64}$/.test(metadata.gitSha256))) return null; + if (metadata.version !== 1 || metadata.generation !== generation) return null; + const workspaceArchivePath = path.join(directory, "workspace.tar"); + const gitArchivePath = metadata.gitSha256 ? path.join(directory, "git.tar") : null; + for (const [file, expected] of [[workspaceArchivePath, metadata.workspaceSha256], [gitArchivePath, metadata.gitSha256]]) { + if (!file) continue; + const stat = await fs.lstat(file); + if (!stat.isFile() || stat.isSymbolicLink() || await digest(file) !== expected) return null; + } + return { workspaceArchivePath, workspaceArchiveSha256: metadata.workspaceSha256, + gitArchivePath, gitArchiveSha256: metadata.gitSha256 }; + } catch { return null; } +} +export async function publishWorkspaceSeedGeneration(root: string, generation: string, seed: WorkspaceDurableSeedPaths): Promise { + await fs.mkdir(root, { recursive: true, mode: 0o700 }); + const rootStat = await fs.lstat(root); + if (!rootStat.isDirectory() || rootStat.isSymbolicLink()) throw new Error("workspace_seed_cache_root_invalid"); + const temporary = path.join(root, `.pending-${randomUUID()}`); + await fs.mkdir(temporary, { mode: 0o700 }); + try { + const copy = async (source: string, name: string) => { + const target = path.join(temporary, name); + await fs.link(source, target).catch(() => fs.copyFile(source, target)); + const file = await fs.open(target, "r"); + try { await file.sync(); } finally { await file.close(); } + return digest(target); + }; + const workspaceSha256 = await copy(seed.workspaceArchivePath, "workspace.tar"); + const gitSha256 = seed.gitArchivePath ? await copy(seed.gitArchivePath, "git.tar") : null; + await fs.writeFile(path.join(temporary, "receipt.json"), JSON.stringify({ version: 1, generation, workspaceSha256, gitSha256 })); + await fs.rename(temporary, path.join(root, generation)).catch((error: NodeJS.ErrnoException) => { + if (error.code !== "EEXIST" && error.code !== "ENOTEMPTY") throw error; + }); + } finally { await fs.rm(temporary, { recursive: true, force: true }); } +} diff --git a/packages/db/src/migrations/0324_needy_wong.sql b/packages/db/src/migrations/0324_needy_wong.sql new file mode 100644 index 00000000000..c9488117544 --- /dev/null +++ b/packages/db/src/migrations/0324_needy_wong.sql @@ -0,0 +1,49 @@ +-- Reapplication is safe for instances that used the earlier development migration number. +CREATE TABLE IF NOT EXISTS "execution_workspace_repositories" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "company_id" uuid NOT NULL, + "execution_workspace_id" uuid NOT NULL, + "requested_by_issue_id" uuid, + "repository_identity" text NOT NULL, + "catalog_repository_id" text, + "repo_url" text NOT NULL, + "relative_path" text NOT NULL, + "requested_ref" text DEFAULT 'HEAD' NOT NULL, + "pinned_commit" text, + "branch_name" text, + "request_key" text NOT NULL, + "request_keys" jsonb DEFAULT '[]'::jsonb NOT NULL, + "state" text DEFAULT 'pending' NOT NULL, + "failure_code" text, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +ALTER TABLE "execution_workspaces" DROP CONSTRAINT IF EXISTS "execution_workspaces_project_id_projects_id_fk"; +--> statement-breakpoint +ALTER TABLE "execution_workspaces" ALTER COLUMN "project_id" DROP NOT NULL;--> statement-breakpoint +ALTER TABLE "chat_endpoint_resources" ADD COLUMN IF NOT EXISTS "execution_defaults" jsonb;--> statement-breakpoint +ALTER TABLE "chat_endpoints" ADD COLUMN IF NOT EXISTS "execution_defaults" jsonb;--> statement-breakpoint +ALTER TABLE "issues" ADD COLUMN IF NOT EXISTS "workspace_binding_revision" integer DEFAULT 0 NOT NULL;--> statement-breakpoint +ALTER TABLE "issues" ADD COLUMN IF NOT EXISTS "workspace_selection" jsonb;--> statement-breakpoint +ALTER TABLE "issues" ADD COLUMN IF NOT EXISTS "workspace_pending_selection" jsonb;--> statement-breakpoint +DO $migration$ BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conrelid = 'execution_workspace_repositories'::regclass AND conname = 'execution_workspace_repositories_company_id_companies_id_fk') THEN + ALTER TABLE "execution_workspace_repositories" ADD CONSTRAINT "execution_workspace_repositories_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE cascade ON UPDATE no action; + END IF; +END $migration$;--> statement-breakpoint +DO $migration$ BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conrelid = 'execution_workspace_repositories'::regclass AND conname = 'execution_workspace_repositories_execution_workspace_id_execution_workspaces_id_fk') THEN + ALTER TABLE "execution_workspace_repositories" ADD CONSTRAINT "execution_workspace_repositories_execution_workspace_id_execution_workspaces_id_fk" FOREIGN KEY ("execution_workspace_id") REFERENCES "public"."execution_workspaces"("id") ON DELETE cascade ON UPDATE no action; + END IF; +END $migration$;--> statement-breakpoint +DO $migration$ BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conrelid = 'execution_workspace_repositories'::regclass AND conname = 'execution_workspace_repositories_requested_by_issue_id_issues_id_fk') THEN + ALTER TABLE "execution_workspace_repositories" ADD CONSTRAINT "execution_workspace_repositories_requested_by_issue_id_issues_id_fk" FOREIGN KEY ("requested_by_issue_id") REFERENCES "public"."issues"("id") ON DELETE set null ON UPDATE no action; + END IF; +END $migration$;--> statement-breakpoint +CREATE UNIQUE INDEX IF NOT EXISTS "execution_workspace_repositories_identity_idx" ON "execution_workspace_repositories" USING btree ("execution_workspace_id","repository_identity");--> statement-breakpoint +CREATE UNIQUE INDEX IF NOT EXISTS "execution_workspace_repositories_path_idx" ON "execution_workspace_repositories" USING btree ("execution_workspace_id","relative_path");--> statement-breakpoint +CREATE UNIQUE INDEX IF NOT EXISTS "execution_workspace_repositories_request_idx" ON "execution_workspace_repositories" USING btree ("execution_workspace_id","request_key");--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "execution_workspace_repositories_company_workspace_idx" ON "execution_workspace_repositories" USING btree ("company_id","execution_workspace_id");--> statement-breakpoint +ALTER TABLE "execution_workspaces" ADD CONSTRAINT "execution_workspaces_project_id_projects_id_fk" FOREIGN KEY ("project_id") REFERENCES "public"."projects"("id") ON DELETE restrict ON UPDATE no action; \ No newline at end of file diff --git a/packages/db/src/migrations/meta/0319_snapshot.json b/packages/db/src/migrations/meta/0324_snapshot.json similarity index 98% rename from packages/db/src/migrations/meta/0319_snapshot.json rename to packages/db/src/migrations/meta/0324_snapshot.json index 8152888d270..643eb624e38 100644 --- a/packages/db/src/migrations/meta/0319_snapshot.json +++ b/packages/db/src/migrations/meta/0324_snapshot.json @@ -1,6 +1,6 @@ { - "id": "af0b2d33-956b-4b12-8d68-8b4e14c991b2", - "prevId": "541f5e10-e2f3-44e1-b238-4d1cbfba61f2", + "id": "0830f550-2a08-4d03-983e-75b982e3475c", + "prevId": "76861d12-d9a9-4f5f-bcbc-0fb06c0addbc", "version": "7", "dialect": "postgresql", "tables": { @@ -3914,6 +3914,45 @@ "notNull": true, "default": "'idle'" }, + "lifecycle_state": { + "name": "lifecycle_state", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'ready'" + }, + "lifecycle_required_plugin_ids": { + "name": "lifecycle_required_plugin_ids", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "lifecycle_holds": { + "name": "lifecycle_holds", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "lifecycle_version": { + "name": "lifecycle_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "lifecycle_error": { + "name": "lifecycle_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lifecycle_operation": { + "name": "lifecycle_operation", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, "reports_to": { "name": "reports_to", "type": "uuid", @@ -4026,6 +4065,33 @@ } }, "indexes": { + "agents_lifecycle_work_idx": { + "name": "agents_lifecycle_work_idx", + "columns": [ + { + "expression": "lifecycle_state", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, "agents_company_status_idx": { "name": "agents_company_status_idx", "columns": [ @@ -4744,6 +4810,516 @@ }, "isRLSEnabled": false }, + "public.ai_subscription_connections": { + "name": "ai_subscription_connections", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "connection_id": { + "name": "connection_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "grant_id": { + "name": "grant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "credential_key": { + "name": "credential_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "subscription_id": { + "name": "subscription_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "ai_subscription_connections_grant_idx": { + "name": "ai_subscription_connections_grant_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "grant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "ai_subscription_connections_credential_idx": { + "name": "ai_subscription_connections_credential_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "credential_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "ai_subscription_connections_account_idx": { + "name": "ai_subscription_connections_account_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "subscription_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "ai_subscription_connections_company_id_companies_id_fk": { + "name": "ai_subscription_connections_company_id_companies_id_fk", + "tableFrom": "ai_subscription_connections", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "ai_subscription_connections_connection_id_tool_connections_id_fk": { + "name": "ai_subscription_connections_connection_id_tool_connections_id_fk", + "tableFrom": "ai_subscription_connections", + "tableTo": "tool_connections", + "columnsFrom": [ + "connection_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "ai_subscription_connections_grant_id_connection_grants_id_fk": { + "name": "ai_subscription_connections_grant_id_connection_grants_id_fk", + "tableFrom": "ai_subscription_connections", + "tableTo": "connection_grants", + "columnsFrom": [ + "grant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "ai_subscription_connections_subscription_id_ai_subscriptions_id_fk": { + "name": "ai_subscription_connections_subscription_id_ai_subscriptions_id_fk", + "tableFrom": "ai_subscription_connections", + "tableTo": "ai_subscriptions", + "columnsFrom": [ + "subscription_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.ai_subscription_prices": { + "name": "ai_subscription_prices", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "subscription_id": { + "name": "subscription_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "plan": { + "name": "plan", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "amount_cents": { + "name": "amount_cents", + "type": "numeric(24, 7)", + "primaryKey": false, + "notNull": false + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'USD'" + }, + "cadence": { + "name": "cadence", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'month'" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_url": { + "name": "source_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "effective_at": { + "name": "effective_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "ai_subscription_prices_revision_idx": { + "name": "ai_subscription_prices_revision_idx", + "columns": [ + { + "expression": "subscription_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "revision", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "ai_subscription_prices_company_idx": { + "name": "ai_subscription_prices_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "ai_subscription_prices_company_id_companies_id_fk": { + "name": "ai_subscription_prices_company_id_companies_id_fk", + "tableFrom": "ai_subscription_prices", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "ai_subscription_prices_subscription_id_ai_subscriptions_id_fk": { + "name": "ai_subscription_prices_subscription_id_ai_subscriptions_id_fk", + "tableFrom": "ai_subscription_prices", + "tableTo": "ai_subscriptions", + "columnsFrom": [ + "subscription_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "ai_subscription_prices_valid": { + "name": "ai_subscription_prices_valid", + "value": "(\"ai_subscription_prices\".\"amount_cents\" is null or \"ai_subscription_prices\".\"amount_cents\" >= 0) and \"ai_subscription_prices\".\"cadence\" in ('month','year') and \"ai_subscription_prices\".\"status\" in ('active','ended','excluded') and \"ai_subscription_prices\".\"source\" in ('catalog','user','unknown')" + } + }, + "isRLSEnabled": false + }, + "public.ai_subscriptions": { + "name": "ai_subscriptions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "account_key": { + "name": "account_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "identity_verified": { + "name": "identity_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "owner_user_ids": { + "name": "owner_user_ids", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'::text[]" + }, + "shared": { + "name": "shared", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "detected_plan": { + "name": "detected_plan", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "observed_at": { + "name": "observed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_checked_at": { + "name": "last_checked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "refresh_status": { + "name": "refresh_status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "merged_into_id": { + "name": "merged_into_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "ai_subscriptions_identity_idx": { + "name": "ai_subscriptions_identity_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "account_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "ai_subscriptions_company_idx": { + "name": "ai_subscriptions_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "ai_subscriptions_company_id_companies_id_fk": { + "name": "ai_subscriptions_company_id_companies_id_fk", + "tableFrom": "ai_subscriptions", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "ai_subscriptions_merged_into_id_ai_subscriptions_id_fk": { + "name": "ai_subscriptions_merged_into_id_ai_subscriptions_id_fk", + "tableFrom": "ai_subscriptions", + "tableTo": "ai_subscriptions", + "columnsFrom": [ + "merged_into_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "ai_subscriptions_no_self_merge": { + "name": "ai_subscriptions_no_self_merge", + "value": "\"ai_subscriptions\".\"merged_into_id\" is null or \"ai_subscriptions\".\"merged_into_id\" <> \"ai_subscriptions\".\"id\"" + } + }, + "isRLSEnabled": false + }, "public.announcement_dismissals": { "name": "announcement_dismissals", "schema": "", @@ -9233,6 +9809,12 @@ "notNull": true, "default": false }, + "execution_defaults": { + "name": "execution_defaults", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, "metadata": { "name": "metadata", "type": "jsonb", @@ -9473,6 +10055,12 @@ "notNull": true, "default": "''" }, + "execution_defaults": { + "name": "execution_defaults", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, "allow_direct_messages": { "name": "allow_direct_messages", "type": "boolean", @@ -9480,6 +10068,13 @@ "notNull": true, "default": true }, + "require_at_mention": { + "name": "require_at_mention", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, "allow_group_chats": { "name": "allow_group_chats", "type": "boolean", @@ -11383,6 +11978,31 @@ "primaryKey": false, "notNull": true }, + "owner_type": { + "name": "owner_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'personal'" + }, + "owner_login": { + "name": "owner_login", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "app_name": { + "name": "app_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "handoff": { + "name": "handoff", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, "status": { "name": "status", "type": "text", @@ -17487,6 +18107,12 @@ "primaryKey": false, "notNull": false }, + "subscription_id": { + "name": "subscription_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, "billing_code": { "name": "billing_code", "type": "text", @@ -17722,6 +18348,33 @@ "method": "btree", "with": {} }, + "cost_events_company_subscription_occurred_idx": { + "name": "cost_events_company_subscription_occurred_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "subscription_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "occurred_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, "cost_events_company_agent_occurred_idx": { "name": "cost_events_company_agent_occurred_idx", "columns": [ @@ -17903,6 +18556,19 @@ ], "onDelete": "no action", "onUpdate": "no action" + }, + "cost_events_subscription_id_ai_subscriptions_id_fk": { + "name": "cost_events_subscription_id_ai_subscriptions_id_fk", + "tableFrom": "cost_events", + "tableTo": "ai_subscriptions", + "columnsFrom": [ + "subscription_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" } }, "compositePrimaryKeys": {}, @@ -24266,6 +24932,252 @@ "checkConstraints": {}, "isRLSEnabled": false }, + "public.execution_workspace_repositories": { + "name": "execution_workspace_repositories", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "execution_workspace_id": { + "name": "execution_workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "requested_by_issue_id": { + "name": "requested_by_issue_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "repository_identity": { + "name": "repository_identity", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "catalog_repository_id": { + "name": "catalog_repository_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "repo_url": { + "name": "repo_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "relative_path": { + "name": "relative_path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "requested_ref": { + "name": "requested_ref", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'HEAD'" + }, + "pinned_commit": { + "name": "pinned_commit", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "branch_name": { + "name": "branch_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "request_key": { + "name": "request_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "request_keys": { + "name": "request_keys", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "failure_code": { + "name": "failure_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "execution_workspace_repositories_identity_idx": { + "name": "execution_workspace_repositories_identity_idx", + "columns": [ + { + "expression": "execution_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "repository_identity", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "execution_workspace_repositories_path_idx": { + "name": "execution_workspace_repositories_path_idx", + "columns": [ + { + "expression": "execution_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "relative_path", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "execution_workspace_repositories_request_idx": { + "name": "execution_workspace_repositories_request_idx", + "columns": [ + { + "expression": "execution_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "request_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "execution_workspace_repositories_company_workspace_idx": { + "name": "execution_workspace_repositories_company_workspace_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "execution_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "execution_workspace_repositories_company_id_companies_id_fk": { + "name": "execution_workspace_repositories_company_id_companies_id_fk", + "tableFrom": "execution_workspace_repositories", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "execution_workspace_repositories_execution_workspace_id_execution_workspaces_id_fk": { + "name": "execution_workspace_repositories_execution_workspace_id_execution_workspaces_id_fk", + "tableFrom": "execution_workspace_repositories", + "tableTo": "execution_workspaces", + "columnsFrom": [ + "execution_workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "execution_workspace_repositories_requested_by_issue_id_issues_id_fk": { + "name": "execution_workspace_repositories_requested_by_issue_id_issues_id_fk", + "tableFrom": "execution_workspace_repositories", + "tableTo": "issues", + "columnsFrom": [ + "requested_by_issue_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, "public.execution_workspace_runtime_leases": { "name": "execution_workspace_runtime_leases", "schema": "", @@ -24521,7 +25433,7 @@ "name": "project_id", "type": "uuid", "primaryKey": false, - "notNull": true + "notNull": false }, "project_workspace_id": { "name": "project_workspace_id", @@ -24799,7 +25711,7 @@ "columnsTo": [ "id" ], - "onDelete": "cascade", + "onDelete": "restrict", "onUpdate": "no action" }, "execution_workspaces_project_workspace_id_project_workspaces_id_fk": { @@ -35174,6 +36086,25 @@ "primaryKey": false, "notNull": false }, + "workspace_binding_revision": { + "name": "workspace_binding_revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "workspace_selection": { + "name": "workspace_selection", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "workspace_pending_selection": { + "name": "workspace_pending_selection", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, "execution_workspace_preference": { "name": "execution_workspace_preference", "type": "text", diff --git a/packages/db/src/migrations/meta/_journal.json b/packages/db/src/migrations/meta/_journal.json index 5a60506f374..1cf9286afba 100644 --- a/packages/db/src/migrations/meta/_journal.json +++ b/packages/db/src/migrations/meta/_journal.json @@ -2255,6 +2255,13 @@ "when": 1791578748368, "tag": "0323_petite_shockwave", "breakpoints": true + }, + { + "idx": 324, + "version": "7", + "when": 1791584830800, + "tag": "0324_needy_wong", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/db/src/schema/chat_channels.ts b/packages/db/src/schema/chat_channels.ts index e335a9c5bf9..91096022384 100644 --- a/packages/db/src/schema/chat_channels.ts +++ b/packages/db/src/schema/chat_channels.ts @@ -14,6 +14,7 @@ import { uuid, } from "drizzle-orm/pg-core"; import type { + ChatExecutionDefaults, ChatAdapterCapabilities, ChatConcurrencyPolicy, ChatDeliveryState, @@ -65,6 +66,7 @@ export const chatEndpoints = pgTable( botDisplayName: text("bot_display_name"), botAvatarUrl: text("bot_avatar_url"), communicationInstructions: text("communication_instructions").notNull().default(""), + executionDefaults: jsonb("execution_defaults").$type(), allowDirectMessages: boolean("allow_direct_messages") .notNull() .default(true), @@ -220,6 +222,7 @@ export const chatEndpointResources = pgTable( .notNull() .default("available"), enabled: boolean("enabled").notNull().default(false), + executionDefaults: jsonb("execution_defaults").$type(), metadata: jsonb("metadata") .$type>() .notNull() diff --git a/packages/db/src/schema/execution_workspace_repositories.ts b/packages/db/src/schema/execution_workspace_repositories.ts new file mode 100644 index 00000000000..17d7e2d10c6 --- /dev/null +++ b/packages/db/src/schema/execution_workspace_repositories.ts @@ -0,0 +1,30 @@ +import { index, jsonb, pgTable, text, timestamp, uniqueIndex, uuid } from "drizzle-orm/pg-core"; +import { companies } from "./companies.js"; +import { executionWorkspaces } from "./execution_workspaces.js"; +import { issues } from "./issues.js"; + +/** Durable acquisition receipt and inventory. Credentials never belong in this table. */ +export const executionWorkspaceRepositories = pgTable("execution_workspace_repositories", { + id: uuid("id").primaryKey().defaultRandom(), + companyId: uuid("company_id").notNull().references(() => companies.id, { onDelete: "cascade" }), + executionWorkspaceId: uuid("execution_workspace_id").notNull().references(() => executionWorkspaces.id, { onDelete: "cascade" }), + requestedByIssueId: uuid("requested_by_issue_id").references(() => issues.id, { onDelete: "set null" }), + repositoryIdentity: text("repository_identity").notNull(), + catalogRepositoryId: text("catalog_repository_id"), + repoUrl: text("repo_url").notNull(), + relativePath: text("relative_path").notNull(), + requestedRef: text("requested_ref").notNull().default("HEAD"), + pinnedCommit: text("pinned_commit"), + branchName: text("branch_name"), + requestKey: text("request_key").notNull(), + requestKeys: jsonb("request_keys").$type().notNull().default([]), + state: text("state").notNull().default("pending"), + failureCode: text("failure_code"), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), +}, (table) => ({ + identityIdx: uniqueIndex("execution_workspace_repositories_identity_idx").on(table.executionWorkspaceId, table.repositoryIdentity), + pathIdx: uniqueIndex("execution_workspace_repositories_path_idx").on(table.executionWorkspaceId, table.relativePath), + requestIdx: uniqueIndex("execution_workspace_repositories_request_idx").on(table.executionWorkspaceId, table.requestKey), + companyWorkspaceIdx: index("execution_workspace_repositories_company_workspace_idx").on(table.companyId, table.executionWorkspaceId), +})); diff --git a/packages/db/src/schema/execution_workspaces.ts b/packages/db/src/schema/execution_workspaces.ts index 6d012445bde..6e3f8b01331 100644 --- a/packages/db/src/schema/execution_workspaces.ts +++ b/packages/db/src/schema/execution_workspaces.ts @@ -17,7 +17,7 @@ export const executionWorkspaces = pgTable( { id: uuid("id").primaryKey().defaultRandom(), companyId: uuid("company_id").notNull().references(() => companies.id, { onDelete: "cascade" }), - projectId: uuid("project_id").notNull().references(() => projects.id, { onDelete: "cascade" }), + projectId: uuid("project_id").references(() => projects.id, { onDelete: "restrict" }), projectWorkspaceId: uuid("project_workspace_id").references(() => projectWorkspaces.id, { onDelete: "set null" }), sourceIssueId: uuid("source_issue_id").references((): AnyPgColumn => issues.id, { onDelete: "set null" }), mode: text("mode").notNull(), diff --git a/packages/db/src/schema/index.ts b/packages/db/src/schema/index.ts index 32a00c248b1..7bf131374b9 100644 --- a/packages/db/src/schema/index.ts +++ b/packages/db/src/schema/index.ts @@ -238,3 +238,5 @@ export { agentIdentityKeys } from "./agent_identity_keys.js"; export * from "./decision_models.js"; export { userCompanyPreferences } from "./user_company_preferences.js"; export { aiSubscriptions, aiSubscriptionPrices, aiSubscriptionConnections } from "./ai_subscriptions.js"; + +export { executionWorkspaceRepositories } from "./execution_workspace_repositories.js"; diff --git a/packages/db/src/schema/issues.ts b/packages/db/src/schema/issues.ts index a4384709ed8..1f9b61dd22c 100644 --- a/packages/db/src/schema/issues.ts +++ b/packages/db/src/schema/issues.ts @@ -21,7 +21,7 @@ import { companies } from "./companies.js"; import { heartbeatRuns } from "./heartbeat_runs.js"; import { projectWorkspaces } from "./project_workspaces.js"; import { executionWorkspaces } from "./execution_workspaces.js"; -import type { IssueReviewPolicy, IssueUnblockDescriptor, SourceTrustMetadata } from "@paperclipai/shared"; +import type { IssueReviewPolicy, IssueUnblockDescriptor, SourceTrustMetadata, TaskWorkspaceIntent, TaskWorkspacePendingSelection } from "@paperclipai/shared"; export const issues = pgTable( "issues", @@ -81,6 +81,9 @@ export const issues = pgTable( monitorScheduledBy: text("monitor_scheduled_by"), executionWorkspaceId: uuid("execution_workspace_id") .references((): AnyPgColumn => executionWorkspaces.id, { onDelete: "set null" }), + workspaceBindingRevision: integer("workspace_binding_revision").notNull().default(0), + workspaceSelection: jsonb("workspace_selection").$type(), + workspacePendingSelection: jsonb("workspace_pending_selection").$type(), executionWorkspacePreference: text("execution_workspace_preference"), executionWorkspaceSettings: jsonb("execution_workspace_settings").$type>(), sourceTrust: jsonb("source_trust").$type(), diff --git a/packages/paperclip-runner/docs/capability-contract.md b/packages/paperclip-runner/docs/capability-contract.md index d8bd8bcbaa4..ba41e93259b 100644 --- a/packages/paperclip-runner/docs/capability-contract.md +++ b/packages/paperclip-runner/docs/capability-contract.md @@ -8,9 +8,9 @@ The skill/reference inventory and eval cases are the only normative behavior sou ## Baseline Counts -- Skill/reference headings: 162 +- Skill/reference headings: 164 - Eval cases: 106 across 16 groups -- Total normative rows: 268 +- Total normative rows: 270 - Legacy MCP aliases folded into normative rows: 42 | Eval group | Cases | @@ -61,19 +61,20 @@ The skill/reference inventory and eval cases are the only normative behavior sou | skill:skills/paperclip/SKILL.md:cases:520 | optional_agent_tool | skills/paperclip/SKILL.md:520 | | skill:skills/paperclip/SKILL.md:company-skills-workflow:525 | optional_agent_tool | skills/paperclip/SKILL.md:525 | | skill:skills/paperclip/SKILL.md:routines:536 | optional_agent_tool | skills/paperclip/SKILL.md:536 | -| skill:skills/paperclip/SKILL.md:issue-workspace-runtime-controls:547 | optional_agent_tool | skills/paperclip/SKILL.md:547 | -| skill:skills/paperclip/SKILL.md:proposing-credentials-safely:554 | optional_agent_tool | skills/paperclip/SKILL.md:554 | -| skill:skills/paperclip/SKILL.md:reading-granted-secrets:561 | optional_agent_tool | skills/paperclip/SKILL.md:561 | -| skill:skills/paperclip/SKILL.md:critical-rules:587 | optional_agent_tool | skills/paperclip/SKILL.md:587 | -| skill:skills/paperclip/SKILL.md:comment-style-required:608 | always_agent_tool | skills/paperclip/SKILL.md:608 | -| skill:skills/paperclip/SKILL.md:update:640 | optional_agent_tool | skills/paperclip/SKILL.md:640 | -| skill:skills/paperclip/SKILL.md:task-documents-and-deliverables:650 | always_agent_tool | skills/paperclip/SKILL.md:650 | -| skill:skills/paperclip/SKILL.md:planning-required-when-planning-requested:664 | optional_agent_tool | skills/paperclip/SKILL.md:664 | -| skill:skills/paperclip/SKILL.md:key-endpoints-hot-routes:695 | optional_agent_tool | skills/paperclip/SKILL.md:695 | -| skill:skills/paperclip/SKILL.md:searching-issues:724 | optional_agent_tool | skills/paperclip/SKILL.md:724 | -| skill:skills/paperclip/SKILL.md:full-reference:734 | optional_agent_tool | skills/paperclip/SKILL.md:734 | -| skill:skills/paperclip/SKILL.md:conversational-confirmation-answers:738 | always_agent_tool | skills/paperclip/SKILL.md:738 | -| skill:skills/paperclip/SKILL.md:incidental-feedback:755 | optional_agent_tool | skills/paperclip/SKILL.md:755 | +| skill:skills/paperclip/SKILL.md:task-files-and-workspace-choices:547 | optional_agent_tool | skills/paperclip/SKILL.md:547 | +| skill:skills/paperclip/SKILL.md:issue-workspace-runtime-controls:553 | optional_agent_tool | skills/paperclip/SKILL.md:553 | +| skill:skills/paperclip/SKILL.md:proposing-credentials-safely:560 | optional_agent_tool | skills/paperclip/SKILL.md:560 | +| skill:skills/paperclip/SKILL.md:reading-granted-secrets:567 | optional_agent_tool | skills/paperclip/SKILL.md:567 | +| skill:skills/paperclip/SKILL.md:critical-rules:593 | optional_agent_tool | skills/paperclip/SKILL.md:593 | +| skill:skills/paperclip/SKILL.md:comment-style-required:614 | always_agent_tool | skills/paperclip/SKILL.md:614 | +| skill:skills/paperclip/SKILL.md:update:646 | optional_agent_tool | skills/paperclip/SKILL.md:646 | +| skill:skills/paperclip/SKILL.md:task-documents-and-deliverables:656 | always_agent_tool | skills/paperclip/SKILL.md:656 | +| skill:skills/paperclip/SKILL.md:planning-required-when-planning-requested:670 | optional_agent_tool | skills/paperclip/SKILL.md:670 | +| skill:skills/paperclip/SKILL.md:key-endpoints-hot-routes:701 | optional_agent_tool | skills/paperclip/SKILL.md:701 | +| skill:skills/paperclip/SKILL.md:searching-issues:730 | optional_agent_tool | skills/paperclip/SKILL.md:730 | +| skill:skills/paperclip/SKILL.md:full-reference:740 | optional_agent_tool | skills/paperclip/SKILL.md:740 | +| skill:skills/paperclip/SKILL.md:conversational-confirmation-answers:744 | always_agent_tool | skills/paperclip/SKILL.md:744 | +| skill:skills/paperclip/SKILL.md:incidental-feedback:761 | optional_agent_tool | skills/paperclip/SKILL.md:761 | | skill:skills/paperclip/references/api-reference.md:paperclip-api-reference:1 | optional_agent_tool | skills/paperclip/references/api-reference.md:1 | | skill:skills/paperclip/references/api-reference.md:response-schemas:9 | optional_agent_tool | skills/paperclip/references/api-reference.md:9 | | skill:skills/paperclip/references/api-reference.md:agent-record-get-api-agents-me-or-get-api-agents-agentid:11 | optional_agent_tool | skills/paperclip/references/api-reference.md:11 | @@ -176,12 +177,13 @@ The skill/reference inventory and eval cases are the only normative behavior sou | skill:skills/paperclip/references/issue-documents.md:update-or-resolve-an-unclear-write:49 | optional_agent_tool | skills/paperclip/references/issue-documents.md:49 | | skill:skills/paperclip/references/issue-workspaces.md:issue-workspace-runtime-controls:1 | optional_agent_tool | skills/paperclip/references/issue-workspaces.md:1 | | skill:skills/paperclip/references/issue-workspaces.md:discover-the-workspace:5 | optional_agent_tool | skills/paperclip/references/issue-workspaces.md:5 | -| skill:skills/paperclip/references/issue-workspaces.md:control-services:23 | optional_agent_tool | skills/paperclip/references/issue-workspaces.md:23 | -| skill:skills/paperclip/references/issue-workspaces.md:start-all-configured-services-waits-for-configured-readiness-checks:28 | optional_agent_tool | skills/paperclip/references/issue-workspaces.md:28 | -| skill:skills/paperclip/references/issue-workspaces.md:restart-all-configured-services:36 | optional_agent_tool | skills/paperclip/references/issue-workspaces.md:36 | -| skill:skills/paperclip/references/issue-workspaces.md:stop-all-running-services:44 | optional_agent_tool | skills/paperclip/references/issue-workspaces.md:44 | -| skill:skills/paperclip/references/issue-workspaces.md:read-the-url:63 | optional_agent_tool | skills/paperclip/references/issue-workspaces.md:63 | -| skill:skills/paperclip/references/issue-workspaces.md:mcp-tools:72 | optional_agent_tool | skills/paperclip/references/issue-workspaces.md:72 | +| skill:skills/paperclip/references/issue-workspaces.md:choose-files-and-repositories:23 | optional_agent_tool | skills/paperclip/references/issue-workspaces.md:23 | +| skill:skills/paperclip/references/issue-workspaces.md:control-services:37 | optional_agent_tool | skills/paperclip/references/issue-workspaces.md:37 | +| skill:skills/paperclip/references/issue-workspaces.md:start-all-configured-services-waits-for-configured-readiness-checks:42 | optional_agent_tool | skills/paperclip/references/issue-workspaces.md:42 | +| skill:skills/paperclip/references/issue-workspaces.md:restart-all-configured-services:50 | optional_agent_tool | skills/paperclip/references/issue-workspaces.md:50 | +| skill:skills/paperclip/references/issue-workspaces.md:stop-all-running-services:58 | optional_agent_tool | skills/paperclip/references/issue-workspaces.md:58 | +| skill:skills/paperclip/references/issue-workspaces.md:read-the-url:77 | optional_agent_tool | skills/paperclip/references/issue-workspaces.md:77 | +| skill:skills/paperclip/references/issue-workspaces.md:mcp-tools:86 | optional_agent_tool | skills/paperclip/references/issue-workspaces.md:86 | | skill:skills/paperclip/references/routines.md:paperclip-routines:1 | optional_agent_tool | skills/paperclip/references/routines.md:1 | | skill:skills/paperclip/references/routines.md:lifecycle:16 | optional_agent_tool | skills/paperclip/references/routines.md:16 | | skill:skills/paperclip/references/routines.md:creating-a-routine:27 | optional_agent_tool | skills/paperclip/references/routines.md:27 | diff --git a/packages/paperclip-runner/generated/capability/capabilities.yaml b/packages/paperclip-runner/generated/capability/capabilities.yaml index c079d2063c8..4c0ccdb432f 100644 --- a/packages/paperclip-runner/generated/capability/capabilities.yaml +++ b/packages/paperclip-runner/generated/capability/capabilities.yaml @@ -184,115 +184,124 @@ { "id": "skill:skills/paperclip/SKILL.md:547", "kind": "skill_heading", - "sourceAnchor": "skills/paperclip/SKILL.md#L547:issue-workspace-runtime-controls", + "sourceAnchor": "skills/paperclip/SKILL.md#L547:task-files-and-workspace-choices", + "heading": "Task Files and Workspace Choices", + "primaryDisposition": "optional_agent_tool", + "semanticOperation": "scoped_discovery", + "expectedMockState": "operation_result" + }, + { + "id": "skill:skills/paperclip/SKILL.md:553", + "kind": "skill_heading", + "sourceAnchor": "skills/paperclip/SKILL.md#L553:issue-workspace-runtime-controls", "heading": "Issue Workspace Runtime Controls", "primaryDisposition": "optional_agent_tool", "semanticOperation": "scoped_discovery", "expectedMockState": "operation_result" }, { - "id": "skill:skills/paperclip/SKILL.md:554", + "id": "skill:skills/paperclip/SKILL.md:560", "kind": "skill_heading", - "sourceAnchor": "skills/paperclip/SKILL.md#L554:proposing-credentials-safely", + "sourceAnchor": "skills/paperclip/SKILL.md#L560:proposing-credentials-safely", "heading": "Proposing Credentials Safely", "primaryDisposition": "control_plane_owned", "semanticOperation": "runtime_reconciliation", "expectedMockState": "runtime_decision_record" }, { - "id": "skill:skills/paperclip/SKILL.md:561", + "id": "skill:skills/paperclip/SKILL.md:567", "kind": "skill_heading", - "sourceAnchor": "skills/paperclip/SKILL.md#L561:reading-granted-secrets", + "sourceAnchor": "skills/paperclip/SKILL.md#L567:reading-granted-secrets", "heading": "Reading Granted Secrets", "primaryDisposition": "optional_agent_tool", "semanticOperation": "scoped_discovery", "expectedMockState": "operation_result" }, { - "id": "skill:skills/paperclip/SKILL.md:587", + "id": "skill:skills/paperclip/SKILL.md:593", "kind": "skill_heading", - "sourceAnchor": "skills/paperclip/SKILL.md#L587:critical-rules", + "sourceAnchor": "skills/paperclip/SKILL.md#L593:critical-rules", "heading": "Critical Rules", "primaryDisposition": "control_plane_owned", "semanticOperation": "runtime_reconciliation", "expectedMockState": "runtime_decision_record" }, { - "id": "skill:skills/paperclip/SKILL.md:608", + "id": "skill:skills/paperclip/SKILL.md:614", "kind": "skill_heading", - "sourceAnchor": "skills/paperclip/SKILL.md#L608:comment-style-required", + "sourceAnchor": "skills/paperclip/SKILL.md#L614:comment-style-required", "heading": "Comment Style (Required)", "primaryDisposition": "always_agent_tool", "semanticOperation": "report_progress", "expectedMockState": "operation_result" }, { - "id": "skill:skills/paperclip/SKILL.md:640", + "id": "skill:skills/paperclip/SKILL.md:646", "kind": "skill_heading", - "sourceAnchor": "skills/paperclip/SKILL.md#L640:update", + "sourceAnchor": "skills/paperclip/SKILL.md#L646:update", "heading": "Update", "primaryDisposition": "control_plane_owned", "semanticOperation": "runtime_reconciliation", "expectedMockState": "runtime_decision_record" }, { - "id": "skill:skills/paperclip/SKILL.md:650", + "id": "skill:skills/paperclip/SKILL.md:656", "kind": "skill_heading", - "sourceAnchor": "skills/paperclip/SKILL.md#L650:task-documents-and-deliverables", + "sourceAnchor": "skills/paperclip/SKILL.md#L656:task-documents-and-deliverables", "heading": "Task Documents and Deliverables", "primaryDisposition": "always_agent_tool", "semanticOperation": "write_document", "expectedMockState": "operation_result" }, { - "id": "skill:skills/paperclip/SKILL.md:664", + "id": "skill:skills/paperclip/SKILL.md:670", "kind": "skill_heading", - "sourceAnchor": "skills/paperclip/SKILL.md#L664:planning-required-when-planning-requested", + "sourceAnchor": "skills/paperclip/SKILL.md#L670:planning-required-when-planning-requested", "heading": "Planning (Required when planning requested)", "primaryDisposition": "always_agent_tool", "semanticOperation": "write_document", "expectedMockState": "operation_result" }, { - "id": "skill:skills/paperclip/SKILL.md:695", + "id": "skill:skills/paperclip/SKILL.md:701", "kind": "skill_heading", - "sourceAnchor": "skills/paperclip/SKILL.md#L695:key-endpoints-hot-routes", + "sourceAnchor": "skills/paperclip/SKILL.md#L701:key-endpoints-hot-routes", "heading": "Key Endpoints (Hot Routes)", "primaryDisposition": "control_plane_owned", "semanticOperation": "runtime_reconciliation", "expectedMockState": "runtime_decision_record" }, { - "id": "skill:skills/paperclip/SKILL.md:724", + "id": "skill:skills/paperclip/SKILL.md:730", "kind": "skill_heading", - "sourceAnchor": "skills/paperclip/SKILL.md#L724:searching-issues", + "sourceAnchor": "skills/paperclip/SKILL.md#L730:searching-issues", "heading": "Searching Issues", "primaryDisposition": "control_plane_owned", "semanticOperation": "runtime_reconciliation", "expectedMockState": "runtime_decision_record" }, { - "id": "skill:skills/paperclip/SKILL.md:734", + "id": "skill:skills/paperclip/SKILL.md:740", "kind": "skill_heading", - "sourceAnchor": "skills/paperclip/SKILL.md#L734:full-reference", + "sourceAnchor": "skills/paperclip/SKILL.md#L740:full-reference", "heading": "Full Reference", "primaryDisposition": "control_plane_owned", "semanticOperation": "runtime_reconciliation", "expectedMockState": "runtime_decision_record" }, { - "id": "skill:skills/paperclip/SKILL.md:738", + "id": "skill:skills/paperclip/SKILL.md:744", "kind": "skill_heading", - "sourceAnchor": "skills/paperclip/SKILL.md#L738:conversational-confirmation-answers", + "sourceAnchor": "skills/paperclip/SKILL.md#L744:conversational-confirmation-answers", "heading": "Conversational confirmation answers", "primaryDisposition": "always_agent_tool", "semanticOperation": "call_api", "expectedMockState": "operation_result" }, { - "id": "skill:skills/paperclip/SKILL.md:755", + "id": "skill:skills/paperclip/SKILL.md:761", "kind": "skill_heading", - "sourceAnchor": "skills/paperclip/SKILL.md#L755:incidental-feedback", + "sourceAnchor": "skills/paperclip/SKILL.md#L761:incidental-feedback", "heading": "Incidental feedback", "primaryDisposition": "optional_agent_tool", "semanticOperation": "scoped_discovery", @@ -1228,52 +1237,61 @@ { "id": "skill:skills/paperclip/references/issue-workspaces.md:23", "kind": "skill_heading", - "sourceAnchor": "skills/paperclip/references/issue-workspaces.md#L23:control-services", + "sourceAnchor": "skills/paperclip/references/issue-workspaces.md#L23:choose-files-and-repositories", + "heading": "Choose Files and Repositories", + "primaryDisposition": "control_plane_owned", + "semanticOperation": "runtime_reconciliation", + "expectedMockState": "runtime_decision_record" + }, + { + "id": "skill:skills/paperclip/references/issue-workspaces.md:37", + "kind": "skill_heading", + "sourceAnchor": "skills/paperclip/references/issue-workspaces.md#L37:control-services", "heading": "Control Services", "primaryDisposition": "control_plane_owned", "semanticOperation": "runtime_reconciliation", "expectedMockState": "runtime_decision_record" }, { - "id": "skill:skills/paperclip/references/issue-workspaces.md:28", + "id": "skill:skills/paperclip/references/issue-workspaces.md:42", "kind": "skill_heading", - "sourceAnchor": "skills/paperclip/references/issue-workspaces.md#L28:start-all-configured-services-waits-for-configured-readiness-checks", + "sourceAnchor": "skills/paperclip/references/issue-workspaces.md#L42:start-all-configured-services-waits-for-configured-readiness-checks", "heading": "Start all configured services; waits for configured readiness checks.", "primaryDisposition": "control_plane_owned", "semanticOperation": "runtime_reconciliation", "expectedMockState": "runtime_decision_record" }, { - "id": "skill:skills/paperclip/references/issue-workspaces.md:36", + "id": "skill:skills/paperclip/references/issue-workspaces.md:50", "kind": "skill_heading", - "sourceAnchor": "skills/paperclip/references/issue-workspaces.md#L36:restart-all-configured-services", + "sourceAnchor": "skills/paperclip/references/issue-workspaces.md#L50:restart-all-configured-services", "heading": "Restart all configured services.", "primaryDisposition": "control_plane_owned", "semanticOperation": "runtime_reconciliation", "expectedMockState": "runtime_decision_record" }, { - "id": "skill:skills/paperclip/references/issue-workspaces.md:44", + "id": "skill:skills/paperclip/references/issue-workspaces.md:58", "kind": "skill_heading", - "sourceAnchor": "skills/paperclip/references/issue-workspaces.md#L44:stop-all-running-services", + "sourceAnchor": "skills/paperclip/references/issue-workspaces.md#L58:stop-all-running-services", "heading": "Stop all running services.", "primaryDisposition": "control_plane_owned", "semanticOperation": "runtime_reconciliation", "expectedMockState": "runtime_decision_record" }, { - "id": "skill:skills/paperclip/references/issue-workspaces.md:63", + "id": "skill:skills/paperclip/references/issue-workspaces.md:77", "kind": "skill_heading", - "sourceAnchor": "skills/paperclip/references/issue-workspaces.md#L63:read-the-url", + "sourceAnchor": "skills/paperclip/references/issue-workspaces.md#L77:read-the-url", "heading": "Read the URL", "primaryDisposition": "control_plane_owned", "semanticOperation": "runtime_reconciliation", "expectedMockState": "runtime_decision_record" }, { - "id": "skill:skills/paperclip/references/issue-workspaces.md:72", + "id": "skill:skills/paperclip/references/issue-workspaces.md:86", "kind": "skill_heading", - "sourceAnchor": "skills/paperclip/references/issue-workspaces.md#L72:mcp-tools", + "sourceAnchor": "skills/paperclip/references/issue-workspaces.md#L86:mcp-tools", "heading": "MCP Tools", "primaryDisposition": "control_plane_owned", "semanticOperation": "runtime_reconciliation", diff --git a/packages/paperclip-runner/generated/capability/capability-contract.md b/packages/paperclip-runner/generated/capability/capability-contract.md index e07cc236e5a..6c6665b696a 100644 --- a/packages/paperclip-runner/generated/capability/capability-contract.md +++ b/packages/paperclip-runner/generated/capability/capability-contract.md @@ -2,9 +2,9 @@ Generated by `scripts/generate-capability-contract.mjs`; do not edit generated files. -- Skill/reference headings: 163 +- Skill/reference headings: 165 - Legacy MCP tools: 42 - Eval cases: 106 across 16 groups -- Deterministic content SHA-256: `70c4d4e90346747d89b722e09bf1c82672437d55e81bfe5f19211642c3749aa8` +- Deterministic content SHA-256: `83447a4f9463f9a3d73dd4bea78b580df0356545ba00d0da96763282653bdf0f` Every row has exactly one primary disposition, a source anchor, a semantic operation, and a mock-state expectation. diff --git a/packages/paperclip-runner/generated/capability/semantic-tool-contracts.json b/packages/paperclip-runner/generated/capability/semantic-tool-contracts.json index f58accf57c2..f9dc67e83f9 100644 --- a/packages/paperclip-runner/generated/capability/semantic-tool-contracts.json +++ b/packages/paperclip-runner/generated/capability/semantic-tool-contracts.json @@ -1 +1 @@ -[{"annotations":{"exposure":"always","operationId":"get_task_context","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Read the active task and actor, including the exact approved Markdown revision when this issue has an accepted plan.","inputSchema":{"additionalProperties":false,"properties":{},"required":[],"type":"object"},"name":"get_task_context","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"always","operationId":"get_task_history","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Read bounded comments on the active task.","inputSchema":{"additionalProperties":false,"properties":{"limit":{"default":50,"maximum":200,"minimum":1,"type":"integer"}},"required":[],"type":"object"},"name":"get_task_history","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"always","operationId":"list_documents","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"List revisioned documents on the active task.","inputSchema":{"additionalProperties":false,"properties":{},"required":[],"type":"object"},"name":"list_documents","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"always","operationId":"read_document","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Read the current revision of one active-task document.","inputSchema":{"additionalProperties":false,"properties":{"key":{"description":"Stable issue-document key.","maxLength":120,"minLength":1,"type":"string"}},"required":["key"],"type":"object"},"name":"read_document","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"always","operationId":"list_document_revisions","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Read bounded revision history for one active-task document.","inputSchema":{"additionalProperties":false,"properties":{"key":{"description":"Stable issue-document key.","maxLength":120,"minLength":1,"type":"string"},"limit":{"default":50,"maximum":200,"minimum":1,"type":"integer"}},"required":["key"],"type":"object"},"name":"list_document_revisions","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"always","operationId":"report_progress","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Append a durable progress comment to the active task.","inputSchema":{"additionalProperties":false,"properties":{"body":{"description":"Multiline progress update.","maxLength":20000,"minLength":1,"type":"string"},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"}},"required":["idempotencyKey","body"],"type":"object"},"name":"report_progress","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds"],"type":"object"}},{"annotations":{"exposure":"always","operationId":"answer_status_question","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Append the answer to a status-only wake without changing task disposition.","inputSchema":{"additionalProperties":false,"properties":{"body":{"description":"Concise status answer.","maxLength":20000,"minLength":1,"type":"string"},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"}},"required":["idempotencyKey","body"],"type":"object"},"name":"answer_status_question","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds"],"type":"object"}},{"annotations":{"exposure":"always","operationId":"write_document","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Create or update an active-task document with optimistic revision safety.","inputSchema":{"additionalProperties":false,"properties":{"baseRevisionId":{"description":"Current revision id, or null when creating.","maxLength":20000,"type":["string","null"]},"body":{"description":"Markdown document body.","maxLength":200000,"minLength":1,"type":"string"},"changeSummary":{"description":"Optional revision summary.","maxLength":20000,"type":["string","null"]},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"},"key":{"description":"Stable issue-document key.","maxLength":120,"minLength":1,"type":"string"},"title":{"description":"Document title.","maxLength":300,"minLength":1,"type":"string"}},"required":["idempotencyKey","key","title","body","baseRevisionId"],"type":"object"},"name":"write_document","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds"],"type":"object"}},{"annotations":{"exposure":"always","operationId":"request_human_input","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Create a durable human question or approval card on the current Paperclip task bound to this run; Paperclip renders it and authenticates the response. Use questions with continuationPolicy='wake_assignee' when an answer is needed, including otherwise tool-free chat turns. Supply a stable idempotencyKey and reuse it on retries. For one question at a time, ask only the next unanswered question and wait for its real answer. Never fabricate answers or treat ambiguous clarification as approval. For an ordinary confirmation or checkbox card, record a clear user chat answer with call_api POST /api/issues/{id}/interactions/{interactionId}/resolve-from-comment, using the source commentId and decision (accept/reject), plus explicit selectedOptionIds for checkbox acceptance. Existing resolver permissions still apply; governed tool, secret, and connection approvals are excluded. Question forms retain their dedicated answer workflow. Preserve existing review gates. Call this tool before claiming a question was asked; if creation fails, report the failure. Do not fabricate answer links or Markdown buttons, post duplicate cards, or use call_api to create the card. Use one complete payload.questionSet for text and choice questions. Paperclip generates compatibility questions; see the payload schema for formats.","inputSchema":{"additionalProperties":false,"allOf":[{"if":{"properties":{"interactionKind":{"const":"questions"}},"required":["interactionKind"]},"then":{"properties":{"payload":{"anyOf":[{"required":["questionSet"]},{"required":["questions"]}],"required":["version"]}},"required":["payload"]}}],"properties":{"continuationPolicy":{"enum":["none","wake_assignee","wake_assignee_on_accept"]},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"},"interactionKind":{"enum":["confirmation","checkbox","questions","suggest_tasks","item_verdicts"]},"payload":{"additionalProperties":true,"description":"Kind-specific interaction data. For questions, send version:1 and one complete questionSet containing every text and choice question. Paperclip generates compatibility questions. Each canonical question needs id, prompt, required, and answerMode: text, single_select, or multi_select. Text questions have no options or customAnswer. Choice questions need at least two meaningful options with id/label. Use customAnswer:{enabled:true} for an optional written answer to a choice question. Legacy questions remain supported; if both representations are supplied, they must describe the same complete form. Keep IDs stable across retries. For confirmation, payload may be {}.","properties":{"questionSet":{"additionalProperties":false,"properties":{"description":{"maxLength":100000,"type":"string"},"questions":{"items":{"additionalProperties":false,"allOf":[{"if":{"properties":{"answerMode":{"const":"text"}},"required":["answerMode"]},"then":{"not":{"required":["customAnswer"]},"properties":{"options":{"maxItems":0,"type":"array"}}}},{"if":{"properties":{"answerMode":{"enum":["single_select","multi_select"]}},"required":["answerMode"]},"then":{"not":{"required":["initialText"]},"properties":{"options":{"minItems":1,"type":"array"}},"required":["options"]}}],"properties":{"answerMode":{"enum":["single_select","multi_select","text"]},"customAnswer":{"additionalProperties":false,"properties":{"enabled":{"const":true},"label":{"maxLength":1000,"type":"string"},"placeholder":{"maxLength":1000,"type":"string"}},"required":["enabled"],"type":"object"},"header":{"maxLength":1000,"type":"string"},"helpText":{"maxLength":4000,"type":"string"},"id":{"maxLength":160,"minLength":1,"type":"string"},"initialText":{"description":"Editable draft of at most 100000 Unicode code points. Never an implicit answer; submitted text still uses the existing response bounds.","maxLength":100000,"type":"string"},"options":{"items":{"additionalProperties":false,"properties":{"description":{"maxLength":4000,"type":"string"},"id":{"maxLength":160,"minLength":1,"type":"string"},"label":{"maxLength":1000,"minLength":1,"type":"string"},"recommended":{"type":"boolean"}},"required":["id","label"],"type":"object"},"maxItems":128,"type":"array"},"prompt":{"maxLength":4000,"minLength":1,"type":"string"},"required":{"type":"boolean"},"textValidation":{"additionalProperties":false,"properties":{"inputType":{"enum":["text","number","integer"]},"maxLength":{"maximum":100000,"minimum":0,"type":"integer"},"maximum":{"type":"number"},"minLength":{"maximum":100000,"minimum":0,"type":"integer"},"minimum":{"type":"number"},"pattern":{"maxLength":1000,"type":"string"}},"type":"object"}},"required":["id","prompt","required","answerMode"],"type":"object"},"maxItems":64,"minItems":1,"type":"array"},"schema":{"const":"paperclip.question_set.v1"},"submitLabel":{"maxLength":200,"type":"string"},"title":{"maxLength":1000,"type":"string"}},"required":["schema","questions"],"type":"object"},"questions":{"items":{"additionalProperties":true,"properties":{"id":{"maxLength":160,"minLength":1,"type":"string"},"options":{"items":{"additionalProperties":true,"properties":{"freeText":{"type":"boolean"},"id":{"maxLength":160,"minLength":1,"type":"string"},"label":{"maxLength":1000,"minLength":1,"type":"string"}},"required":["id","label"],"type":"object"},"maxItems":129,"minItems":1,"type":"array"},"prompt":{"maxLength":4000,"minLength":1,"type":"string"},"required":{"type":"boolean"},"selectionMode":{"enum":["single","multi"]}},"required":["id","prompt","selectionMode","options"],"type":"object"},"maxItems":64,"minItems":1,"type":"array"},"version":{"const":1}},"type":"object"},"prompt":{"description":"Question or decision prompt.","maxLength":10000,"minLength":1,"type":"string"},"targetRevisionId":{"description":"Optional bound document revision.","maxLength":20000,"type":["string","null"]},"title":{"description":"Interaction card title.","maxLength":300,"minLength":1,"type":"string"}},"required":["idempotencyKey","interactionKind","title","prompt","continuationPolicy"],"type":"object"},"name":"request_human_input","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds"],"type":"object"}},{"annotations":{"exposure":"always","operationId":"register_deliverable","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Register attachment metadata and its artifact work product without credentials or bytes in the tool result.","inputSchema":{"additionalProperties":false,"properties":{"byteSize":{"maximum":100000000,"minimum":0,"type":"integer"},"contentRef":{"description":"Opaque package-local content reference.","maxLength":2000,"minLength":1,"type":"string"},"contentType":{"description":"Media type.","maxLength":200,"minLength":1,"type":"string"},"filename":{"description":"Display filename.","maxLength":500,"minLength":1,"type":"string"},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"},"sha256":{"pattern":"^[a-fA-F0-9]{64}$","type":"string"},"title":{"description":"Work-product title.","maxLength":500,"minLength":1,"type":"string"}},"required":["idempotencyKey","filename","contentType","byteSize","sha256","contentRef","title"],"type":"object"},"name":"register_deliverable","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds"],"type":"object"}},{"annotations":{"exposure":"always","operationId":"finish_task","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Finish the active task with a durable summary.","inputSchema":{"additionalProperties":false,"properties":{"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"},"summary":{"description":"Completion summary.","maxLength":20000,"minLength":1,"type":"string"}},"required":["idempotencyKey","summary"],"type":"object"},"name":"finish_task","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds"],"type":"object"}},{"annotations":{"exposure":"always","operationId":"block_task","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Block the active task with a durable reason and optional first-class dependencies.","inputSchema":{"additionalProperties":false,"properties":{"blockedByTaskIds":{"description":"Internal task ids that block this task.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"},"reason":{"description":"Block reason.","maxLength":20000,"minLength":1,"type":"string"}},"required":["idempotencyKey","reason"],"type":"object"},"name":"block_task","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds"],"type":"object"}},{"annotations":{"exposure":"always","operationId":"request_review","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Move the active task to review with a durable summary.","inputSchema":{"additionalProperties":false,"properties":{"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"},"summary":{"description":"Review handoff summary.","maxLength":20000,"minLength":1,"type":"string"}},"required":["idempotencyKey","summary"],"type":"object"},"name":"request_review","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds"],"type":"object"}},{"annotations":{"exposure":"optional","operationId":"list_agents","requiredClaims":["discovery:agents:read"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"List redacted actor profiles.","inputSchema":{"additionalProperties":false,"properties":{},"required":[],"type":"object"},"name":"list_agents","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"get_agent","requiredClaims":["discovery:agents:read"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Read one redacted actor profile.","inputSchema":{"additionalProperties":false,"properties":{"actorId":{"description":"Actor id.","maxLength":200,"minLength":1,"type":"string"}},"required":["actorId"],"type":"object"},"name":"get_agent","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"hire_agent","requiredClaims":["delegation:agents:create"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Create a persistent native Runner teammate with an identity and persona. The teammate reports to the caller and inherits the caller's native runtime; provider, adapter, environment, and credential settings are selected by Paperclip and are never caller-supplied here. Reuse a suitable teammate from list_agents when possible.","inputSchema":{"additionalProperties":false,"properties":{"capabilities":{"maxLength":2000,"type":["string","null"]},"instructions":{"maxLength":20000,"type":["string","null"]},"name":{"maxLength":200,"minLength":1,"type":"string"},"role":{"enum":["ceo","cto","cmo","cfo","security","engineer","designer","pm","qa","devops","researcher","general"],"type":"string"},"title":{"maxLength":300,"type":["string","null"]}},"required":["name"],"type":"object"},"name":"hire_agent","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"search_tasks","requiredClaims":["discovery:tasks:read"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Search tasks by text and status within the run company.","inputSchema":{"additionalProperties":false,"properties":{"limit":{"default":50,"maximum":200,"minimum":1,"type":"integer"},"query":{"maxLength":500,"type":"string"},"statuses":{"items":{"enum":["backlog","todo","in_progress","in_review","done","blocked","cancelled"]},"maxItems":7,"type":"array","uniqueItems":true}},"required":[],"type":"object"},"name":"search_tasks","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"list_approvals","requiredClaims":["governance:approvals:read"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"List approvals in the run company.","inputSchema":{"additionalProperties":false,"properties":{},"required":[],"type":"object"},"name":"list_approvals","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"get_approval","requiredClaims":["governance:approvals:read"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Read one approval without protected data.","inputSchema":{"additionalProperties":false,"properties":{"approvalId":{"description":"Approval id.","maxLength":200,"minLength":1,"type":"string"}},"required":["approvalId"],"type":"object"},"name":"get_approval","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"get_approval_context","requiredClaims":["governance:approvals:read"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Read one approval, its comments, and linked tasks.","inputSchema":{"additionalProperties":false,"properties":{"approvalId":{"description":"Approval id.","maxLength":200,"minLength":1,"type":"string"}},"required":["approvalId"],"type":"object"},"name":"get_approval_context","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"get_workspace_runtime","requiredClaims":["workspace:read"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Read active-task workspace services.","inputSchema":{"additionalProperties":false,"properties":{},"required":[],"type":"object"},"name":"get_workspace_runtime","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"control_workspace_service","requiredClaims":["workspace:control"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Start, stop, or fault one active-task workspace service.","inputSchema":{"additionalProperties":false,"properties":{"action":{"enum":["start","stop","fail"]},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"},"serviceId":{"description":"Workspace service id.","maxLength":200,"minLength":1,"type":"string"},"url":{"description":"Optional service URL.","maxLength":20000,"type":["string","null"]}},"required":["idempotencyKey","serviceId","action"],"type":"object"},"name":"control_workspace_service","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds"],"type":"object"}},{"annotations":{"exposure":"optional","operationId":"set_dependencies","requiredClaims":["dependencies:write"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Replace the active task's first-class blocker set.","inputSchema":{"additionalProperties":false,"properties":{"blockedByTaskIds":{"description":"Replacement blocker task ids.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"}},"required":["idempotencyKey","blockedByTaskIds"],"type":"object"},"name":"set_dependencies","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds"],"type":"object"}},{"annotations":{"exposure":"optional","operationId":"create_task","requiredClaims":["delegation:tasks:create"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Create a project task from a conversation, or a child from an ordinary task. Persist initialPlan before execution. Set status to backlog when the user wants to save or plan work without starting it; backlog tasks never wake an agent. Omitted status means todo, subject to blockers.","inputSchema":{"additionalProperties":false,"properties":{"assigneeActorId":{"description":"Optional agent assignee. Omit to assign the current agent.","maxLength":20000,"type":["string","null"]},"assigneeUserId":{"description":"Company person ID from list_people. Mutually exclusive with assigneeActorId.","type":["string","null"]},"blockedByTaskIds":{"description":"Initial blocker task ids.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"description":{"description":"Child task description.","maxLength":20000,"type":["string","null"]},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"},"initialPlan":{"description":"Relevant markdown plan saved on the new task before execution starts.","maxLength":200000,"type":["string","null"]},"priority":{"enum":["critical","high","medium","low"]},"projectId":{"description":"Project ID for the task.","type":["string","null"]},"status":{"description":"Initial status. Use backlog to save work without executing it. Defaults to todo (blocked when dependencies are unresolved).","enum":["backlog","todo"]},"title":{"description":"Child task title.","maxLength":500,"minLength":1,"type":"string"}},"required":["idempotencyKey","title"],"type":"object"},"name":"create_task","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"},"task":{"additionalProperties":false,"properties":{"assigneeActorId":{"type":["string","null"]},"assigneeUserId":{"description":"Company person ID from list_people. Mutually exclusive with assigneeActorId.","type":["string","null"]},"id":{"minLength":1,"type":"string"},"identifier":{"type":["string","null"]},"parentId":{"minLength":1,"type":["string","null"]},"projectId":{"minLength":1,"type":["string","null"]},"status":{"minLength":1,"type":"string"}},"required":["id","identifier","parentId","status","assigneeActorId"],"type":"object"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds","task"],"type":"object"}},{"annotations":{"exposure":"optional","operationId":"request_approval","requiredClaims":["governance:approvals:request"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Create a governed approval and waiting posture.","inputSchema":{"additionalProperties":false,"properties":{"approvalType":{"description":"Stable approval type.","maxLength":200,"minLength":1,"type":"string"},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"},"payload":{"additionalProperties":true,"type":"object"}},"required":["idempotencyKey","approvalType","payload"],"type":"object"},"name":"request_approval","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds"],"type":"object"}},{"annotations":{"exposure":"optional","operationId":"decide_approval","requiredClaims":["governance:approvals:decide"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Decide an approval as an explicitly authorized approver.","inputSchema":{"additionalProperties":false,"properties":{"approvalId":{"description":"Approval id.","maxLength":200,"minLength":1,"type":"string"},"decision":{"enum":["approved","rejected","cancelled"]},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"},"note":{"description":"Decision note.","maxLength":20000,"minLength":1,"type":"string"}},"required":["idempotencyKey","approvalId","decision","note"],"type":"object"},"name":"decide_approval","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds"],"type":"object"}},{"annotations":{"exposure":"optional","operationId":"comment_on_approval","requiredClaims":["governance:approvals:comment"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Add a durable comment to an approval.","inputSchema":{"additionalProperties":false,"properties":{"approvalId":{"description":"Approval id.","maxLength":200,"minLength":1,"type":"string"},"body":{"description":"Approval comment.","maxLength":20000,"minLength":1,"type":"string"},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"}},"required":["idempotencyKey","approvalId","body"],"type":"object"},"name":"comment_on_approval","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds"],"type":"object"}},{"annotations":{"exposure":"optional","operationId":"schedule_wake","requiredClaims":["control_plane:wakes"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Schedule a deterministic continuation wake.","inputSchema":{"additionalProperties":false,"properties":{"delayTicks":{"maximum":10000,"minimum":1,"type":"integer"},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"},"payload":{"additionalProperties":true,"type":"object"},"reason":{"enum":["manual","issue_commented","interaction_resolved","approval_resolved","blockers_resolved","scheduled_retry","resume"]}},"required":["idempotencyKey","reason","delayTicks"],"type":"object"},"name":"schedule_wake","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds"],"type":"object"}},{"annotations":{"exposure":"optional","operationId":"generic_api_request","requiredClaims":["test:generic_api_request"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Test-only escape hatch. Disabled unless the scenario and explicit claim both enable it.","inputSchema":{"additionalProperties":false,"properties":{"body":{"additionalProperties":true,"type":"object"},"method":{"enum":["GET","POST","PATCH"]},"path":{"maxLength":500,"pattern":"^/mock/","type":"string"}},"required":["method","path"],"type":"object"},"name":"generic_api_request","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"always","operationId":"read_agent_instructions","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Read the current canonical instruction entry and its revision, or inspect a historical revision by supplying both entryFile and revisionId. Read before updating; do not edit shared instruction caches.","inputSchema":{"additionalProperties":false,"properties":{"entryFile":{"description":"Configured relative entry filename returned by read_agent_instructions; retain it with the revision.","maxLength":4096,"minLength":1,"type":"string"},"revisionId":{"description":"Historical revision to inspect; also provide its entryFile.","pattern":"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$","type":"string"},"targetAgentId":{"description":"Same-company target agent. Omit to use the calling agent.","pattern":"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$","type":"string"}},"required":[],"type":"object"},"name":"read_agent_instructions","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"search_api","requiredClaims":["api:discover"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Fallback only: discover Paperclip API operations when the available dedicated tools cannot express the task. Prefer dedicated tools for common operations; do not search before using them. For a persistent hire, first list_agents to reuse a suitable teammate. Search agent-hires for the hiring schema and agent-configurations for compatible adapter/runtime settings, then use call_api with the returned operationId. Supply role-specific instructionsBundle.files as a filename-to-content record. Use the returned agent.id for delegation and obey any pending approval. Provider helper threads are temporary workers, not Paperclip hires. If a hire response is uncertain, reconcile with list_agents before retrying.","inputSchema":{"additionalProperties":false,"properties":{"cursor":{"maxLength":200,"type":"string"},"limit":{"default":5,"maximum":10,"minimum":1,"type":"integer"},"query":{"maxLength":500,"minLength":1,"type":"string"}},"required":["query"],"type":"object"},"name":"search_api","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"always","operationId":"update_agent_instructions","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Commit instruction content with the exact entryFile and baseRevisionId from a prior read. Requires the responsible user’s current target edit permission. On conflict, preserve your candidate and explicitly resolve it; never overwrite the newer head.","inputSchema":{"additionalProperties":false,"properties":{"baseRevisionId":{"description":"Revision read before editing; null only when no canonical entry exists. Never replace a stale base silently.","pattern":"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$","type":["string","null"]},"content":{"description":"Complete UTF-8 instruction content, at most 1 MiB; empty content is valid.","maxLength":1048576,"type":"string"},"entryFile":{"description":"Configured relative entry filename returned by read_agent_instructions; retain it with the revision.","maxLength":4096,"minLength":1,"type":"string"},"targetAgentId":{"description":"Same-company target agent. Omit to use the calling agent.","pattern":"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$","type":"string"}},"required":["entryFile","content","baseRevisionId"],"type":"object"},"name":"update_agent_instructions","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"call_api","requiredClaims":["api:call"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Fallback only: call a discovered Paperclip API operation when dedicated tools lack the required operation or parameters. Uses your existing permissions. Prefer dedicated tools; never bypass a denial or runner lifecycle tool. For large text responses, read the returned artifact with GET /api/assets/{assetId}/content and responseText; follow nextOffsetBytes until null.","inputSchema":{"additionalProperties":false,"properties":{"body":{"anyOf":[{"additionalProperties":true,"type":"object"},{"items":{},"type":"array"},{"type":"string"},{"type":"number"},{"type":"boolean"},{"type":"null"}],"description":"Request value matching the discovered schema. For JSON object or array requests, pass the object or array directly, never a JSON-encoded string. Strings are for text bodies or endpoints whose schema explicitly accepts a string."},"contentType":{"maxLength":120,"type":"string"},"files":{"items":{"additionalProperties":false,"oneOf":[{"properties":{"artifactId":{}},"required":["artifactId"]},{"properties":{"path":{}},"required":["path"]}],"properties":{"artifactId":{"type":"string"},"field":{"type":"string"},"path":{"description":"File relative to the active issue workspace. Remote files must first be uploaded as an artifact.","type":"string"}},"type":"object"},"maxItems":10,"type":"array"},"operationId":{"description":"Exact operationId returned by search_api, for example GET /api/projects/{id}. Do not guess identifiers.","maxLength":500,"minLength":1,"type":"string"},"pathParams":{"additionalProperties":{"type":"string"},"type":"object"},"query":{"additionalProperties":true,"type":"object"},"responseText":{"additionalProperties":false,"description":"GET only: return a bounded UTF-8 text window inline, including JSON as text, without saving another artifact. Offsets and limits are bytes. Use the returned nextOffsetBytes to continue; null means complete. Prefer reading a saved artifact for a stable snapshot. New live responses have a 1 GiB capture limit and a 4 GiB per-run capture budget. Existing larger assets remain readable in bounded pages. If a live response returns an artifact, continue on its content operation for a stable snapshot.","properties":{"limitBytes":{"default":24576,"maximum":24576,"minimum":4,"type":"integer"},"offsetBytes":{"default":0,"maximum":9007199254740991,"minimum":0,"type":"integer"}},"type":"object"}},"required":["operationId"],"type":"object"},"name":"call_api","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"always","operationId":"get_agent_instruction_history","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"List bounded canonical instruction revision metadata, including actor, source run, and restore origin. Use read_agent_instructions with entryFile and revisionId to inspect exact historical content.","inputSchema":{"additionalProperties":false,"properties":{"cursor":{"maxLength":2048,"minLength":1,"type":"string"},"entryFile":{"description":"Configured relative entry filename returned by read_agent_instructions; retain it with the revision.","maxLength":4096,"minLength":1,"type":"string"},"limit":{"maximum":100,"minimum":1,"type":"integer"},"targetAgentId":{"description":"Same-company target agent. Omit to use the calling agent.","pattern":"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$","type":"string"}},"required":["entryFile"],"type":"object"},"name":"get_agent_instruction_history","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"always","operationId":"restore_agent_instructions","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Append a historical instruction revision as the current content using the current baseRevisionId. Requires the responsible user’s current target edit permission. History remains intact; conflicts require an explicit resolution.","inputSchema":{"additionalProperties":false,"properties":{"baseRevisionId":{"description":"Current revision read before restoring. Never replace a stale base silently.","pattern":"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$","type":"string"},"entryFile":{"description":"Configured relative entry filename returned by read_agent_instructions; retain it with the revision.","maxLength":4096,"minLength":1,"type":"string"},"revisionId":{"description":"Historical revision whose exact content should be restored.","pattern":"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$","type":"string"},"targetAgentId":{"description":"Same-company target agent. Omit to use the calling agent.","pattern":"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$","type":"string"}},"required":["entryFile","revisionId","baseRevisionId"],"type":"object"},"name":"restore_agent_instructions","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"create_project","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Create a project after considering existing projects and available repositories. repositoryIds and repositoryUrls accept multiple existing repositories. Use HTTPS GitHub repositoryUrls when an accessible repo is not in the catalog; this registers project repositories, not remote GitHub repositories. Non-code projects may omit repositories. Cannot combine repositoryIds/repositoryUrls with workspace. Reuse the idempotency key on retries.","inputSchema":{"additionalProperties":false,"properties":{"archivedAt":{"description":"Archive timestamp.","maxLength":20000,"type":["string","null"]},"color":{"description":"Project color.","maxLength":20000,"type":["string","null"]},"description":{"description":"Project outcome and context.","maxLength":20000,"type":["string","null"]},"env":{"additionalProperties":true,"type":"object"},"executionWorkspacePolicy":{"additionalProperties":true,"type":"object"},"goalId":{"description":"Goal ID.","maxLength":20000,"type":["string","null"]},"goalIds":{"description":"Goal IDs.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"icon":{"description":"Project icon.","enum":["folder","rocket","code","terminal","database","globe","package","boxes","box","layers","briefcase","compass","target","flame","zap","star","bug","wrench","hammer","lightbulb","sparkles","shield","lock","search","cog","brain","cpu","git-branch","file-code","puzzle","gem","atom","heart","mail","message-square","crown","radar","telescope","hexagon",null],"type":["string","null"]},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"},"leadAgentId":{"description":"Lead agent ID.","maxLength":20000,"type":["string","null"]},"name":{"description":"Project name.","maxLength":500,"minLength":1,"type":"string"},"repositoryIds":{"description":"Authorized repository IDs from list_project_repositories; may contain multiple repositories.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"repositoryUrls":{"description":"Existing HTTPS GitHub repository URLs, including repos absent from the catalog.","items":{"maxLength":2000,"pattern":"^https://github\\.com/(?!\\.{1,2}/)[A-Za-z0-9_.-]+/(?!\\.{1,2}/?$)[A-Za-z0-9_.-]+/?$","type":"string"},"maxItems":100,"type":"array"},"status":{"enum":["backlog","planned","in_progress","completed","cancelled"]},"targetDate":{"description":"Target date.","maxLength":20000,"type":["string","null"]},"workspace":{"additionalProperties":true,"type":"object"}},"required":["idempotencyKey","name"],"type":"object"},"name":"create_project","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"list_project_repositories","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"List authorized repositories with stable IDs and names. Consider appropriate repositories before creating a project; never invent IDs.","inputSchema":{"additionalProperties":false,"properties":{},"required":[],"type":"object"},"name":"list_project_repositories","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"list_projects","requiredClaims":["discovery:projects:read"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"List company project summaries (IDs, names, status, and up to 1,000 characters of description). Returns up to 50 projects and nextCursor; continue with cursor until it is null. Read a project's API resource for full details.","inputSchema":{"additionalProperties":false,"properties":{"cursor":{"description":"The nextCursor returned by the previous page.","pattern":"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$","type":"string"},"limit":{"description":"Page size (default 50).","maximum":50,"minimum":1,"type":"integer"}},"required":[],"type":"object"},"name":"list_projects","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"create_skill","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Create a reusable single-file skill in the company library. Supply a complete SKILL.md whose name and description match the inputs. This saves the skill and shows a card; it does not assign the skill to any agent. Reuse idempotencyKey on retries.","inputSchema":{"additionalProperties":false,"properties":{"description":{"maxLength":2000,"minLength":1,"type":"string"},"idempotencyKey":{"maxLength":240,"minLength":1,"type":"string"},"markdown":{"description":"Complete SKILL.md including name and description frontmatter and substantive instructions.","maxLength":200000,"minLength":1,"type":"string"},"name":{"description":"Lowercase skill name, matching SKILL.md frontmatter.","maxLength":120,"minLength":1,"pattern":"^[a-z0-9]+(?:-[a-z0-9]+)*$","type":"string"},"slug":{"description":"Optional; must equal name.","maxLength":120,"minLength":1,"pattern":"^[a-z0-9]+(?:-[a-z0-9]+)*$","type":"string"}},"required":["idempotencyKey","name","description","markdown"],"type":"object"},"name":"create_skill","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"reassign_task","requiredClaims":["delegation:tasks:assign"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Reassign an existing task to another company agent. Read search_tasks first and supply its current assignee and statusVersion to prevent overwriting a concurrent change. Preserve the task, documents, dependencies, and blocked/backlog status. Active work is stopped before handoff. Use a stable idempotency key for retries. Cannot reassign this run’s own task, conversations, completed tasks, or pending reviews; use create_task for delegation from the current task.","inputSchema":{"additionalProperties":false,"properties":{"assigneeActorId":{"description":"New company agent ID from list_agents; null when assigning a person.","type":["string","null"]},"assigneeUserId":{"description":"Company person ID from list_people. Mutually exclusive with assigneeActorId.","type":["string","null"]},"expectedAssigneeActorId":{"description":"Current assigneeAgentId from search_tasks; null means unassigned.","type":["string","null"]},"expectedAssigneeUserId":{"description":"Current assigneeUserId from search_tasks; omit only when there is no person assigned.","type":["string","null"]},"expectedStatusVersion":{"description":"Current statusVersion from search_tasks.","minimum":0,"type":"integer"},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"},"reason":{"description":"Why the task should move and context for the new owner.","maxLength":20000,"minLength":1,"type":"string"},"taskId":{"description":"Existing task ID from search_tasks.","minLength":1,"type":"string"}},"required":["idempotencyKey","taskId","assigneeActorId","expectedAssigneeActorId","expectedStatusVersion","reason"],"type":"object"},"name":"reassign_task","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds"],"type":"object"}},{"annotations":{"exposure":"optional","operationId":"update_skill","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Replace an existing company skill's complete SKILL.md using the current version as a guard. Reuse idempotencyKey on lost-response retries; read again after a version conflict.","inputSchema":{"additionalProperties":false,"properties":{"expectedVersionId":{"minLength":1,"type":"string"},"idempotencyKey":{"maxLength":240,"minLength":1,"type":"string"},"markdown":{"maxLength":200000,"minLength":1,"type":"string"},"skillId":{"minLength":1,"type":"string"}},"required":["skillId","markdown","expectedVersionId","idempotencyKey"],"type":"object"},"name":"update_skill","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"set_task_title","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Set a concise, descriptive title for the active task. When its titleNeedsGeneration is true, call this early with onlyIfProvisional: true to replace the initial prompt slice without overwriting a user's title. Use false only for an intentional rename. This changes no task status, ownership, or description.","inputSchema":{"additionalProperties":false,"properties":{"idempotencyKey":{"description":"Reuse this key on retries.","maxLength":240,"minLength":1,"type":"string"},"onlyIfProvisional":{"description":"True for automatic initial naming; preserves any title already chosen by a user or agent.","type":"boolean"},"title":{"description":"Short title describing the requested outcome.","maxLength":240,"minLength":1,"type":"string"}},"required":["idempotencyKey","title","onlyIfProvisional"],"type":"object"},"name":"set_task_title","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"set_task_monitor","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Schedule, replace, or clear a one-shot task monitor. Omit taskId for your current task; other tasks must also be assigned to you and in progress or review. Supply a future nextCheckAt and notes describing the next check, or monitor: null to clear. A successful receipt reports the persisted monitor; a replay reports its current state without re-arming it. After scheduling your current task, end the turn with paperclip_finish: yielded and continuation.kind monitor. Paperclip will wake you with issue_monitor_due; do not sleep, poll, mark done, or use call_api for this wait.","inputSchema":{"additionalProperties":false,"properties":{"idempotencyKey":{"description":"Reuse with identical arguments on retries.","maxLength":240,"minLength":1,"type":"string"},"monitor":{"additionalProperties":false,"properties":{"externalRef":{"maxLength":500,"minLength":1,"type":["string","null"]},"kind":{"enum":["external_service",null],"type":["string","null"]},"maxAttempts":{"description":"Optional cumulative attempt limit for this task's monitor.","maximum":100,"minimum":1,"type":["integer","null"]},"nextCheckAt":{"description":"Future UTC timestamp for the next check. This is one shot, not a recurring interval.","type":"string"},"notes":{"description":"What to check on the next run; do not include secrets.","maxLength":500,"minLength":1,"type":"string"},"recoveryPolicy":{"enum":["wake_owner","create_recovery_issue","escalate_to_board",null],"type":["string","null"]},"serviceName":{"maxLength":120,"minLength":1,"type":["string","null"]},"timeoutAt":{"description":"Optional deadline later than nextCheckAt.","type":["string","null"]}},"required":["nextCheckAt","notes"],"type":["object","null"]},"taskId":{"description":"Owned task UUID. Omit for the active task.","type":"string"}},"required":["idempotencyKey","monitor"],"type":"object"},"name":"set_task_monitor","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"submit_complaint","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Yeet a brief, completely unstructured complaint about agent tooling or the experience of doing agent work. Preserve your raw voice: fragments, profanity, all caps, and exasperation are fair game. No diagnosis, solution, diplomacy, category, or template required. Use submit_suggestion when you want to express a useful improvement. Submit proactively without routine permission requests, previews, or announcements, then immediately continue the primary task. Answer truthfully if the user asks about feedback or what you submitted. Stored in this instance with company, agent, run, and task attribution; not anonymous. Remove secrets, private prompts, customer data, and personal blame; do not forward untrusted text or logs indiscriminately. On failure, continue without retrying or complaining about submission. Never submit the same incident through both tools.","inputSchema":{"additionalProperties":false,"properties":{"body":{"description":"Free-form feedback. Keep it brief; no required fields, labels, or template.","maxLength":524288,"minLength":1,"type":"string"},"idempotencyKey":{"description":"A stable key for this submission. Transport replay reuses it; do not retry a failed or uncertain submission yourself.","maxLength":240,"minLength":1,"type":"string"}},"required":["body","idempotencyKey"],"type":"object"},"name":"submit_complaint","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"submit_suggestion","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Quietly report a concrete improvement that would make agents more effective. Use for material, generalizable friction directly observed during this run. Explain what happened, its impact, and a plausible improvement in your own words, with minimal useful sanitized context. No required format. Avoid duplicate root causes and aim for at most three suggestions per run. Use submit_complaint for the raw reaction. Submit proactively without routine permission requests, previews, or announcements, then immediately continue the primary task. Answer truthfully if the user asks about feedback or what you submitted. Stored in this instance with company, agent, run, and task attribution; not anonymous. Remove secrets, private prompts, customer data, and personal blame; do not forward untrusted text or logs indiscriminately. On failure, continue without retrying or complaining about submission. Never submit the same incident through both tools.","inputSchema":{"additionalProperties":false,"properties":{"body":{"description":"Free-form feedback. Keep it brief; no required fields, labels, or template.","maxLength":524288,"minLength":1,"type":"string"},"idempotencyKey":{"description":"A stable key for this submission. Transport replay reuses it; do not retry a failed or uncertain submission yourself.","maxLength":240,"minLength":1,"type":"string"}},"required":["body","idempotencyKey"],"type":"object"},"name":"submit_suggestion","outputSchema":{"additionalProperties":true,"type":"object"}}] +[{"annotations":{"exposure":"always","operationId":"get_task_context","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Read the active task and actor, including the exact approved Markdown revision when this issue has an accepted plan.","inputSchema":{"additionalProperties":false,"properties":{},"required":[],"type":"object"},"name":"get_task_context","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"always","operationId":"get_task_history","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Read bounded comments on the active task.","inputSchema":{"additionalProperties":false,"properties":{"limit":{"default":50,"maximum":200,"minimum":1,"type":"integer"}},"required":[],"type":"object"},"name":"get_task_history","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"always","operationId":"list_documents","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"List revisioned documents on the active task.","inputSchema":{"additionalProperties":false,"properties":{},"required":[],"type":"object"},"name":"list_documents","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"always","operationId":"read_document","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Read the current revision of one active-task document.","inputSchema":{"additionalProperties":false,"properties":{"key":{"description":"Stable issue-document key.","maxLength":120,"minLength":1,"type":"string"}},"required":["key"],"type":"object"},"name":"read_document","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"always","operationId":"list_document_revisions","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Read bounded revision history for one active-task document.","inputSchema":{"additionalProperties":false,"properties":{"key":{"description":"Stable issue-document key.","maxLength":120,"minLength":1,"type":"string"},"limit":{"default":50,"maximum":200,"minimum":1,"type":"integer"}},"required":["key"],"type":"object"},"name":"list_document_revisions","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"always","operationId":"report_progress","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Append a durable progress comment to the active task.","inputSchema":{"additionalProperties":false,"properties":{"body":{"description":"Multiline progress update.","maxLength":20000,"minLength":1,"type":"string"},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"}},"required":["idempotencyKey","body"],"type":"object"},"name":"report_progress","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds"],"type":"object"}},{"annotations":{"exposure":"always","operationId":"answer_status_question","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Append the answer to a status-only wake without changing task disposition.","inputSchema":{"additionalProperties":false,"properties":{"body":{"description":"Concise status answer.","maxLength":20000,"minLength":1,"type":"string"},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"}},"required":["idempotencyKey","body"],"type":"object"},"name":"answer_status_question","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds"],"type":"object"}},{"annotations":{"exposure":"always","operationId":"write_document","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Create or update an active-task document with optimistic revision safety.","inputSchema":{"additionalProperties":false,"properties":{"baseRevisionId":{"description":"Current revision id, or null when creating.","maxLength":20000,"type":["string","null"]},"body":{"description":"Markdown document body.","maxLength":200000,"minLength":1,"type":"string"},"changeSummary":{"description":"Optional revision summary.","maxLength":20000,"type":["string","null"]},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"},"key":{"description":"Stable issue-document key.","maxLength":120,"minLength":1,"type":"string"},"title":{"description":"Document title.","maxLength":300,"minLength":1,"type":"string"}},"required":["idempotencyKey","key","title","body","baseRevisionId"],"type":"object"},"name":"write_document","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds"],"type":"object"}},{"annotations":{"exposure":"always","operationId":"request_human_input","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Create a durable human question or approval card on the current Paperclip task bound to this run; Paperclip renders it and authenticates the response. Use questions with continuationPolicy='wake_assignee' when an answer is needed, including otherwise tool-free chat turns. Supply a stable idempotencyKey and reuse it on retries. For one question at a time, ask only the next unanswered question and wait for its real answer. Never fabricate answers or treat ambiguous clarification as approval. For an ordinary confirmation or checkbox card, record a clear user chat answer with call_api POST /api/issues/{id}/interactions/{interactionId}/resolve-from-comment, using the source commentId and decision (accept/reject), plus explicit selectedOptionIds for checkbox acceptance. Existing resolver permissions still apply; governed tool, secret, and connection approvals are excluded. Question forms retain their dedicated answer workflow. Preserve existing review gates. Call this tool before claiming a question was asked; if creation fails, report the failure. Do not fabricate answer links or Markdown buttons, post duplicate cards, or use call_api to create the card. Use one complete payload.questionSet for text and choice questions. Paperclip generates compatibility questions; see the payload schema for formats.","inputSchema":{"additionalProperties":false,"allOf":[{"if":{"properties":{"interactionKind":{"const":"questions"}},"required":["interactionKind"]},"then":{"properties":{"payload":{"anyOf":[{"required":["questionSet"]},{"required":["questions"]}],"required":["version"]}},"required":["payload"]}}],"properties":{"continuationPolicy":{"enum":["none","wake_assignee","wake_assignee_on_accept"]},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"},"interactionKind":{"enum":["confirmation","checkbox","questions","suggest_tasks","item_verdicts"]},"payload":{"additionalProperties":true,"description":"Kind-specific interaction data. For questions, send version:1 and one complete questionSet containing every text and choice question. Paperclip generates compatibility questions. Each canonical question needs id, prompt, required, and answerMode: text, single_select, or multi_select. Text questions have no options or customAnswer. Choice questions need at least two meaningful options with id/label. Use customAnswer:{enabled:true} for an optional written answer to a choice question. Legacy questions remain supported; if both representations are supplied, they must describe the same complete form. Keep IDs stable across retries. For confirmation, payload may be {}.","properties":{"questionSet":{"additionalProperties":false,"properties":{"description":{"maxLength":100000,"type":"string"},"questions":{"items":{"additionalProperties":false,"allOf":[{"if":{"properties":{"answerMode":{"const":"text"}},"required":["answerMode"]},"then":{"not":{"required":["customAnswer"]},"properties":{"options":{"maxItems":0,"type":"array"}}}},{"if":{"properties":{"answerMode":{"enum":["single_select","multi_select"]}},"required":["answerMode"]},"then":{"not":{"required":["initialText"]},"properties":{"options":{"minItems":1,"type":"array"}},"required":["options"]}}],"properties":{"answerMode":{"enum":["single_select","multi_select","text"]},"customAnswer":{"additionalProperties":false,"properties":{"enabled":{"const":true},"label":{"maxLength":1000,"type":"string"},"placeholder":{"maxLength":1000,"type":"string"}},"required":["enabled"],"type":"object"},"header":{"maxLength":1000,"type":"string"},"helpText":{"maxLength":4000,"type":"string"},"id":{"maxLength":160,"minLength":1,"type":"string"},"initialText":{"description":"Editable draft of at most 100000 Unicode code points. Never an implicit answer; submitted text still uses the existing response bounds.","maxLength":100000,"type":"string"},"options":{"items":{"additionalProperties":false,"properties":{"description":{"maxLength":4000,"type":"string"},"id":{"maxLength":160,"minLength":1,"type":"string"},"label":{"maxLength":1000,"minLength":1,"type":"string"},"recommended":{"type":"boolean"}},"required":["id","label"],"type":"object"},"maxItems":128,"type":"array"},"prompt":{"maxLength":4000,"minLength":1,"type":"string"},"required":{"type":"boolean"},"textValidation":{"additionalProperties":false,"properties":{"inputType":{"enum":["text","number","integer"]},"maxLength":{"maximum":100000,"minimum":0,"type":"integer"},"maximum":{"type":"number"},"minLength":{"maximum":100000,"minimum":0,"type":"integer"},"minimum":{"type":"number"},"pattern":{"maxLength":1000,"type":"string"}},"type":"object"}},"required":["id","prompt","required","answerMode"],"type":"object"},"maxItems":64,"minItems":1,"type":"array"},"schema":{"const":"paperclip.question_set.v1"},"submitLabel":{"maxLength":200,"type":"string"},"title":{"maxLength":1000,"type":"string"}},"required":["schema","questions"],"type":"object"},"questions":{"items":{"additionalProperties":true,"properties":{"id":{"maxLength":160,"minLength":1,"type":"string"},"options":{"items":{"additionalProperties":true,"properties":{"freeText":{"type":"boolean"},"id":{"maxLength":160,"minLength":1,"type":"string"},"label":{"maxLength":1000,"minLength":1,"type":"string"}},"required":["id","label"],"type":"object"},"maxItems":129,"minItems":1,"type":"array"},"prompt":{"maxLength":4000,"minLength":1,"type":"string"},"required":{"type":"boolean"},"selectionMode":{"enum":["single","multi"]}},"required":["id","prompt","selectionMode","options"],"type":"object"},"maxItems":64,"minItems":1,"type":"array"},"version":{"const":1}},"type":"object"},"prompt":{"description":"Question or decision prompt.","maxLength":10000,"minLength":1,"type":"string"},"targetRevisionId":{"description":"Optional bound document revision.","maxLength":20000,"type":["string","null"]},"title":{"description":"Interaction card title.","maxLength":300,"minLength":1,"type":"string"}},"required":["idempotencyKey","interactionKind","title","prompt","continuationPolicy"],"type":"object"},"name":"request_human_input","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds"],"type":"object"}},{"annotations":{"exposure":"always","operationId":"register_deliverable","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Register attachment metadata and its artifact work product without credentials or bytes in the tool result.","inputSchema":{"additionalProperties":false,"properties":{"byteSize":{"maximum":100000000,"minimum":0,"type":"integer"},"contentRef":{"description":"Opaque package-local content reference.","maxLength":2000,"minLength":1,"type":"string"},"contentType":{"description":"Media type.","maxLength":200,"minLength":1,"type":"string"},"filename":{"description":"Display filename.","maxLength":500,"minLength":1,"type":"string"},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"},"sha256":{"pattern":"^[a-fA-F0-9]{64}$","type":"string"},"title":{"description":"Work-product title.","maxLength":500,"minLength":1,"type":"string"}},"required":["idempotencyKey","filename","contentType","byteSize","sha256","contentRef","title"],"type":"object"},"name":"register_deliverable","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds"],"type":"object"}},{"annotations":{"exposure":"always","operationId":"finish_task","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Finish the active task with a durable summary.","inputSchema":{"additionalProperties":false,"properties":{"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"},"summary":{"description":"Completion summary.","maxLength":20000,"minLength":1,"type":"string"}},"required":["idempotencyKey","summary"],"type":"object"},"name":"finish_task","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds"],"type":"object"}},{"annotations":{"exposure":"always","operationId":"block_task","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Block the active task with a durable reason and optional first-class dependencies.","inputSchema":{"additionalProperties":false,"properties":{"blockedByTaskIds":{"description":"Internal task ids that block this task.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"},"reason":{"description":"Block reason.","maxLength":20000,"minLength":1,"type":"string"}},"required":["idempotencyKey","reason"],"type":"object"},"name":"block_task","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds"],"type":"object"}},{"annotations":{"exposure":"always","operationId":"request_review","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Move the active task to review with a durable summary.","inputSchema":{"additionalProperties":false,"properties":{"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"},"summary":{"description":"Review handoff summary.","maxLength":20000,"minLength":1,"type":"string"}},"required":["idempotencyKey","summary"],"type":"object"},"name":"request_review","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds"],"type":"object"}},{"annotations":{"exposure":"optional","operationId":"list_agents","requiredClaims":["discovery:agents:read"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"List redacted actor profiles.","inputSchema":{"additionalProperties":false,"properties":{},"required":[],"type":"object"},"name":"list_agents","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"get_agent","requiredClaims":["discovery:agents:read"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Read one redacted actor profile.","inputSchema":{"additionalProperties":false,"properties":{"actorId":{"description":"Actor id.","maxLength":200,"minLength":1,"type":"string"}},"required":["actorId"],"type":"object"},"name":"get_agent","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"hire_agent","requiredClaims":["delegation:agents:create"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Create a persistent native Runner teammate with an identity and persona. The teammate reports to the caller and inherits the caller's native runtime; provider, adapter, environment, and credential settings are selected by Paperclip and are never caller-supplied here. Reuse a suitable teammate from list_agents when possible.","inputSchema":{"additionalProperties":false,"properties":{"capabilities":{"maxLength":2000,"type":["string","null"]},"instructions":{"maxLength":20000,"type":["string","null"]},"name":{"maxLength":200,"minLength":1,"type":"string"},"role":{"enum":["ceo","cto","cmo","cfo","security","engineer","designer","pm","qa","devops","researcher","general"],"type":"string"},"title":{"maxLength":300,"type":["string","null"]}},"required":["name"],"type":"object"},"name":"hire_agent","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"search_tasks","requiredClaims":["discovery:tasks:read"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Search tasks by text and status within the run company.","inputSchema":{"additionalProperties":false,"properties":{"limit":{"default":50,"maximum":200,"minimum":1,"type":"integer"},"query":{"maxLength":500,"type":"string"},"statuses":{"items":{"enum":["backlog","todo","in_progress","in_review","done","blocked","cancelled"]},"maxItems":7,"type":"array","uniqueItems":true}},"required":[],"type":"object"},"name":"search_tasks","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"list_approvals","requiredClaims":["governance:approvals:read"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"List approvals in the run company.","inputSchema":{"additionalProperties":false,"properties":{},"required":[],"type":"object"},"name":"list_approvals","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"get_approval","requiredClaims":["governance:approvals:read"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Read one approval without protected data.","inputSchema":{"additionalProperties":false,"properties":{"approvalId":{"description":"Approval id.","maxLength":200,"minLength":1,"type":"string"}},"required":["approvalId"],"type":"object"},"name":"get_approval","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"get_approval_context","requiredClaims":["governance:approvals:read"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Read one approval, its comments, and linked tasks.","inputSchema":{"additionalProperties":false,"properties":{"approvalId":{"description":"Approval id.","maxLength":200,"minLength":1,"type":"string"}},"required":["approvalId"],"type":"object"},"name":"get_approval_context","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"get_workspace_runtime","requiredClaims":["workspace:read"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Read active-task workspace services.","inputSchema":{"additionalProperties":false,"properties":{},"required":[],"type":"object"},"name":"get_workspace_runtime","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"control_workspace_service","requiredClaims":["workspace:control"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Start, stop, or fault one active-task workspace service.","inputSchema":{"additionalProperties":false,"properties":{"action":{"enum":["start","stop","fail"]},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"},"serviceId":{"description":"Workspace service id.","maxLength":200,"minLength":1,"type":"string"},"url":{"description":"Optional service URL.","maxLength":20000,"type":["string","null"]}},"required":["idempotencyKey","serviceId","action"],"type":"object"},"name":"control_workspace_service","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds"],"type":"object"}},{"annotations":{"exposure":"optional","operationId":"set_dependencies","requiredClaims":["dependencies:write"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Replace the active task's first-class blocker set.","inputSchema":{"additionalProperties":false,"properties":{"blockedByTaskIds":{"description":"Replacement blocker task ids.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"}},"required":["idempotencyKey","blockedByTaskIds"],"type":"object"},"name":"set_dependencies","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds"],"type":"object"}},{"annotations":{"exposure":"optional","operationId":"create_task","requiredClaims":["delegation:tasks:create"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Create a project task from a conversation, or a child from an ordinary task. Persist initialPlan before execution. Set status to backlog when the user wants to save or plan work without starting it; backlog tasks never wake an agent. Omitted status means todo, subject to blockers.","inputSchema":{"additionalProperties":false,"properties":{"assigneeActorId":{"description":"Optional agent assignee. Omit to assign the current agent.","maxLength":20000,"type":["string","null"]},"assigneeUserId":{"description":"Company person ID from list_people. Mutually exclusive with assigneeActorId.","type":["string","null"]},"blockedByTaskIds":{"description":"Initial blocker task ids.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"description":{"description":"Child task description.","maxLength":20000,"type":["string","null"]},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"},"initialPlan":{"description":"Relevant markdown plan saved on the new task before execution starts.","maxLength":200000,"type":["string","null"]},"priority":{"enum":["critical","high","medium","low"]},"projectId":{"description":"Project ID for the task.","type":["string","null"]},"status":{"description":"Initial status. Use backlog to save work without executing it. Defaults to todo (blocked when dependencies are unresolved).","enum":["backlog","todo"]},"title":{"description":"Child task title.","maxLength":500,"minLength":1,"type":"string"}},"required":["idempotencyKey","title"],"type":"object"},"name":"create_task","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"},"task":{"additionalProperties":false,"properties":{"assigneeActorId":{"type":["string","null"]},"assigneeUserId":{"description":"Company person ID from list_people. Mutually exclusive with assigneeActorId.","type":["string","null"]},"id":{"minLength":1,"type":"string"},"identifier":{"type":["string","null"]},"parentId":{"minLength":1,"type":["string","null"]},"projectId":{"minLength":1,"type":["string","null"]},"status":{"minLength":1,"type":"string"}},"required":["id","identifier","parentId","status","assigneeActorId"],"type":"object"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds","task"],"type":"object"}},{"annotations":{"exposure":"optional","operationId":"request_approval","requiredClaims":["governance:approvals:request"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Create a governed approval and waiting posture.","inputSchema":{"additionalProperties":false,"properties":{"approvalType":{"description":"Stable approval type.","maxLength":200,"minLength":1,"type":"string"},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"},"payload":{"additionalProperties":true,"type":"object"}},"required":["idempotencyKey","approvalType","payload"],"type":"object"},"name":"request_approval","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds"],"type":"object"}},{"annotations":{"exposure":"optional","operationId":"decide_approval","requiredClaims":["governance:approvals:decide"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Decide an approval as an explicitly authorized approver.","inputSchema":{"additionalProperties":false,"properties":{"approvalId":{"description":"Approval id.","maxLength":200,"minLength":1,"type":"string"},"decision":{"enum":["approved","rejected","cancelled"]},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"},"note":{"description":"Decision note.","maxLength":20000,"minLength":1,"type":"string"}},"required":["idempotencyKey","approvalId","decision","note"],"type":"object"},"name":"decide_approval","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds"],"type":"object"}},{"annotations":{"exposure":"optional","operationId":"comment_on_approval","requiredClaims":["governance:approvals:comment"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Add a durable comment to an approval.","inputSchema":{"additionalProperties":false,"properties":{"approvalId":{"description":"Approval id.","maxLength":200,"minLength":1,"type":"string"},"body":{"description":"Approval comment.","maxLength":20000,"minLength":1,"type":"string"},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"}},"required":["idempotencyKey","approvalId","body"],"type":"object"},"name":"comment_on_approval","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds"],"type":"object"}},{"annotations":{"exposure":"optional","operationId":"schedule_wake","requiredClaims":["control_plane:wakes"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Schedule a deterministic continuation wake.","inputSchema":{"additionalProperties":false,"properties":{"delayTicks":{"maximum":10000,"minimum":1,"type":"integer"},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"},"payload":{"additionalProperties":true,"type":"object"},"reason":{"enum":["manual","issue_commented","interaction_resolved","approval_resolved","blockers_resolved","scheduled_retry","resume"]}},"required":["idempotencyKey","reason","delayTicks"],"type":"object"},"name":"schedule_wake","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds"],"type":"object"}},{"annotations":{"exposure":"optional","operationId":"generic_api_request","requiredClaims":["test:generic_api_request"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Test-only escape hatch. Disabled unless the scenario and explicit claim both enable it.","inputSchema":{"additionalProperties":false,"properties":{"body":{"additionalProperties":true,"type":"object"},"method":{"enum":["GET","POST","PATCH"]},"path":{"maxLength":500,"pattern":"^/mock/","type":"string"}},"required":["method","path"],"type":"object"},"name":"generic_api_request","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"always","operationId":"read_agent_instructions","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Read the current canonical instruction entry and its revision, or inspect a historical revision by supplying both entryFile and revisionId. Read before updating; do not edit shared instruction caches.","inputSchema":{"additionalProperties":false,"properties":{"entryFile":{"description":"Configured relative entry filename returned by read_agent_instructions; retain it with the revision.","maxLength":4096,"minLength":1,"type":"string"},"revisionId":{"description":"Historical revision to inspect; also provide its entryFile.","pattern":"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$","type":"string"},"targetAgentId":{"description":"Same-company target agent. Omit to use the calling agent.","pattern":"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$","type":"string"}},"required":[],"type":"object"},"name":"read_agent_instructions","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"search_api","requiredClaims":["api:discover"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Fallback only: discover Paperclip API operations when the available dedicated tools cannot express the task. Prefer dedicated tools for common operations; do not search before using them. For a persistent hire, first list_agents to reuse a suitable teammate. Search agent-hires for the hiring schema and agent-configurations for compatible adapter/runtime settings, then use call_api with the returned operationId. Supply role-specific instructionsBundle.files as a filename-to-content record. Use the returned agent.id for delegation and obey any pending approval. Provider helper threads are temporary workers, not Paperclip hires. If a hire response is uncertain, reconcile with list_agents before retrying.","inputSchema":{"additionalProperties":false,"properties":{"cursor":{"maxLength":200,"type":"string"},"limit":{"default":5,"maximum":10,"minimum":1,"type":"integer"},"query":{"maxLength":500,"minLength":1,"type":"string"}},"required":["query"],"type":"object"},"name":"search_api","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"always","operationId":"update_agent_instructions","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Commit instruction content with the exact entryFile and baseRevisionId from a prior read. Requires the responsible user’s current target edit permission. On conflict, preserve your candidate and explicitly resolve it; never overwrite the newer head.","inputSchema":{"additionalProperties":false,"properties":{"baseRevisionId":{"description":"Revision read before editing; null only when no canonical entry exists. Never replace a stale base silently.","pattern":"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$","type":["string","null"]},"content":{"description":"Complete UTF-8 instruction content, at most 1 MiB; empty content is valid.","maxLength":1048576,"type":"string"},"entryFile":{"description":"Configured relative entry filename returned by read_agent_instructions; retain it with the revision.","maxLength":4096,"minLength":1,"type":"string"},"targetAgentId":{"description":"Same-company target agent. Omit to use the calling agent.","pattern":"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$","type":"string"}},"required":["entryFile","content","baseRevisionId"],"type":"object"},"name":"update_agent_instructions","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"call_api","requiredClaims":["api:call"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Fallback only: call a discovered Paperclip API operation when dedicated tools lack the required operation or parameters. Uses your existing permissions. Prefer dedicated tools; never bypass a denial or runner lifecycle tool. For large text responses, read the returned artifact with GET /api/assets/{assetId}/content and responseText; follow nextOffsetBytes until null.","inputSchema":{"additionalProperties":false,"properties":{"body":{"anyOf":[{"additionalProperties":true,"type":"object"},{"items":{},"type":"array"},{"type":"string"},{"type":"number"},{"type":"boolean"},{"type":"null"}],"description":"Request value matching the discovered schema. For JSON object or array requests, pass the object or array directly, never a JSON-encoded string. Strings are for text bodies or endpoints whose schema explicitly accepts a string."},"contentType":{"maxLength":120,"type":"string"},"files":{"items":{"additionalProperties":false,"oneOf":[{"properties":{"artifactId":{}},"required":["artifactId"]},{"properties":{"path":{}},"required":["path"]}],"properties":{"artifactId":{"type":"string"},"field":{"type":"string"},"path":{"description":"File relative to the active issue workspace. Remote files must first be uploaded as an artifact.","type":"string"}},"type":"object"},"maxItems":10,"type":"array"},"operationId":{"description":"Exact operationId returned by search_api, for example GET /api/projects/{id}. Do not guess identifiers.","maxLength":500,"minLength":1,"type":"string"},"pathParams":{"additionalProperties":{"type":"string"},"type":"object"},"query":{"additionalProperties":true,"type":"object"},"responseText":{"additionalProperties":false,"description":"GET only: return a bounded UTF-8 text window inline, including JSON as text, without saving another artifact. Offsets and limits are bytes. Use the returned nextOffsetBytes to continue; null means complete. Prefer reading a saved artifact for a stable snapshot. New live responses have a 1 GiB capture limit and a 4 GiB per-run capture budget. Existing larger assets remain readable in bounded pages. If a live response returns an artifact, continue on its content operation for a stable snapshot.","properties":{"limitBytes":{"default":24576,"maximum":24576,"minimum":4,"type":"integer"},"offsetBytes":{"default":0,"maximum":9007199254740991,"minimum":0,"type":"integer"}},"type":"object"}},"required":["operationId"],"type":"object"},"name":"call_api","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"always","operationId":"get_agent_instruction_history","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"List bounded canonical instruction revision metadata, including actor, source run, and restore origin. Use read_agent_instructions with entryFile and revisionId to inspect exact historical content.","inputSchema":{"additionalProperties":false,"properties":{"cursor":{"maxLength":2048,"minLength":1,"type":"string"},"entryFile":{"description":"Configured relative entry filename returned by read_agent_instructions; retain it with the revision.","maxLength":4096,"minLength":1,"type":"string"},"limit":{"maximum":100,"minimum":1,"type":"integer"},"targetAgentId":{"description":"Same-company target agent. Omit to use the calling agent.","pattern":"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$","type":"string"}},"required":["entryFile"],"type":"object"},"name":"get_agent_instruction_history","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"always","operationId":"restore_agent_instructions","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Append a historical instruction revision as the current content using the current baseRevisionId. Requires the responsible user’s current target edit permission. History remains intact; conflicts require an explicit resolution.","inputSchema":{"additionalProperties":false,"properties":{"baseRevisionId":{"description":"Current revision read before restoring. Never replace a stale base silently.","pattern":"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$","type":"string"},"entryFile":{"description":"Configured relative entry filename returned by read_agent_instructions; retain it with the revision.","maxLength":4096,"minLength":1,"type":"string"},"revisionId":{"description":"Historical revision whose exact content should be restored.","pattern":"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$","type":"string"},"targetAgentId":{"description":"Same-company target agent. Omit to use the calling agent.","pattern":"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$","type":"string"}},"required":["entryFile","revisionId","baseRevisionId"],"type":"object"},"name":"restore_agent_instructions","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"create_project","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Create a project after considering existing projects and available repositories. repositoryIds and repositoryUrls accept multiple existing repositories. Use HTTPS GitHub repositoryUrls when an accessible repo is not in the catalog; this registers project repositories, not remote GitHub repositories. Non-code projects may omit repositories. Cannot combine repositoryIds/repositoryUrls with workspace. Reuse the idempotency key on retries.","inputSchema":{"additionalProperties":false,"properties":{"archivedAt":{"description":"Archive timestamp.","maxLength":20000,"type":["string","null"]},"color":{"description":"Project color.","maxLength":20000,"type":["string","null"]},"description":{"description":"Project outcome and context.","maxLength":20000,"type":["string","null"]},"env":{"additionalProperties":true,"type":"object"},"executionWorkspacePolicy":{"additionalProperties":true,"type":"object"},"goalId":{"description":"Goal ID.","maxLength":20000,"type":["string","null"]},"goalIds":{"description":"Goal IDs.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"icon":{"description":"Project icon.","enum":["folder","rocket","code","terminal","database","globe","package","boxes","box","layers","briefcase","compass","target","flame","zap","star","bug","wrench","hammer","lightbulb","sparkles","shield","lock","search","cog","brain","cpu","git-branch","file-code","puzzle","gem","atom","heart","mail","message-square","crown","radar","telescope","hexagon",null],"type":["string","null"]},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"},"leadAgentId":{"description":"Lead agent ID.","maxLength":20000,"type":["string","null"]},"name":{"description":"Project name.","maxLength":500,"minLength":1,"type":"string"},"repositoryIds":{"description":"Authorized repository IDs from list_project_repositories; may contain multiple repositories.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"repositoryUrls":{"description":"Existing HTTPS GitHub repository URLs, including repos absent from the catalog.","items":{"maxLength":2000,"pattern":"^https://github\\.com/(?!\\.{1,2}/)[A-Za-z0-9_.-]+/(?!\\.{1,2}/?$)[A-Za-z0-9_.-]+/?$","type":"string"},"maxItems":100,"type":"array"},"status":{"enum":["backlog","planned","in_progress","completed","cancelled"]},"targetDate":{"description":"Target date.","maxLength":20000,"type":["string","null"]},"workspace":{"additionalProperties":true,"type":"object"}},"required":["idempotencyKey","name"],"type":"object"},"name":"create_project","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"list_project_repositories","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"List authorized repositories with stable IDs and names. Consider appropriate repositories before creating a project; never invent IDs.","inputSchema":{"additionalProperties":false,"properties":{},"required":[],"type":"object"},"name":"list_project_repositories","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"list_projects","requiredClaims":["discovery:projects:read"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"List company project summaries (IDs, names, status, and up to 1,000 characters of description). Returns up to 50 projects and nextCursor; continue with cursor until it is null. Read a project's API resource for full details.","inputSchema":{"additionalProperties":false,"properties":{"cursor":{"description":"The nextCursor returned by the previous page.","pattern":"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$","type":"string"},"limit":{"description":"Page size (default 50).","maximum":50,"minimum":1,"type":"integer"}},"required":[],"type":"object"},"name":"list_projects","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"create_skill","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Create a reusable single-file skill in the company library. Supply a complete SKILL.md whose name and description match the inputs. This saves the skill and shows a card; it does not assign the skill to any agent. Reuse idempotencyKey on retries.","inputSchema":{"additionalProperties":false,"properties":{"description":{"maxLength":2000,"minLength":1,"type":"string"},"idempotencyKey":{"maxLength":240,"minLength":1,"type":"string"},"markdown":{"description":"Complete SKILL.md including name and description frontmatter and substantive instructions.","maxLength":200000,"minLength":1,"type":"string"},"name":{"description":"Lowercase skill name, matching SKILL.md frontmatter.","maxLength":120,"minLength":1,"pattern":"^[a-z0-9]+(?:-[a-z0-9]+)*$","type":"string"},"slug":{"description":"Optional; must equal name.","maxLength":120,"minLength":1,"pattern":"^[a-z0-9]+(?:-[a-z0-9]+)*$","type":"string"}},"required":["idempotencyKey","name","description","markdown"],"type":"object"},"name":"create_skill","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"reassign_task","requiredClaims":["delegation:tasks:assign"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Reassign an existing task to another company agent. Read search_tasks first and supply its current assignee and statusVersion to prevent overwriting a concurrent change. Preserve the task, documents, dependencies, and blocked/backlog status. Active work is stopped before handoff. Use a stable idempotency key for retries. Cannot reassign this run’s own task, conversations, completed tasks, or pending reviews; use create_task for delegation from the current task.","inputSchema":{"additionalProperties":false,"properties":{"assigneeActorId":{"description":"New company agent ID from list_agents; null when assigning a person.","type":["string","null"]},"assigneeUserId":{"description":"Company person ID from list_people. Mutually exclusive with assigneeActorId.","type":["string","null"]},"expectedAssigneeActorId":{"description":"Current assigneeAgentId from search_tasks; null means unassigned.","type":["string","null"]},"expectedAssigneeUserId":{"description":"Current assigneeUserId from search_tasks; omit only when there is no person assigned.","type":["string","null"]},"expectedStatusVersion":{"description":"Current statusVersion from search_tasks.","minimum":0,"type":"integer"},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"},"reason":{"description":"Why the task should move and context for the new owner.","maxLength":20000,"minLength":1,"type":"string"},"taskId":{"description":"Existing task ID from search_tasks.","minLength":1,"type":"string"}},"required":["idempotencyKey","taskId","assigneeActorId","expectedAssigneeActorId","expectedStatusVersion","reason"],"type":"object"},"name":"reassign_task","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds"],"type":"object"}},{"annotations":{"exposure":"optional","operationId":"update_skill","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Replace an existing company skill's complete SKILL.md using the current version as a guard. Reuse idempotencyKey on lost-response retries; read again after a version conflict.","inputSchema":{"additionalProperties":false,"properties":{"expectedVersionId":{"minLength":1,"type":"string"},"idempotencyKey":{"maxLength":240,"minLength":1,"type":"string"},"markdown":{"maxLength":200000,"minLength":1,"type":"string"},"skillId":{"minLength":1,"type":"string"}},"required":["skillId","markdown","expectedVersionId","idempotencyKey"],"type":"object"},"name":"update_skill","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"set_task_title","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Set a concise, descriptive title for the active task. When its titleNeedsGeneration is true, call this early with onlyIfProvisional: true to replace the initial prompt slice without overwriting a user's title. Use false only for an intentional rename. This changes no task status, ownership, or description.","inputSchema":{"additionalProperties":false,"properties":{"idempotencyKey":{"description":"Reuse this key on retries.","maxLength":240,"minLength":1,"type":"string"},"onlyIfProvisional":{"description":"True for automatic initial naming; preserves any title already chosen by a user or agent.","type":"boolean"},"title":{"description":"Short title describing the requested outcome.","maxLength":240,"minLength":1,"type":"string"}},"required":["idempotencyKey","title","onlyIfProvisional"],"type":"object"},"name":"set_task_title","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"set_task_monitor","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Schedule, replace, or clear a one-shot task monitor. Omit taskId for your current task; other tasks must also be assigned to you and in progress or review. Supply a future nextCheckAt and notes describing the next check, or monitor: null to clear. A successful receipt reports the persisted monitor; a replay reports its current state without re-arming it. After scheduling your current task, end the turn with paperclip_finish: yielded and continuation.kind monitor. Paperclip will wake you with issue_monitor_due; do not sleep, poll, mark done, or use call_api for this wait.","inputSchema":{"additionalProperties":false,"properties":{"idempotencyKey":{"description":"Reuse with identical arguments on retries.","maxLength":240,"minLength":1,"type":"string"},"monitor":{"additionalProperties":false,"properties":{"externalRef":{"maxLength":500,"minLength":1,"type":["string","null"]},"kind":{"enum":["external_service",null],"type":["string","null"]},"maxAttempts":{"description":"Optional cumulative attempt limit for this task's monitor.","maximum":100,"minimum":1,"type":["integer","null"]},"nextCheckAt":{"description":"Future UTC timestamp for the next check. This is one shot, not a recurring interval.","type":"string"},"notes":{"description":"What to check on the next run; do not include secrets.","maxLength":500,"minLength":1,"type":"string"},"recoveryPolicy":{"enum":["wake_owner","create_recovery_issue","escalate_to_board",null],"type":["string","null"]},"serviceName":{"maxLength":120,"minLength":1,"type":["string","null"]},"timeoutAt":{"description":"Optional deadline later than nextCheckAt.","type":["string","null"]}},"required":["nextCheckAt","notes"],"type":["object","null"]},"taskId":{"description":"Owned task UUID. Omit for the active task.","type":"string"}},"required":["idempotencyKey","monitor"],"type":"object"},"name":"set_task_monitor","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"submit_complaint","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Yeet a brief, completely unstructured complaint about agent tooling or the experience of doing agent work. Preserve your raw voice: fragments, profanity, all caps, and exasperation are fair game. No diagnosis, solution, diplomacy, category, or template required. Use submit_suggestion when you want to express a useful improvement. Submit proactively without routine permission requests, previews, or announcements, then immediately continue the primary task. Answer truthfully if the user asks about feedback or what you submitted. Stored in this instance with company, agent, run, and task attribution; not anonymous. Remove secrets, private prompts, customer data, and personal blame; do not forward untrusted text or logs indiscriminately. On failure, continue without retrying or complaining about submission. Never submit the same incident through both tools.","inputSchema":{"additionalProperties":false,"properties":{"body":{"description":"Free-form feedback. Keep it brief; no required fields, labels, or template.","maxLength":524288,"minLength":1,"type":"string"},"idempotencyKey":{"description":"A stable key for this submission. Transport replay reuses it; do not retry a failed or uncertain submission yourself.","maxLength":240,"minLength":1,"type":"string"}},"required":["body","idempotencyKey"],"type":"object"},"name":"submit_complaint","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"submit_suggestion","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Quietly report a concrete improvement that would make agents more effective. Use for material, generalizable friction directly observed during this run. Explain what happened, its impact, and a plausible improvement in your own words, with minimal useful sanitized context. No required format. Avoid duplicate root causes and aim for at most three suggestions per run. Use submit_complaint for the raw reaction. Submit proactively without routine permission requests, previews, or announcements, then immediately continue the primary task. Answer truthfully if the user asks about feedback or what you submitted. Stored in this instance with company, agent, run, and task attribution; not anonymous. Remove secrets, private prompts, customer data, and personal blame; do not forward untrusted text or logs indiscriminately. On failure, continue without retrying or complaining about submission. Never submit the same incident through both tools.","inputSchema":{"additionalProperties":false,"properties":{"body":{"description":"Free-form feedback. Keep it brief; no required fields, labels, or template.","maxLength":524288,"minLength":1,"type":"string"},"idempotencyKey":{"description":"A stable key for this submission. Transport replay reuses it; do not retry a failed or uncertain submission yourself.","maxLength":240,"minLength":1,"type":"string"}},"required":["body","idempotencyKey"],"type":"object"},"name":"submit_suggestion","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"get_workspace","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Inspect current task files, repositories, pending selection, binding revision, and preparation capabilities. AGENT_HOME is personal instructions and memory; task outputs belong in the workspace.","inputSchema":{"additionalProperties":false,"properties":{},"required":[],"type":"object"},"name":"get_workspace","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"list_workspaces","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"List authorized reusable workspaces. A project is optional; choosing a workspace does not assign a project.","inputSchema":{"additionalProperties":false,"properties":{},"required":[],"type":"object"},"name":"list_workspaces","outputSchema":{"items":{"additionalProperties":true,"type":"object"},"type":"array"}},{"annotations":{"exposure":"optional","operationId":"select_workspace","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Request a different task workspace at the next normal admission. Does not interrupt or wake the current run. Inspect the binding revision first and reuse requestKey on retries; existing files stay in their current folder.","inputSchema":{"additionalProperties":false,"properties":{"expectedBindingRevision":{"minimum":0,"type":"integer"},"requestKey":{"description":"Stable retry key.","minLength":1,"type":"string"},"selection":{"oneOf":[{"additionalProperties":false,"properties":{"kind":{"const":"task_directory"}},"required":["kind"],"type":"object"},{"additionalProperties":false,"properties":{"kind":{"const":"existing"},"workspaceId":{"description":"Existing authorized workspace ID.","minLength":1,"type":"string"}},"required":["kind","workspaceId"],"type":"object"},{"additionalProperties":false,"properties":{"kind":{"const":"configured_source"},"mode":{"enum":["shared","managed_isolated"]},"projectWorkspaceId":{"description":"Configured source workspace ID.","minLength":1,"type":"string"}},"required":["kind","projectWorkspaceId","mode"],"type":"object"}]}},"required":["selection","expectedBindingRevision","requestKey"],"type":"object"},"name":"select_workspace","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"prepare_repository","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Request an authorized repository inside task files at the next normal admission, without creating a project. Discover repository IDs first. Does not interrupt or wake the current run. Reuse requestKey; a conflicting ref never resets existing work.","inputSchema":{"additionalProperties":false,"properties":{"ref":{"description":"Optional Git branch, tag, or commit. Default HEAD is pinned once.","minLength":1,"type":"string"},"repository":{"oneOf":[{"additionalProperties":false,"properties":{"id":{"description":"Authorized ID from list_project_repositories.","minLength":1,"type":"string"},"kind":{"const":"catalog"}},"required":["kind","id"],"type":"object"},{"additionalProperties":false,"properties":{"kind":{"const":"url"},"url":{"description":"Existing HTTPS GitHub repository URL; uncataloged URLs use public anonymous access only.","minLength":1,"type":"string"}},"required":["kind","url"],"type":"object"}]},"requestKey":{"description":"Stable retry key.","minLength":1,"type":"string"}},"required":["repository","requestKey"],"type":"object"},"name":"prepare_repository","outputSchema":{"additionalProperties":true,"type":"object"}}] diff --git a/packages/paperclip-runner/generated/semantic-action-catalog.json b/packages/paperclip-runner/generated/semantic-action-catalog.json index 822984b3d77..e45555acc0e 100644 --- a/packages/paperclip-runner/generated/semantic-action-catalog.json +++ b/packages/paperclip-runner/generated/semantic-action-catalog.json @@ -1883,6 +1883,236 @@ "title": "Set task dependencies", "version": 1 }, + { + "allowedModes": [ + "standard", + "ask", + "planning", + "skill_test" + ], + "description": "Inspect current task files, repositories, pending selection, binding revision, and preparation capabilities. AGENT_HOME is personal instructions and memory; task outputs belong in the workspace.", + "effect": "read", + "inputSchema": { + "additionalProperties": false, + "properties": {}, + "required": [], + "type": "object" + }, + "operationId": "get_workspace", + "outputSchema": { + "additionalProperties": true, + "type": "object" + }, + "placement": "optional", + "requiredClaims": [], + "schema": "paperclip.semantic-action.v1", + "title": "Inspect task workspace", + "version": 1 + }, + { + "allowedModes": [ + "standard", + "ask", + "planning", + "skill_test" + ], + "description": "List authorized reusable workspaces. A project is optional; choosing a workspace does not assign a project.", + "effect": "read", + "inputSchema": { + "additionalProperties": false, + "properties": {}, + "required": [], + "type": "object" + }, + "operationId": "list_workspaces", + "outputSchema": { + "items": { + "additionalProperties": true, + "type": "object" + }, + "type": "array" + }, + "placement": "optional", + "requiredClaims": [], + "schema": "paperclip.semantic-action.v1", + "title": "List available workspaces", + "version": 1 + }, + { + "allowedModes": [ + "standard", + "skill_test" + ], + "description": "Request a different task workspace at the next normal admission. Does not interrupt or wake the current run. Inspect the binding revision first and reuse requestKey on retries; existing files stay in their current folder.", + "effect": "write", + "inputSchema": { + "additionalProperties": false, + "properties": { + "expectedBindingRevision": { + "minimum": 0, + "type": "integer" + }, + "requestKey": { + "description": "Stable retry key.", + "minLength": 1, + "type": "string" + }, + "selection": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "kind": { + "const": "task_directory" + } + }, + "required": [ + "kind" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "kind": { + "const": "existing" + }, + "workspaceId": { + "description": "Existing authorized workspace ID.", + "minLength": 1, + "type": "string" + } + }, + "required": [ + "kind", + "workspaceId" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "kind": { + "const": "configured_source" + }, + "mode": { + "enum": [ + "shared", + "managed_isolated" + ] + }, + "projectWorkspaceId": { + "description": "Configured source workspace ID.", + "minLength": 1, + "type": "string" + } + }, + "required": [ + "kind", + "projectWorkspaceId", + "mode" + ], + "type": "object" + } + ] + } + }, + "required": [ + "selection", + "expectedBindingRevision", + "requestKey" + ], + "type": "object" + }, + "operationId": "select_workspace", + "outputSchema": { + "additionalProperties": true, + "type": "object" + }, + "placement": "optional", + "requiredClaims": [], + "schema": "paperclip.semantic-action.v1", + "title": "Select task workspace", + "version": 1 + }, + { + "allowedModes": [ + "standard", + "skill_test" + ], + "description": "Request an authorized repository inside task files at the next normal admission, without creating a project. Discover repository IDs first. Does not interrupt or wake the current run. Reuse requestKey; a conflicting ref never resets existing work.", + "effect": "write", + "inputSchema": { + "additionalProperties": false, + "properties": { + "ref": { + "description": "Optional Git branch, tag, or commit. Default HEAD is pinned once.", + "minLength": 1, + "type": "string" + }, + "repository": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "id": { + "description": "Authorized ID from list_project_repositories.", + "minLength": 1, + "type": "string" + }, + "kind": { + "const": "catalog" + } + }, + "required": [ + "kind", + "id" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "kind": { + "const": "url" + }, + "url": { + "description": "Existing HTTPS GitHub repository URL; uncataloged URLs use public anonymous access only.", + "minLength": 1, + "type": "string" + } + }, + "required": [ + "kind", + "url" + ], + "type": "object" + } + ] + }, + "requestKey": { + "description": "Stable retry key.", + "minLength": 1, + "type": "string" + } + }, + "required": [ + "repository", + "requestKey" + ], + "type": "object" + }, + "operationId": "prepare_repository", + "outputSchema": { + "additionalProperties": true, + "type": "object" + }, + "placement": "optional", + "requiredClaims": [], + "schema": "paperclip.semantic-action.v1", + "title": "Prepare task repository", + "version": 1 + }, { "allowedModes": [ "standard", diff --git a/packages/paperclip-runner/protocol/fixtures/evals/native-execution-seeded.json b/packages/paperclip-runner/protocol/fixtures/evals/native-execution-seeded.json index bbfdfd46930..7a12f8c3b22 100644 --- a/packages/paperclip-runner/protocol/fixtures/evals/native-execution-seeded.json +++ b/packages/paperclip-runner/protocol/fixtures/evals/native-execution-seeded.json @@ -24,7 +24,7 @@ "prpVersion": 1, "nativeExecutionVersion": 1, "catalogVersion": 1, - "catalogSha256": "sha256:ff3e2bdc66e564cef9500f0940b9afe9d1878e7c8ca3d5a5a0c986b23ffe79de", + "catalogSha256": "sha256:73f19996d9e1b46e957ba732354eb24daf4e96776ae121ea059acfaf01f56ef6", "driverContractVersion": 1, "driverKind": "paperclip-deterministic", "driverVersion": "1.0.0" diff --git a/packages/paperclip-runner/protocol/manifest.json b/packages/paperclip-runner/protocol/manifest.json index 2eefd4f3069..234c6e49139 100644 --- a/packages/paperclip-runner/protocol/manifest.json +++ b/packages/paperclip-runner/protocol/manifest.json @@ -175,7 +175,7 @@ }, { "path": "fixtures/evals/native-execution-seeded.json", - "sha256": "248c366cd272e1cc13506886bee4ee15fd63813280583b5859011446035ebbbb", + "sha256": "872f556fd39a4b7e50a902d09c8b42f8fc729030d03642983fa16872f623e1ef", "expectation": "accept", "compatibilityCase": "canonical" }, diff --git a/packages/paperclip-runner/scripts/check-capability-inventory.test.mjs b/packages/paperclip-runner/scripts/check-capability-inventory.test.mjs index 9da72f5f233..66ff6d15ec4 100644 --- a/packages/paperclip-runner/scripts/check-capability-inventory.test.mjs +++ b/packages/paperclip-runner/scripts/check-capability-inventory.test.mjs @@ -42,7 +42,7 @@ function validInventories() { schemaVersion: 2, inventoryRole: "normative", generatedFrom: ["skills/paperclip/SKILL.md"], - rows: Array.from({ length: 162 }, (_, index) => row(`capability-${index}`)), + rows: Array.from({ length: 164 }, (_, index) => row(`capability-${index}`)), }, evaluations: { schemaVersion: 2, diff --git a/packages/paperclip-runner/scripts/lib/capability-inventory.mjs b/packages/paperclip-runner/scripts/lib/capability-inventory.mjs index c56a53d2081..940961deb20 100644 --- a/packages/paperclip-runner/scripts/lib/capability-inventory.mjs +++ b/packages/paperclip-runner/scripts/lib/capability-inventory.mjs @@ -247,7 +247,7 @@ export async function buildMcpInventory(repoRoot) { export function validateInventories(inventories) { const errors = []; - const expectedCounts = { capabilities: 162, evaluations: 106, legacyMcpAliases: 42 }; + const expectedCounts = { capabilities: 164, evaluations: 106, legacyMcpAliases: 42 }; const normativeNames = ["capabilities", "evaluations"]; const normativeRows = new Map(); const globalNormativeIds = new Set(); diff --git a/packages/paperclip-runner/spec/capability/capabilities.yaml b/packages/paperclip-runner/spec/capability/capabilities.yaml index 33a91219114..7899ff444d0 100644 --- a/packages/paperclip-runner/spec/capability/capabilities.yaml +++ b/packages/paperclip-runner/spec/capability/capabilities.yaml @@ -315,11 +315,11 @@ ] }, { - "id": "skill:skills/paperclip/SKILL.md:issue-workspace-runtime-controls:547", + "id": "skill:skills/paperclip/SKILL.md:task-files-and-workspace-choices:547", "sourceKind": "skill_heading", "sourceAnchor": "skills/paperclip/SKILL.md:547", - "title": "Issue Workspace Runtime Controls", - "expectedSemantics": "Skill guidance headed “Issue Workspace Runtime Controls”.", + "title": "Task Files and Workspace Choices", + "expectedSemantics": "Skill guidance headed “Task Files and Workspace Choices”.", "primaryDisposition": "optional_agent_tool", "requiredGrants": [], "assertionClasses": [ @@ -330,9 +330,24 @@ ] }, { - "id": "skill:skills/paperclip/SKILL.md:proposing-credentials-safely:554", + "id": "skill:skills/paperclip/SKILL.md:issue-workspace-runtime-controls:553", "sourceKind": "skill_heading", - "sourceAnchor": "skills/paperclip/SKILL.md:554", + "sourceAnchor": "skills/paperclip/SKILL.md:553", + "title": "Issue Workspace Runtime Controls", + "expectedSemantics": "Skill guidance headed “Issue Workspace Runtime Controls”.", + "primaryDisposition": "optional_agent_tool", + "requiredGrants": [], + "assertionClasses": [ + "control_plane_invariant" + ], + "evidenceIds": [ + "skill:skills/paperclip/SKILL.md:553" + ] + }, + { + "id": "skill:skills/paperclip/SKILL.md:proposing-credentials-safely:560", + "sourceKind": "skill_heading", + "sourceAnchor": "skills/paperclip/SKILL.md:560", "title": "Proposing Credentials Safely", "expectedSemantics": "Skill guidance headed “Proposing Credentials Safely”.", "primaryDisposition": "optional_agent_tool", @@ -341,13 +356,13 @@ "control_plane_invariant" ], "evidenceIds": [ - "skill:skills/paperclip/SKILL.md:554" + "skill:skills/paperclip/SKILL.md:560" ] }, { - "id": "skill:skills/paperclip/SKILL.md:reading-granted-secrets:561", + "id": "skill:skills/paperclip/SKILL.md:reading-granted-secrets:567", "sourceKind": "skill_heading", - "sourceAnchor": "skills/paperclip/SKILL.md:561", + "sourceAnchor": "skills/paperclip/SKILL.md:567", "title": "Reading Granted Secrets", "expectedSemantics": "Skill guidance headed “Reading Granted Secrets”.", "primaryDisposition": "optional_agent_tool", @@ -356,13 +371,13 @@ "control_plane_invariant" ], "evidenceIds": [ - "skill:skills/paperclip/SKILL.md:561" + "skill:skills/paperclip/SKILL.md:567" ] }, { - "id": "skill:skills/paperclip/SKILL.md:critical-rules:587", + "id": "skill:skills/paperclip/SKILL.md:critical-rules:593", "sourceKind": "skill_heading", - "sourceAnchor": "skills/paperclip/SKILL.md:587", + "sourceAnchor": "skills/paperclip/SKILL.md:593", "title": "Critical Rules", "expectedSemantics": "Skill guidance headed “Critical Rules”.", "primaryDisposition": "optional_agent_tool", @@ -371,13 +386,13 @@ "control_plane_invariant" ], "evidenceIds": [ - "skill:skills/paperclip/SKILL.md:587" + "skill:skills/paperclip/SKILL.md:593" ] }, { - "id": "skill:skills/paperclip/SKILL.md:comment-style-required:608", + "id": "skill:skills/paperclip/SKILL.md:comment-style-required:614", "sourceKind": "skill_heading", - "sourceAnchor": "skills/paperclip/SKILL.md:608", + "sourceAnchor": "skills/paperclip/SKILL.md:614", "title": "Comment Style (Required)", "expectedSemantics": "Skill guidance headed “Comment Style (Required)”.", "primaryDisposition": "always_agent_tool", @@ -386,13 +401,13 @@ "control_plane_invariant" ], "evidenceIds": [ - "skill:skills/paperclip/SKILL.md:608" + "skill:skills/paperclip/SKILL.md:614" ] }, { - "id": "skill:skills/paperclip/SKILL.md:update:640", + "id": "skill:skills/paperclip/SKILL.md:update:646", "sourceKind": "skill_heading", - "sourceAnchor": "skills/paperclip/SKILL.md:640", + "sourceAnchor": "skills/paperclip/SKILL.md:646", "title": "Update", "expectedSemantics": "Skill guidance headed “Update”.", "primaryDisposition": "optional_agent_tool", @@ -401,13 +416,13 @@ "control_plane_invariant" ], "evidenceIds": [ - "skill:skills/paperclip/SKILL.md:640" + "skill:skills/paperclip/SKILL.md:646" ] }, { - "id": "skill:skills/paperclip/SKILL.md:task-documents-and-deliverables:650", + "id": "skill:skills/paperclip/SKILL.md:task-documents-and-deliverables:656", "sourceKind": "skill_heading", - "sourceAnchor": "skills/paperclip/SKILL.md:650", + "sourceAnchor": "skills/paperclip/SKILL.md:656", "title": "Task Documents and Deliverables", "expectedSemantics": "Skill guidance headed “Task Documents and Deliverables”.", "primaryDisposition": "always_agent_tool", @@ -416,13 +431,13 @@ "control_plane_invariant" ], "evidenceIds": [ - "skill:skills/paperclip/SKILL.md:650" + "skill:skills/paperclip/SKILL.md:656" ] }, { - "id": "skill:skills/paperclip/SKILL.md:planning-required-when-planning-requested:664", + "id": "skill:skills/paperclip/SKILL.md:planning-required-when-planning-requested:670", "sourceKind": "skill_heading", - "sourceAnchor": "skills/paperclip/SKILL.md:664", + "sourceAnchor": "skills/paperclip/SKILL.md:670", "title": "Planning (Required when planning requested)", "expectedSemantics": "Skill guidance headed “Planning (Required when planning requested)”.", "primaryDisposition": "optional_agent_tool", @@ -431,13 +446,13 @@ "control_plane_invariant" ], "evidenceIds": [ - "skill:skills/paperclip/SKILL.md:664" + "skill:skills/paperclip/SKILL.md:670" ] }, { - "id": "skill:skills/paperclip/SKILL.md:key-endpoints-hot-routes:695", + "id": "skill:skills/paperclip/SKILL.md:key-endpoints-hot-routes:701", "sourceKind": "skill_heading", - "sourceAnchor": "skills/paperclip/SKILL.md:695", + "sourceAnchor": "skills/paperclip/SKILL.md:701", "title": "Key Endpoints (Hot Routes)", "expectedSemantics": "Skill guidance headed “Key Endpoints (Hot Routes)”.", "primaryDisposition": "optional_agent_tool", @@ -446,13 +461,13 @@ "control_plane_invariant" ], "evidenceIds": [ - "skill:skills/paperclip/SKILL.md:695" + "skill:skills/paperclip/SKILL.md:701" ] }, { - "id": "skill:skills/paperclip/SKILL.md:searching-issues:724", + "id": "skill:skills/paperclip/SKILL.md:searching-issues:730", "sourceKind": "skill_heading", - "sourceAnchor": "skills/paperclip/SKILL.md:724", + "sourceAnchor": "skills/paperclip/SKILL.md:730", "title": "Searching Issues", "expectedSemantics": "Skill guidance headed “Searching Issues”.", "primaryDisposition": "optional_agent_tool", @@ -461,13 +476,13 @@ "control_plane_invariant" ], "evidenceIds": [ - "skill:skills/paperclip/SKILL.md:724" + "skill:skills/paperclip/SKILL.md:730" ] }, { - "id": "skill:skills/paperclip/SKILL.md:full-reference:734", + "id": "skill:skills/paperclip/SKILL.md:full-reference:740", "sourceKind": "skill_heading", - "sourceAnchor": "skills/paperclip/SKILL.md:734", + "sourceAnchor": "skills/paperclip/SKILL.md:740", "title": "Full Reference", "expectedSemantics": "Skill guidance headed “Full Reference”.", "primaryDisposition": "optional_agent_tool", @@ -476,13 +491,13 @@ "control_plane_invariant" ], "evidenceIds": [ - "skill:skills/paperclip/SKILL.md:734" + "skill:skills/paperclip/SKILL.md:740" ] }, { - "id": "skill:skills/paperclip/SKILL.md:conversational-confirmation-answers:738", + "id": "skill:skills/paperclip/SKILL.md:conversational-confirmation-answers:744", "sourceKind": "skill_heading", - "sourceAnchor": "skills/paperclip/SKILL.md:738", + "sourceAnchor": "skills/paperclip/SKILL.md:744", "title": "Conversational confirmation answers", "expectedSemantics": "Skill guidance headed “Conversational confirmation answers”.", "primaryDisposition": "always_agent_tool", @@ -491,13 +506,13 @@ "control_plane_invariant" ], "evidenceIds": [ - "skill:skills/paperclip/SKILL.md:738" + "skill:skills/paperclip/SKILL.md:744" ] }, { - "id": "skill:skills/paperclip/SKILL.md:incidental-feedback:755", + "id": "skill:skills/paperclip/SKILL.md:incidental-feedback:761", "sourceKind": "skill_heading", - "sourceAnchor": "skills/paperclip/SKILL.md:755", + "sourceAnchor": "skills/paperclip/SKILL.md:761", "title": "Incidental feedback", "expectedSemantics": "Skill guidance headed “Incidental feedback”.", "primaryDisposition": "optional_agent_tool", @@ -506,7 +521,7 @@ "control_plane_invariant" ], "evidenceIds": [ - "skill:skills/paperclip/SKILL.md:755" + "skill:skills/paperclip/SKILL.md:761" ] }, { @@ -2040,11 +2055,11 @@ ] }, { - "id": "skill:skills/paperclip/references/issue-workspaces.md:control-services:23", + "id": "skill:skills/paperclip/references/issue-workspaces.md:choose-files-and-repositories:23", "sourceKind": "skill_heading", "sourceAnchor": "skills/paperclip/references/issue-workspaces.md:23", - "title": "Control Services", - "expectedSemantics": "Skill guidance headed “Control Services”.", + "title": "Choose Files and Repositories", + "expectedSemantics": "Skill guidance headed “Choose Files and Repositories”.", "primaryDisposition": "optional_agent_tool", "requiredGrants": [], "assertionClasses": [ @@ -2055,9 +2070,24 @@ ] }, { - "id": "skill:skills/paperclip/references/issue-workspaces.md:start-all-configured-services-waits-for-configured-readiness-checks:28", + "id": "skill:skills/paperclip/references/issue-workspaces.md:control-services:37", "sourceKind": "skill_heading", - "sourceAnchor": "skills/paperclip/references/issue-workspaces.md:28", + "sourceAnchor": "skills/paperclip/references/issue-workspaces.md:37", + "title": "Control Services", + "expectedSemantics": "Skill guidance headed “Control Services”.", + "primaryDisposition": "optional_agent_tool", + "requiredGrants": [], + "assertionClasses": [ + "control_plane_invariant" + ], + "evidenceIds": [ + "skill:skills/paperclip/references/issue-workspaces.md:37" + ] + }, + { + "id": "skill:skills/paperclip/references/issue-workspaces.md:start-all-configured-services-waits-for-configured-readiness-checks:42", + "sourceKind": "skill_heading", + "sourceAnchor": "skills/paperclip/references/issue-workspaces.md:42", "title": "Start all configured services; waits for configured readiness checks.", "expectedSemantics": "Skill guidance headed “Start all configured services; waits for configured readiness checks.”.", "primaryDisposition": "optional_agent_tool", @@ -2066,13 +2096,13 @@ "control_plane_invariant" ], "evidenceIds": [ - "skill:skills/paperclip/references/issue-workspaces.md:28" + "skill:skills/paperclip/references/issue-workspaces.md:42" ] }, { - "id": "skill:skills/paperclip/references/issue-workspaces.md:restart-all-configured-services:36", + "id": "skill:skills/paperclip/references/issue-workspaces.md:restart-all-configured-services:50", "sourceKind": "skill_heading", - "sourceAnchor": "skills/paperclip/references/issue-workspaces.md:36", + "sourceAnchor": "skills/paperclip/references/issue-workspaces.md:50", "title": "Restart all configured services.", "expectedSemantics": "Skill guidance headed “Restart all configured services.”.", "primaryDisposition": "optional_agent_tool", @@ -2081,13 +2111,13 @@ "control_plane_invariant" ], "evidenceIds": [ - "skill:skills/paperclip/references/issue-workspaces.md:36" + "skill:skills/paperclip/references/issue-workspaces.md:50" ] }, { - "id": "skill:skills/paperclip/references/issue-workspaces.md:stop-all-running-services:44", + "id": "skill:skills/paperclip/references/issue-workspaces.md:stop-all-running-services:58", "sourceKind": "skill_heading", - "sourceAnchor": "skills/paperclip/references/issue-workspaces.md:44", + "sourceAnchor": "skills/paperclip/references/issue-workspaces.md:58", "title": "Stop all running services.", "expectedSemantics": "Skill guidance headed “Stop all running services.”.", "primaryDisposition": "optional_agent_tool", @@ -2096,13 +2126,13 @@ "control_plane_invariant" ], "evidenceIds": [ - "skill:skills/paperclip/references/issue-workspaces.md:44" + "skill:skills/paperclip/references/issue-workspaces.md:58" ] }, { - "id": "skill:skills/paperclip/references/issue-workspaces.md:read-the-url:63", + "id": "skill:skills/paperclip/references/issue-workspaces.md:read-the-url:77", "sourceKind": "skill_heading", - "sourceAnchor": "skills/paperclip/references/issue-workspaces.md:63", + "sourceAnchor": "skills/paperclip/references/issue-workspaces.md:77", "title": "Read the URL", "expectedSemantics": "Skill guidance headed “Read the URL”.", "primaryDisposition": "optional_agent_tool", @@ -2111,13 +2141,13 @@ "control_plane_invariant" ], "evidenceIds": [ - "skill:skills/paperclip/references/issue-workspaces.md:63" + "skill:skills/paperclip/references/issue-workspaces.md:77" ] }, { - "id": "skill:skills/paperclip/references/issue-workspaces.md:mcp-tools:72", + "id": "skill:skills/paperclip/references/issue-workspaces.md:mcp-tools:86", "sourceKind": "skill_heading", - "sourceAnchor": "skills/paperclip/references/issue-workspaces.md:72", + "sourceAnchor": "skills/paperclip/references/issue-workspaces.md:86", "title": "MCP Tools", "expectedSemantics": "Skill guidance headed “MCP Tools”.", "primaryDisposition": "optional_agent_tool", @@ -2126,7 +2156,7 @@ "control_plane_invariant" ], "evidenceIds": [ - "skill:skills/paperclip/references/issue-workspaces.md:72" + "skill:skills/paperclip/references/issue-workspaces.md:86" ] }, { diff --git a/packages/paperclip-runner/spec/capability/protocol-coverage.json b/packages/paperclip-runner/spec/capability/protocol-coverage.json index e61e64a4b37..2a23747b2e1 100644 --- a/packages/paperclip-runner/spec/capability/protocol-coverage.json +++ b/packages/paperclip-runner/spec/capability/protocol-coverage.json @@ -7,10 +7,86 @@ "src/scenarios/scenario-plan.ts" ], "counts": { - "actions": 57, + "actions": 61, "legacyRequirements": 106 }, "actions": [ + { + "id": "get_workspace", + "ownership": "optional_agent_tool", + "surfaces": [ + "live" + ], + "legacyAliases": [], + "contractCase": "protocol-action:get_workspace", + "contractOwner": "src/catalog/protocol-action-contracts.test.ts::get_workspace has a schema-valid canonical example and every declared projection", + "legacyBehavioralCases": [], + "deterministicCases": [ + "protocol-action:get_workspace" + ], + "legacyRequirementCases": [], + "deterministicOwners": [ + "src/catalog/protocol-action-contracts.test.ts::get_workspace has a schema-valid canonical example and every declared projection", + "src/scenarios/scenario-explorer.test.ts::renders every scenario with exposure, control plane, authorization, diff, and parity" + ] + }, + { + "id": "list_workspaces", + "ownership": "optional_agent_tool", + "surfaces": [ + "live" + ], + "legacyAliases": [], + "contractCase": "protocol-action:list_workspaces", + "contractOwner": "src/catalog/protocol-action-contracts.test.ts::list_workspaces has a schema-valid canonical example and every declared projection", + "legacyBehavioralCases": [], + "deterministicCases": [ + "protocol-action:list_workspaces" + ], + "legacyRequirementCases": [], + "deterministicOwners": [ + "src/catalog/protocol-action-contracts.test.ts::list_workspaces has a schema-valid canonical example and every declared projection", + "src/scenarios/scenario-explorer.test.ts::renders every scenario with exposure, control plane, authorization, diff, and parity" + ] + }, + { + "id": "select_workspace", + "ownership": "optional_agent_tool", + "surfaces": [ + "live" + ], + "legacyAliases": [], + "contractCase": "protocol-action:select_workspace", + "contractOwner": "src/catalog/protocol-action-contracts.test.ts::select_workspace has a schema-valid canonical example and every declared projection", + "legacyBehavioralCases": [], + "deterministicCases": [ + "protocol-action:select_workspace" + ], + "legacyRequirementCases": [], + "deterministicOwners": [ + "src/catalog/protocol-action-contracts.test.ts::select_workspace has a schema-valid canonical example and every declared projection", + "src/scenarios/scenario-explorer.test.ts::renders every scenario with exposure, control plane, authorization, diff, and parity" + ] + }, + { + "id": "prepare_repository", + "ownership": "optional_agent_tool", + "surfaces": [ + "live" + ], + "legacyAliases": [], + "contractCase": "protocol-action:prepare_repository", + "contractOwner": "src/catalog/protocol-action-contracts.test.ts::prepare_repository has a schema-valid canonical example and every declared projection", + "legacyBehavioralCases": [], + "deterministicCases": [ + "protocol-action:prepare_repository" + ], + "legacyRequirementCases": [], + "deterministicOwners": [ + "src/catalog/protocol-action-contracts.test.ts::prepare_repository has a schema-valid canonical example and every declared projection", + "src/scenarios/scenario-explorer.test.ts::renders every scenario with exposure, control plane, authorization, diff, and parity" + ] + }, { "id": "set_task_title", "ownership": "optional_agent_tool", diff --git a/packages/paperclip-runner/src/catalog/reconciliation.test.ts b/packages/paperclip-runner/src/catalog/reconciliation.test.ts index 923fc036b33..89483b7cce8 100644 --- a/packages/paperclip-runner/src/catalog/reconciliation.test.ts +++ b/packages/paperclip-runner/src/catalog/reconciliation.test.ts @@ -20,9 +20,9 @@ describe("canonical semantic-catalog reconciliation authority", () => { it("pins the reconciled op-set relationship between the two catalogs", () => { const summary = capabilityCatalogReconciliation(); expect(summary.scenarioCount).toBe(40); - expect(summary.liveCount).toBe(45); + expect(summary.liveCount).toBe(49); expect(summary.sharedCount).toBe(28); - expect(summary.unionCount).toBe(57); + expect(summary.unionCount).toBe(61); // Any operation added to or removed from either catalog without a // reconciliation decision changes these exact sets and fails the gate. expect(summary.liveOnly).toEqual([ @@ -32,12 +32,16 @@ describe("canonical semantic-catalog reconciliation authority", () => { "get_agent_instruction_history", "get_approval", "get_approval_context", + "get_workspace", "hire_agent", "list_project_repositories", + "list_workspaces", + "prepare_repository", "read_agent_instructions", "restore_agent_instructions", "schedule_wake", "search_api", + "select_workspace", "set_task_monitor", "set_task_title", "submit_complaint", @@ -61,7 +65,7 @@ describe("canonical semantic-catalog reconciliation authority", () => { }); it("is the single source both catalogs derive their operation set from", () => { - expect(CAPABILITY_CANONICAL_OPERATIONS).toHaveLength(57); + expect(CAPABILITY_CANONICAL_OPERATIONS).toHaveLength(61); const canonicalIds = new Set(CAPABILITY_CANONICAL_OPERATIONS.map((operation) => operation.operationId)); // Neither catalog may contain an operation absent from the canonical source. for (const tool of SCENARIO_CATALOG) expect(canonicalIds.has(tool.operationId)).toBe(true); @@ -95,7 +99,7 @@ describe("canonical semantic-catalog reconciliation authority", () => { }); it("names placement, claims, task modes, side-effect class, idempotency, redaction, mock mapping, real binding status, and PRP evidence for every operation", () => { - expect(CAPABILITY_CANONICAL_CATALOG).toHaveLength(57); + expect(CAPABILITY_CANONICAL_CATALOG).toHaveLength(61); for (const operation of CAPABILITY_CANONICAL_CATALOG) { expect(operation.placement).toMatch(/^(always|optional)_agent_tool$/); expect(Array.isArray(operation.requiredClaims)).toBe(true); @@ -121,7 +125,7 @@ describe("canonical semantic-catalog reconciliation authority", () => { it("classifies real binding status so generic_api_request is never product coverage", () => { const summary = capabilityCatalogReconciliation(); expect(summary.byRealBindingStatus).toEqual({ - live_codex: 44, + live_codex: 48, scenario_mock: 12, test_only: 1, }); diff --git a/packages/paperclip-runner/src/catalog/semantic-action-catalog.test.ts b/packages/paperclip-runner/src/catalog/semantic-action-catalog.test.ts index 7692309b236..c4a7102e16c 100644 --- a/packages/paperclip-runner/src/catalog/semantic-action-catalog.test.ts +++ b/packages/paperclip-runner/src/catalog/semantic-action-catalog.test.ts @@ -69,7 +69,7 @@ describe("semantic action catalog", () => { (action) => action.operationId, ); - expect(operationIds).toHaveLength(37); + expect(operationIds).toHaveLength(41); expect(operationIds).toContain("set_task_monitor"); expect(new Set(operationIds).size).toBe(operationIds.length); expect(operationIds).not.toContain("generic_api_request"); diff --git a/packages/paperclip-runner/src/catalog/semantic-action-catalog.ts b/packages/paperclip-runner/src/catalog/semantic-action-catalog.ts index 4c77c08fecb..b8cccc6869e 100644 --- a/packages/paperclip-runner/src/catalog/semantic-action-catalog.ts +++ b/packages/paperclip-runner/src/catalog/semantic-action-catalog.ts @@ -1,3 +1,7 @@ +import { getWorkspaceAction } from "../protocol-actions/get-workspace.js"; +import { listWorkspacesAction } from "../protocol-actions/list-workspaces.js"; +import { selectWorkspaceAction } from "../protocol-actions/select-workspace.js"; +import { prepareRepositoryAction } from "../protocol-actions/prepare-repository.js"; import { setTaskMonitorInputSchema } from "../protocol-actions/set-task-monitor.js"; import { listProjectsDescription, listProjectsInputSchema } from "../protocol-actions/list-projects.js"; import { setTaskTitleAction } from "../protocol-actions/set-task-title.js"; @@ -471,6 +475,10 @@ const descriptors: readonly PaperclipSemanticActionDescriptor[] = [ ), outputSchema: operationReceipt, }), + descriptor({ ...getWorkspaceAction.live.descriptor, placement: "optional" }), + descriptor({ ...listWorkspacesAction.live.descriptor, placement: "optional" }), + descriptor({ ...selectWorkspaceAction.live.descriptor, placement: "optional" }), + descriptor({ ...prepareRepositoryAction.live.descriptor, placement: "optional" }), descriptor({ operationId: "list_projects", title: "List projects", diff --git a/packages/paperclip-runner/src/catalog/semantic-action-types.ts b/packages/paperclip-runner/src/catalog/semantic-action-types.ts index 5f2f1bfef07..46665159522 100644 --- a/packages/paperclip-runner/src/catalog/semantic-action-types.ts +++ b/packages/paperclip-runner/src/catalog/semantic-action-types.ts @@ -29,6 +29,10 @@ export type PaperclipSemanticActionId = | "set_dependencies" | "create_skill" | "update_skill" + | "get_workspace" + | "list_workspaces" + | "select_workspace" + | "prepare_repository" | "create_project" | "list_project_repositories" | "list_projects" @@ -61,6 +65,7 @@ export interface PaperclipJsonSchema { readonly additionalProperties?: boolean | PaperclipJsonSchema; readonly items?: PaperclipJsonSchema; readonly enum?: readonly PaperclipJsonValue[]; + readonly const?: PaperclipJsonValue; readonly oneOf?: readonly PaperclipJsonSchema[]; readonly anyOf?: readonly PaperclipJsonSchema[]; readonly minimum?: number; diff --git a/packages/paperclip-runner/src/drivers/acpx/permission-policy.ts b/packages/paperclip-runner/src/drivers/acpx/permission-policy.ts index 63076db5ea1..37785b61c0d 100644 --- a/packages/paperclip-runner/src/drivers/acpx/permission-policy.ts +++ b/packages/paperclip-runner/src/drivers/acpx/permission-policy.ts @@ -24,7 +24,7 @@ const AUTOMATIC_PAPERCLIP_WORKFLOW_ACTIONS = new Set([ "report_progress", "answer_status_question", "write_document", "request_human_input", "register_deliverable", "finish_task", "block_task", "set_task_title", "request_review", "create_task", "reassign_task", "set_dependencies", - "create_project", "request_approval", + "create_project", "select_workspace", "prepare_repository", "request_approval", ]); /** Exact SDK rules for the run's runner-owned Paperclip MCP connection. */ diff --git a/packages/paperclip-runner/src/eval/workflow-evals.test.ts b/packages/paperclip-runner/src/eval/workflow-evals.test.ts index c90118fe819..b9a377c318d 100644 --- a/packages/paperclip-runner/src/eval/workflow-evals.test.ts +++ b/packages/paperclip-runner/src/eval/workflow-evals.test.ts @@ -466,13 +466,13 @@ describe("workflow reports and stress traceability", () => { candidateFailures: 36, }); expect(report.coverage).toMatchObject({ - canonicalOperations: 57, + canonicalOperations: 61, capabilityCases: 106, workflows: 12, stressFindings: 44, stressExclusions: 1, }); - expect(report.coverage.operations).toHaveLength(57); + expect(report.coverage.operations).toHaveLength(61); expect(report.coverage.composedWorkflows).toHaveLength(12); expect( report.coverage.operations.find( @@ -480,7 +480,7 @@ describe("workflow reports and stress traceability", () => { )?.workflowIds.length, ).toBeGreaterThan(0); expect(renderRunnerWorkflowMarkdown(report)).toContain( - "57 operations · 106 capability cases · 12 workflows", + "61 operations · 106 capability cases · 12 workflows", ); expect(renderRunnerWorkflowJUnit(report)).toContain( 'tests="36" failures="36" skipped="0"', diff --git a/packages/paperclip-runner/src/generated/capability-contract.ts b/packages/paperclip-runner/src/generated/capability-contract.ts index de3cbd9f935..a679623e7c2 100644 --- a/packages/paperclip-runner/src/generated/capability-contract.ts +++ b/packages/paperclip-runner/src/generated/capability-contract.ts @@ -6,9 +6,9 @@ export type CapabilityPrimaryDisposition = | "optional_agent_tool"; export const capabilityInventoryCounts = { - "skillReferenceCapabilities": 162, + "skillReferenceCapabilities": 164, "evalCases": 106, - "normativeRows": 268, + "normativeRows": 270, "legacyMcpAliases": 42 } as const; diff --git a/packages/paperclip-runner/src/protocol-actions/get-workspace.ts b/packages/paperclip-runner/src/protocol-actions/get-workspace.ts new file mode 100644 index 00000000000..5f017f2371c --- /dev/null +++ b/packages/paperclip-runner/src/protocol-actions/get-workspace.ts @@ -0,0 +1,73 @@ +/** Canonical task workspace operation. */ +export const getWorkspaceAction = { + "id": "get_workspace", + "canonical": { + "operationId": "get_workspace", + "surfaces": [ + "live" + ], + "placement": "optional_agent_tool", + "optionalGroup": "workspace_runtime", + "requiredClaims": [], + "taskModes": [ + "standard", + "ask", + "planning", + "skill_test" + ], + "sideEffectClass": "read", + "idempotency": "none", + "disabledByDefault": false, + "realBindingStatus": "live_codex", + "realServiceBinding": "PaperclipRunnerToolAuthority", + "prpEvidence": "Authenticated task workspace routes and durable selection/repository receipts.", + "prpBindingStatus": "bound", + "legacyAliases": [] + }, + "documentation": { + "title": "Inspect task workspace", + "description": "Inspect current task files, repositories, pending selection, binding revision, and preparation capabilities. AGENT_HOME is personal instructions and memory; task outputs belong in the workspace.", + "note": null + }, + "examples": { + "call": { + "operationId": "get_workspace", + "input": {} + }, + "success": { + "ok": true, + "operationId": "get_workspace", + "result": {} + } + }, + "live": { + "order": 80, + "descriptor": { + "schema": "paperclip.semantic-tool.v1", + "operationId": "get_workspace", + "version": 1, + "title": "Inspect task workspace", + "description": "Inspect current task files, repositories, pending selection, binding revision, and preparation capabilities. AGENT_HOME is personal instructions and memory; task outputs belong in the workspace.", + "effect": "read", + "requiredClaims": [], + "allowedModes": [ + "standard", + "ask", + "planning", + "skill_test" + ], + "inputSchema": { + "type": "object", + "properties": {}, + "required": [], + "additionalProperties": false + }, + "outputSchema": { + "type": "object", + "additionalProperties": true + }, + "exposure": "optional" + } + }, + "scenario": null +} as const; diff --git a/packages/paperclip-runner/src/protocol-actions/index.ts b/packages/paperclip-runner/src/protocol-actions/index.ts index 1c5b3083016..f21b890392c 100644 --- a/packages/paperclip-runner/src/protocol-actions/index.ts +++ b/packages/paperclip-runner/src/protocol-actions/index.ts @@ -1,3 +1,7 @@ +import { getWorkspaceAction } from "./get-workspace.js"; +import { listWorkspacesAction } from "./list-workspaces.js"; +import { selectWorkspaceAction } from "./select-workspace.js"; +import { prepareRepositoryAction } from "./prepare-repository.js"; import { setTaskMonitorAction } from "./set-task-monitor.js"; import { setTaskTitleAction } from "./set-task-title.js"; import { submitComplaintAction, submitSuggestionAction } from "./submit-agent-commentary.js"; @@ -57,6 +61,11 @@ import { writeDocumentAction } from "./write-document.js"; import { deepFreezeProtocolAction } from "./freeze.js"; export const PAPERCLIP_PROTOCOL_ACTIONS = deepFreezeProtocolAction([ + getWorkspaceAction, + listWorkspacesAction, + selectWorkspaceAction, + prepareRepositoryAction, + setTaskTitleAction, setTaskMonitorAction, submitComplaintAction, diff --git a/packages/paperclip-runner/src/protocol-actions/list-workspaces.ts b/packages/paperclip-runner/src/protocol-actions/list-workspaces.ts new file mode 100644 index 00000000000..b1bfd4f1f17 --- /dev/null +++ b/packages/paperclip-runner/src/protocol-actions/list-workspaces.ts @@ -0,0 +1,76 @@ +/** Canonical task workspace operation. */ +export const listWorkspacesAction = { + "id": "list_workspaces", + "canonical": { + "operationId": "list_workspaces", + "surfaces": [ + "live" + ], + "placement": "optional_agent_tool", + "optionalGroup": "workspace_runtime", + "requiredClaims": [], + "taskModes": [ + "standard", + "ask", + "planning", + "skill_test" + ], + "sideEffectClass": "read", + "idempotency": "none", + "disabledByDefault": false, + "realBindingStatus": "live_codex", + "realServiceBinding": "PaperclipRunnerToolAuthority", + "prpEvidence": "Authenticated task workspace routes and durable selection/repository receipts.", + "prpBindingStatus": "bound", + "legacyAliases": [] + }, + "documentation": { + "title": "List available workspaces", + "description": "List authorized reusable workspaces. A project is optional; choosing a workspace does not assign a project.", + "note": null + }, + "examples": { + "call": { + "operationId": "list_workspaces", + "input": {} + }, + "success": { + "ok": true, + "operationId": "list_workspaces", + "result": {} + } + }, + "live": { + "order": 81, + "descriptor": { + "schema": "paperclip.semantic-tool.v1", + "operationId": "list_workspaces", + "version": 1, + "title": "List available workspaces", + "description": "List authorized reusable workspaces. A project is optional; choosing a workspace does not assign a project.", + "effect": "read", + "requiredClaims": [], + "allowedModes": [ + "standard", + "ask", + "planning", + "skill_test" + ], + "inputSchema": { + "type": "object", + "properties": {}, + "required": [], + "additionalProperties": false + }, + "outputSchema": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": true + } + }, + "exposure": "optional" + } + }, + "scenario": null +} as const; diff --git a/packages/paperclip-runner/src/protocol-actions/prepare-repository.ts b/packages/paperclip-runner/src/protocol-actions/prepare-repository.ts new file mode 100644 index 00000000000..26ce01c3549 --- /dev/null +++ b/packages/paperclip-runner/src/protocol-actions/prepare-repository.ts @@ -0,0 +1,123 @@ +/** Canonical task workspace operation. */ +export const prepareRepositoryAction = { + "id": "prepare_repository", + "canonical": { + "operationId": "prepare_repository", + "surfaces": [ + "live" + ], + "placement": "optional_agent_tool", + "optionalGroup": "workspace_runtime", + "requiredClaims": [], + "taskModes": [ + "standard", + "skill_test" + ], + "sideEffectClass": "write", + "idempotency": "required", + "disabledByDefault": false, + "realBindingStatus": "live_codex", + "realServiceBinding": "PaperclipRunnerToolAuthority", + "prpEvidence": "Authenticated task workspace routes and durable selection/repository receipts.", + "prpBindingStatus": "bound", + "legacyAliases": [] + }, + "documentation": { + "title": "Prepare task repository", + "description": "Request an authorized repository inside task files at the next normal admission, without creating a project. Discover repository IDs first. Does not interrupt or wake the current run. Reuse requestKey; a conflicting ref never resets existing work.", + "note": null + }, + "examples": { + "call": { + "operationId": "prepare_repository", + "input": { "repository": { "kind": "catalog", "id": "123" }, "requestKey": "example" } + }, + "success": { + "ok": true, + "operationId": "prepare_repository", + "result": {} + } + }, + "live": { + "order": 83, + "descriptor": { + "schema": "paperclip.semantic-tool.v1", + "operationId": "prepare_repository", + "version": 1, + "title": "Prepare task repository", + "description": "Request an authorized repository inside task files at the next normal admission, without creating a project. Discover repository IDs first. Does not interrupt or wake the current run. Reuse requestKey; a conflicting ref never resets existing work.", + "effect": "write", + "requiredClaims": [], + "allowedModes": [ + "standard", + "skill_test" + ], + "inputSchema": { + "type": "object", + "properties": { + "repository": { + "oneOf": [ + { + "type": "object", + "properties": { + "kind": { + "const": "catalog" + }, + "id": { + "type": "string", + "minLength": 1, + "description": "Authorized ID from list_project_repositories." + } + }, + "required": [ + "kind", + "id" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "kind": { + "const": "url" + }, + "url": { + "type": "string", + "minLength": 1, + "description": "Existing HTTPS GitHub repository URL; uncataloged URLs use public anonymous access only." + } + }, + "required": [ + "kind", + "url" + ], + "additionalProperties": false + } + ] + }, + "ref": { + "type": "string", + "minLength": 1, + "description": "Optional Git branch, tag, or commit. Default HEAD is pinned once." + }, + "requestKey": { + "type": "string", + "minLength": 1, + "description": "Stable retry key." + } + }, + "required": [ + "repository", + "requestKey" + ], + "additionalProperties": false + }, + "outputSchema": { + "type": "object", + "additionalProperties": true + }, + "exposure": "optional" + } + }, + "scenario": null +} as const; diff --git a/packages/paperclip-runner/src/protocol-actions/select-workspace.ts b/packages/paperclip-runner/src/protocol-actions/select-workspace.ts new file mode 100644 index 00000000000..e35d6023e69 --- /dev/null +++ b/packages/paperclip-runner/src/protocol-actions/select-workspace.ts @@ -0,0 +1,142 @@ +/** Canonical task workspace operation. */ +export const selectWorkspaceAction = { + "id": "select_workspace", + "canonical": { + "operationId": "select_workspace", + "surfaces": [ + "live" + ], + "placement": "optional_agent_tool", + "optionalGroup": "workspace_runtime", + "requiredClaims": [], + "taskModes": [ + "standard", + "skill_test" + ], + "sideEffectClass": "write", + "idempotency": "required", + "disabledByDefault": false, + "realBindingStatus": "live_codex", + "realServiceBinding": "PaperclipRunnerToolAuthority", + "prpEvidence": "Authenticated task workspace routes and durable selection/repository receipts.", + "prpBindingStatus": "bound", + "legacyAliases": [] + }, + "documentation": { + "title": "Select task workspace", + "description": "Request a different task workspace at the next normal admission. Does not interrupt or wake the current run. Inspect the binding revision first and reuse requestKey on retries; existing files stay in their current folder.", + "note": null + }, + "examples": { + "call": { + "operationId": "select_workspace", + "input": { "selection": { "kind": "task_directory" }, "expectedBindingRevision": 0, "requestKey": "example" } + }, + "success": { + "ok": true, + "operationId": "select_workspace", + "result": {} + } + }, + "live": { + "order": 82, + "descriptor": { + "schema": "paperclip.semantic-tool.v1", + "operationId": "select_workspace", + "version": 1, + "title": "Select task workspace", + "description": "Request a different task workspace at the next normal admission. Does not interrupt or wake the current run. Inspect the binding revision first and reuse requestKey on retries; existing files stay in their current folder.", + "effect": "write", + "requiredClaims": [], + "allowedModes": [ + "standard", + "skill_test" + ], + "inputSchema": { + "type": "object", + "properties": { + "selection": { + "oneOf": [ + { + "type": "object", + "properties": { + "kind": { + "const": "task_directory" + } + }, + "required": [ + "kind" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "kind": { + "const": "existing" + }, + "workspaceId": { + "type": "string", + "minLength": 1, + "description": "Existing authorized workspace ID." + } + }, + "required": [ + "kind", + "workspaceId" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "kind": { + "const": "configured_source" + }, + "projectWorkspaceId": { + "type": "string", + "minLength": 1, + "description": "Configured source workspace ID." + }, + "mode": { + "enum": [ + "shared", + "managed_isolated" + ] + } + }, + "required": [ + "kind", + "projectWorkspaceId", + "mode" + ], + "additionalProperties": false + } + ] + }, + "expectedBindingRevision": { + "type": "integer", + "minimum": 0 + }, + "requestKey": { + "type": "string", + "minLength": 1, + "description": "Stable retry key." + } + }, + "required": [ + "selection", + "expectedBindingRevision", + "requestKey" + ], + "additionalProperties": false + }, + "outputSchema": { + "type": "object", + "additionalProperties": true + }, + "exposure": "optional" + } + }, + "scenario": null +} as const; diff --git a/packages/paperclip-runner/src/semantic-tools/discovery.ts b/packages/paperclip-runner/src/semantic-tools/discovery.ts index 8877d2c765e..ab1e50c1920 100644 --- a/packages/paperclip-runner/src/semantic-tools/discovery.ts +++ b/packages/paperclip-runner/src/semantic-tools/discovery.ts @@ -34,7 +34,7 @@ const NAMESPACE: Readonly> = Objec update_agent_instructions: "agent_instructions", get_agent_instruction_history: "agent_instructions", restore_agent_instructions: "agent_instructions", - create_skill: "skills", update_skill: "skills", create_task: "delegation", create_project: "projects", list_project_repositories: "projects", list_projects: "projects", + create_skill: "skills", update_skill: "skills", create_task: "delegation", get_workspace: "workspace", list_workspaces: "workspace", select_workspace: "workspace", prepare_repository: "workspace", create_project: "projects", list_project_repositories: "projects", list_projects: "projects", set_dependencies: "delegation", reassign_task: "delegation", list_approvals: "governance", get_approval: "governance", get_approval_context: "governance", request_approval: "governance", decide_approval: "governance", comment_on_approval: "governance", @@ -48,7 +48,7 @@ export const CAPABILITY_DISCOVERY_NAMESPACES = Object.freeze([ { name: "discovery", description: "Find company tasks and agents." }, { name: "delegation", description: "Create bounded child work and task dependencies." }, { name: "governance", description: "Read, request, discuss, and decide approvals." }, - { name: "workspace", description: "Inspect and control active-task workspace services." }, + { name: "workspace", description: "Inspect task files, prepare repositories, select workspaces, and control workspace services." }, { name: "continuation", description: "Schedule a bounded continuation wake." }, { name: "feedback", description: "Submit internal complaints and improvement suggestions." }, ]); diff --git a/packages/paperclip-runner/src/semantic-tools/dispatcher.ts b/packages/paperclip-runner/src/semantic-tools/dispatcher.ts index ad58bb77b0a..2ef34a1ce49 100644 --- a/packages/paperclip-runner/src/semantic-tools/dispatcher.ts +++ b/packages/paperclip-runner/src/semantic-tools/dispatcher.ts @@ -188,6 +188,10 @@ export class CapabilitySemanticDispatcher { // it must neither advertise nor accept them merely for lacking claims. denyOperations: [...new Set([ ...(scenario.denyOperations ?? []), + "get_workspace" as const, + "list_workspaces" as const, + "select_workspace" as const, + "prepare_repository" as const, "create_project" as const, "list_project_repositories" as const, "list_projects" as const, diff --git a/packages/paperclip-runner/src/semantic-tools/paperclip-discovery.ts b/packages/paperclip-runner/src/semantic-tools/paperclip-discovery.ts index 491ac22e731..a5661946a49 100644 --- a/packages/paperclip-runner/src/semantic-tools/paperclip-discovery.ts +++ b/packages/paperclip-runner/src/semantic-tools/paperclip-discovery.ts @@ -39,7 +39,7 @@ const NAMESPACE: Readonly> = get_workspace_runtime: "workspace", control_workspace_service: "workspace", set_dependencies: "delegation", reassign_task: "delegation", - create_skill: "skills", update_skill: "skills", create_task: "delegation", create_project: "projects", list_project_repositories: "projects", list_projects: "projects", + create_skill: "skills", update_skill: "skills", create_task: "delegation", get_workspace: "workspace", list_workspaces: "workspace", select_workspace: "workspace", prepare_repository: "workspace", create_project: "projects", list_project_repositories: "projects", list_projects: "projects", request_approval: "governance", decide_approval: "governance", comment_on_approval: "governance", diff --git a/packages/paperclip-runner/src/semantic-tools/semantic-tools.test.ts b/packages/paperclip-runner/src/semantic-tools/semantic-tools.test.ts index 7dbc66243f6..5702bc3fa39 100644 --- a/packages/paperclip-runner/src/semantic-tools/semantic-tools.test.ts +++ b/packages/paperclip-runner/src/semantic-tools/semantic-tools.test.ts @@ -113,7 +113,7 @@ describe("Capability semantic catalog and authorization", () => { it("publishes a stable narrow catalog without credentials or control-plane-owned tools", () => { const names = CAPABILITY_SEMANTIC_TOOL_CATALOG.map((tool) => tool.operationId); expect(new Set(names).size).toBe(names.length); - expect(names).toHaveLength(45); + expect(names).toHaveLength(49); expect(names).toContain("set_task_monitor"); expect(names).toEqual(expect.arrayContaining(["submit_complaint", "submit_suggestion"])); expect(names).toContain("get_task_context"); @@ -187,7 +187,7 @@ describe("Capability semantic catalog and authorization", () => { expect(found.operations).toEqual([]); expect(JSON.stringify(found.operations)).not.toMatch(/create_task|approval|secret|administer_company/); const before = adapter.snapshot().revision; - for (const operationId of ["create_project", "list_project_repositories", "list_projects"] as const) { + for (const operationId of ["create_project", "list_project_repositories", "list_projects", "get_workspace", "list_workspaces", "select_workspace", "prepare_repository"] as const) { expect(dispatcher.listTools(OPEN.identity.runId).map((tool) => tool.name)).not.toContain(operationId); expect(await dispatcher.dispatch({ runId: OPEN.identity.runId, callId: `unbound-${operationId}`, operationId, diff --git a/packages/paperclip-runner/src/semantic-tools/types.ts b/packages/paperclip-runner/src/semantic-tools/types.ts index 28a5b1988de..9c34932943c 100644 --- a/packages/paperclip-runner/src/semantic-tools/types.ts +++ b/packages/paperclip-runner/src/semantic-tools/types.ts @@ -49,6 +49,10 @@ export type CapabilitySemanticOperationId = | "set_dependencies" | "create_skill" | "update_skill" + | "get_workspace" + | "list_workspaces" + | "select_workspace" + | "prepare_repository" | "create_project" | "list_project_repositories" | "list_projects" diff --git a/packages/plugins/sdk/src/types.ts b/packages/plugins/sdk/src/types.ts index 3378d248c4d..7a989eac8df 100644 --- a/packages/plugins/sdk/src/types.ts +++ b/packages/plugins/sdk/src/types.ts @@ -408,8 +408,8 @@ export interface PluginExecutionWorkspaceMetadata { id: string; /** UUID of the owning company. */ companyId: string; - /** UUID of the parent project. */ - projectId: string; + /** Source project whose access policy protects these files; null for task-owned sources. */ + projectId: string | null; /** UUID of the backing project workspace, when present. */ projectWorkspaceId: string | null; /** Absolute filesystem path to the workspace when locally realized. */ diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 7ce182eca85..59621deeeb4 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -2866,3 +2866,8 @@ export { EXTERNAL_AGENT_TOOL_GUIDANCE, DOT_AGENT_TOOL_GUIDANCE } from "./externa export { AGENT_LIFECYCLE_STATES, type AgentLifecycleState, type AgentLifecycleOperation } from "./types/agent-lifecycle.js"; export type { AgentLifecycleRequest, AgentLifecycleResult } from "./types/agent-lifecycle.js"; + +export * from "./types/task-workspace.js"; +export * from "./validators/task-workspace.js"; + +export { prepareWorkspaceRepositorySchema, type PrepareWorkspaceRepository } from "./validators/workspace-repository.js"; diff --git a/packages/shared/src/types/chat-channels.ts b/packages/shared/src/types/chat-channels.ts index ca94e758a55..8c5e263ad80 100644 --- a/packages/shared/src/types/chat-channels.ts +++ b/packages/shared/src/types/chat-channels.ts @@ -1,3 +1,11 @@ +import type { TaskWorkspaceSelection } from "./task-workspace.js"; + +/** Omitted fields inherit endpoint defaults; null explicitly clears them. */ +export interface ChatExecutionDefaults { + projectId?: string | null; + workspace?: TaskWorkspaceSelection | null; +} + /** Provider-neutral contracts for Paperclip's native external chat subsystem. */ export const CHAT_PROVIDERS = [ "slack", @@ -242,6 +250,7 @@ export type ChannelPublicationMode = "automatic" | "explicit"; export type ExternalMessageExecutionPolicy = "restricted" | "agent"; export interface ChatEndpoint { + executionDefaults?: ChatExecutionDefaults | null; /** Additional presentation instructions captured only for newly created tasks. */ communicationInstructions?: string; id: string; @@ -281,6 +290,7 @@ export interface ChatEndpoint { } export interface ChatEndpointResource { + executionDefaults?: ChatExecutionDefaults | null; id: string; companyId: string; endpointId: string; @@ -510,6 +520,7 @@ export interface CreateChatEndpointInput { } export interface UpdateChatEndpointInput { + executionDefaults?: ChatExecutionDefaults | null; slackSetupMethod?: "automatic" | "manual" | "existing"; slackApp?: SlackAppConfiguration; communicationInstructions?: string; diff --git a/packages/shared/src/types/index.ts b/packages/shared/src/types/index.ts index ec590e8e140..d6c1f35f9b8 100644 --- a/packages/shared/src/types/index.ts +++ b/packages/shared/src/types/index.ts @@ -1100,3 +1100,5 @@ export type { AgentInstructionCandidate } from "./agent.js"; export * from "./skill-source.js"; export * from "./agent-lifecycle.js"; + +export * from "./task-workspace.js"; diff --git a/packages/shared/src/types/issue.ts b/packages/shared/src/types/issue.ts index 31a9468a3f5..6ddf9b602ac 100644 --- a/packages/shared/src/types/issue.ts +++ b/packages/shared/src/types/issue.ts @@ -1,3 +1,4 @@ +import type { TaskWorkspaceIntent, TaskWorkspacePendingSelection } from "./task-workspace.js"; import type { ExecutionProjection, ExecutionBlocker } from "./execution-projection.js"; import type { IssueCommentAuthorType, @@ -873,6 +874,9 @@ export interface Issue { monitorAttemptCount?: number; monitorNotes?: string | null; monitorScheduledBy?: IssueMonitorScheduledBy | null; + workspaceBindingRevision?: number; + workspaceSelection?: TaskWorkspaceIntent | null; + workspacePendingSelection?: TaskWorkspacePendingSelection | null; executionWorkspaceId: string | null; executionWorkspacePreference: string | null; executionWorkspaceSettings: IssueExecutionWorkspaceSettings | null; diff --git a/packages/shared/src/types/task-workspace.ts b/packages/shared/src/types/task-workspace.ts new file mode 100644 index 00000000000..c4e816d3e01 --- /dev/null +++ b/packages/shared/src/types/task-workspace.ts @@ -0,0 +1,17 @@ +/** Filesystem intent is independent of a task's organizational project. */ +export type TaskWorkspaceSelection = + | { kind: "task_directory" } + | { kind: "existing"; workspaceId: string } + | { kind: "configured_source"; projectWorkspaceId: string; mode: "shared" | "managed_isolated" }; +export type TaskWorkspaceIntent = { + version: 1; + request?: { key: string; expectedBindingRevision: number }; + selection: TaskWorkspaceSelection; + source: "explicit" | "parent" | "channel" | "project" | "operator_cwd" | "task_default" | "legacy"; +}; +export type TaskWorkspacePendingSelection = { + version: 1; + requestKey: string; + expectedBindingRevision: number; + intent: TaskWorkspaceIntent; +}; diff --git a/packages/shared/src/types/workspace-runtime.ts b/packages/shared/src/types/workspace-runtime.ts index 25a8dbf6549..b6a3fe75524 100644 --- a/packages/shared/src/types/workspace-runtime.ts +++ b/packages/shared/src/types/workspace-runtime.ts @@ -229,9 +229,9 @@ export interface WorkspaceOverviewItem { kind: "execution_workspace"; workspaceId: string; workspaceName: string; - projectId: string; - projectUrlKey: string; - projectName: string; + projectId: string | null; + projectUrlKey: string | null; + projectName: string | null; mode: ExecutionWorkspaceSummary["mode"]; strategyType: ExecutionWorkspaceStrategyType; cwd: string | null; @@ -262,7 +262,7 @@ export interface WorkspaceOverviewResponse { export interface ExecutionWorkspace { id: string; companyId: string; - projectId: string; + projectId: string | null; projectWorkspaceId: string | null; sourceIssueId: string | null; mode: Exclude | "adapter_managed" | "cloud_sandbox"; diff --git a/packages/shared/src/validators/chat-channels.ts b/packages/shared/src/validators/chat-channels.ts index f2553588ef3..7b94037ed56 100644 --- a/packages/shared/src/validators/chat-channels.ts +++ b/packages/shared/src/validators/chat-channels.ts @@ -1,5 +1,11 @@ import { z } from "zod"; import { multilineTextSchema } from "./text.js"; +import { taskWorkspaceSelectionSchema } from "./task-workspace.js"; + +export const chatExecutionDefaultsSchema = z.object({ + projectId: z.string().uuid().nullable().optional(), + workspace: taskWorkspaceSelectionSchema.nullable().optional(), +}).strict(); import { CHAT_CONCURRENCY_POLICIES, CHAT_DELIVERY_STATES, @@ -88,6 +94,7 @@ export const createChatEndpointSchema = z export const updateChatEndpointSchema = z .object({ + executionDefaults: chatExecutionDefaultsSchema.nullable().optional(), communicationInstructions: multilineTextSchema.pipe(z.string().trim().max(4000)).optional(), slackApp: slackAppConfigurationSchema.optional(), slackSetupMethod: z.enum(["automatic", "manual", "existing"]).optional(), @@ -147,7 +154,8 @@ export const replaceChatEndpointResourcesSchema = z z .object({ id: z.string().uuid(), - enabled: z.boolean(), + enabled: z.boolean().optional(), + executionDefaults: chatExecutionDefaultsSchema.nullable().optional(), }) .strict(), ) diff --git a/packages/shared/src/validators/index.ts b/packages/shared/src/validators/index.ts index 47cf59aeb8a..fe28324a255 100644 --- a/packages/shared/src/validators/index.ts +++ b/packages/shared/src/validators/index.ts @@ -996,3 +996,7 @@ export { restoreAgentInstructionSchema } from "./agent.js"; export * from "./skill-source.js"; export * from "./agent-commentary.js"; + +export * from "./task-workspace.js"; + +export * from "./workspace-repository.js"; diff --git a/packages/shared/src/validators/issue.ts b/packages/shared/src/validators/issue.ts index 29f6f07376c..8fddad793ba 100644 --- a/packages/shared/src/validators/issue.ts +++ b/packages/shared/src/validators/issue.ts @@ -1,3 +1,4 @@ +import { taskWorkspaceSelectionSchema } from "./task-workspace.js"; import { z } from "zod"; import { ISSUE_EXECUTION_DECISION_OUTCOMES, @@ -737,6 +738,7 @@ const createIssueBaseSchema = z.object({ .enum(ISSUE_EXECUTION_WORKSPACE_PREFERENCES) .optional() .nullable(), + workspaceSelection: taskWorkspaceSelectionSchema.optional(), executionWorkspaceSettings: issueExecutionWorkspaceSettingsSchema .optional() .nullable(), @@ -884,6 +886,7 @@ const issueCommentAttachmentIdsSchema = z export const updateIssueSchema = objectWithoutDefaults( createIssueBaseSchema.omit({ + workspaceSelection: true, createdByUserId: true, responsibleUserId: true, watchdog: true, diff --git a/packages/shared/src/validators/task-workspace.ts b/packages/shared/src/validators/task-workspace.ts new file mode 100644 index 00000000000..c2b8fe7ad16 --- /dev/null +++ b/packages/shared/src/validators/task-workspace.ts @@ -0,0 +1,15 @@ +import { z } from "zod"; +export const taskWorkspaceSelectionSchema = z.discriminatedUnion("kind", [ + z.object({ kind: z.literal("task_directory") }).strict(), + z.object({ kind: z.literal("existing"), workspaceId: z.string().uuid() }).strict(), + z.object({ kind: z.literal("configured_source"), projectWorkspaceId: z.string().uuid(), mode: z.enum(["shared", "managed_isolated"]) }).strict(), +]); +export const taskWorkspaceIntentSchema = z.object({ + version: z.literal(1), request: z.object({ key: z.string().min(1).max(200), expectedBindingRevision: z.number().int().nonnegative() }).strict().optional(), selection: taskWorkspaceSelectionSchema, + source: z.enum(["explicit", "parent", "channel", "project", "operator_cwd", "task_default", "legacy"]), +}).strict(); +export const selectTaskWorkspaceSchema = z.object({ + selection: taskWorkspaceSelectionSchema, + expectedBindingRevision: z.number().int().nonnegative(), + requestKey: z.string().trim().min(1).max(200), +}).strict(); diff --git a/packages/shared/src/validators/workspace-repository.ts b/packages/shared/src/validators/workspace-repository.ts new file mode 100644 index 00000000000..2b0a5c66cd7 --- /dev/null +++ b/packages/shared/src/validators/workspace-repository.ts @@ -0,0 +1,12 @@ +import { z } from "zod"; + +export const prepareWorkspaceRepositorySchema = z.object({ + repository: z.discriminatedUnion("kind", [ + z.object({ kind: z.literal("catalog"), id: z.string().min(1).max(255) }).strict(), + z.object({ kind: z.literal("url"), url: z.string().url().max(2048) }).strict(), + ]), + // A revision is data passed as one argv entry, never a shell expression. + ref: z.string().min(1).max(255).regex(/^[A-Za-z0-9][A-Za-z0-9._\/-]*$/).refine(value => !value.includes("..") && !value.includes("//"), "Invalid Git ref").optional(), + requestKey: z.string().min(1).max(240), +}).strict(); +export type PrepareWorkspaceRepository = z.infer; diff --git a/server/src/__tests__/chat-channels.integration.test.ts b/server/src/__tests__/chat-channels.integration.test.ts index f1608aac12f..8be9eae5259 100644 --- a/server/src/__tests__/chat-channels.integration.test.ts +++ b/server/src/__tests__/chat-channels.integration.test.ts @@ -7556,6 +7556,39 @@ describeEmbeddedPostgres("chat channel control-plane integration", () => { expect((await db.select().from(chatDeliveries).where(eq(chatDeliveries.id, delivery.id)))[0].state).toBe("filtered"); }); + it("snapshots channel workspace defaults on new tasks and preserves them on replies", async () => { + const fixture = await seedCompany(); + const { callbacks, endpoint, service } = await configuredSlackEndpoint(fixture); + await service.update(endpoint.id, { executionDefaults: { workspace: { kind: "task_directory" } } }, "owner-user"); + const thread = makeThread({ channelId: "C-WORKSPACE", id: "slack:C-WORKSPACE:8100.1", name: "workspace" }); + await deliverMessage({ callbacks, endpointId: endpoint.id, thread: thread.thread, + message: makeMessage({ id: "8100.1", text: "@maya make a report", mentioned: true }), trigger: "mention" }); + const conversations = () => db.select().from(chatConversations).where(eq(chatConversations.endpointId, endpoint.id)).orderBy(asc(chatConversations.createdAt)); + const [first] = await conversations(); + const [task] = await db.select().from(issues).where(eq(issues.id, first.issueId)); + expect(task.projectId).toBeNull(); + expect(task.workspaceSelection).toEqual({ version: 1, selection: { kind: "task_directory" }, source: "channel" }); + const [resource] = (await service.listResources(endpoint.id)).filter(row => row.providerResourceId === "C-WORKSPACE"); + expect(resource).toBeDefined(); + await service.replaceResources(endpoint.id, [{ id: resource.id, executionDefaults: { workspace: null } }], "owner-user"); + expect((await service.listResources(endpoint.id)).find(row => row.id === resource.id)?.enabled).toBe(resource.enabled); + await service.update(endpoint.id, { executionDefaults: null }, "owner-user"); + await deliverMessage({ callbacks, endpointId: endpoint.id, thread: thread.thread, + message: makeMessage({ id: "8100.2", text: "Continue" }), trigger: "subscribed_message" }); + const [unchanged] = await db.select().from(issues).where(eq(issues.id, first.issueId)); + expect(unchanged.workspaceSelection).toEqual(task.workspaceSelection); + await service.update(endpoint.id, { executionDefaults: { workspace: { kind: "task_directory" } } }, "owner-user"); + const next = makeThread({ channelId: "C-WORKSPACE", id: "slack:C-WORKSPACE:8200.1", name: "workspace" }); + await deliverMessage({ callbacks, endpointId: endpoint.id, thread: next.thread, + message: makeMessage({ id: "8200.1", text: "@maya another report", mentioned: true }), trigger: "mention" }); + const rows = await conversations(); + expect(rows).toHaveLength(2); + const [second] = await db.select().from(issues).where(eq(issues.id, rows[1].issueId)); + expect(second.workspaceSelection).toBeNull(); + await expect(service.update(endpoint.id, { executionDefaults: { projectId: randomUUID() } }, "owner-user")).rejects.toMatchObject({ status: 404 }); + expect((await service.get(endpoint.id)).executionDefaults).toEqual({ workspace: { kind: "task_directory" } }); + }); + it("captures initial Slack communication guidance once per task, ignoring forged message configuration", async () => { const fixture = await seedCompany(); const { callbacks, endpoint, service } = await configuredSlackEndpoint(fixture); diff --git a/server/src/__tests__/chat-execution-defaults.test.ts b/server/src/__tests__/chat-execution-defaults.test.ts new file mode 100644 index 00000000000..0f04a1499f4 --- /dev/null +++ b/server/src/__tests__/chat-execution-defaults.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from "vitest"; +import { chatExecutionDefaultsSchema, updateChatEndpointSchema, replaceChatEndpointResourcesSchema } from "@paperclipai/shared"; +import { resolveChatExecutionDefaults } from "../services/chat-execution-defaults.js"; + +const projectId = "11f01381-578d-4bb7-9de4-57e6d9678313"; +const workspaceId = "13f01381-578d-4bb7-9de4-57e6d9678313"; + +describe("channel execution defaults", () => { + it("leaves unconfigured channels on common task defaults", () => { + expect(resolveChatExecutionDefaults(null, null)).toEqual({}); + }); + it("inherits omitted properties and clears explicit null independently", () => { + expect(resolveChatExecutionDefaults({ projectId, workspace: { kind: "existing", workspaceId } }, { projectId: null })) + .toEqual({ projectId: null, workspace: { kind: "existing", workspaceId } }); + expect(resolveChatExecutionDefaults({ projectId, workspace: { kind: "existing", workspaceId } }, { workspace: null })) + .toEqual({ projectId, workspace: null }); + }); + it("resource task directories override endpoint shared choices", () => { + expect(resolveChatExecutionDefaults({ workspace: { kind: "existing", workspaceId } }, { workspace: { kind: "task_directory" } })) + .toEqual({ workspace: { kind: "task_directory" } }); + }); + it("rejects unknown fields, paths and commands rather than granting filesystem authority", () => { + for (const input of [{ cwd: "/tmp" }, { workspace: { kind: "task_directory", command: "echo hello" } }, { projectId: "not-a-uuid" }]) { + expect(chatExecutionDefaultsSchema.safeParse(input).success).toBe(false); + } + }); + it("accepts defaults through both endpoint and resource management contracts", () => { + expect(updateChatEndpointSchema.parse({ executionDefaults: { projectId } })).toEqual({ executionDefaults: { projectId } }); + expect(replaceChatEndpointResourcesSchema.parse({ resources: [{ id: workspaceId, enabled: true, executionDefaults: { workspace: null } }] }).resources[0].executionDefaults) + .toEqual({ workspace: null }); + }); +}); diff --git a/server/src/__tests__/email-channels.integration.test.ts b/server/src/__tests__/email-channels.integration.test.ts index c15499e947b..7e9d0bcce3e 100644 --- a/server/src/__tests__/email-channels.integration.test.ts +++ b/server/src/__tests__/email-channels.integration.test.ts @@ -475,6 +475,22 @@ describe("AgentMail durable email pipeline", () => { await db.select().from(issues).where(eq(issues.companyId, f.companyId)), ).toHaveLength(1); }); + it("snapshots email workspace defaults only when a conversation creates a task", async () => { + const f = await fixture(); + await db.update(chatEndpoints).set({ executionDefaults: { workspace: { kind: "task_directory" } } }).where(eq(chatEndpoints.id, f.endpointId)); + const first = f.message(); + await f.receive(first); + const tasks = () => db.select().from(issues).where(eq(issues.companyId, f.companyId)); + const [task] = await tasks(); + expect(task.workspaceSelection).toEqual({ version: 1, selection: { kind: "task_directory" }, source: "channel" }); + expect(task.projectId).toBeNull(); + await db.update(chatEndpoints).set({ executionDefaults: null }).where(eq(chatEndpoints.id, f.endpointId)); + await f.receive(f.message(randomUUID(), first.thread_id)); + expect((await tasks())[0].workspaceSelection).toEqual(task.workspaceSelection); + await f.receive(f.message()); + expect(await tasks()).toEqual(expect.arrayContaining([expect.objectContaining({ workspaceSelection: null })])); + }); + it("deduplicates events/messages, keeps identical subjects separate, and never grants sender board identity", async () => { const f = await fixture(); const m = f.message(); diff --git a/server/src/__tests__/execution-workspace-reopen.test.ts b/server/src/__tests__/execution-workspace-reopen.test.ts index 9ff003a237c..1bb772b60df 100644 --- a/server/src/__tests__/execution-workspace-reopen.test.ts +++ b/server/src/__tests__/execution-workspace-reopen.test.ts @@ -1,3 +1,4 @@ +import { EMBEDDED_POSTGRES_TEST_TIMEOUT_MS } from "@paperclipai/db"; import { execFileSync } from "node:child_process"; import { mkdirSync, writeFileSync } from "node:fs"; import { mkdtemp, rm, stat } from "node:fs/promises"; @@ -48,7 +49,7 @@ describeEmbeddedPostgres("reopen archived isolated execution workspace", () => { beforeAll(async () => { tempDb = await startEmbeddedPostgresTestDatabase("paperclip-reopen-"); db = createDb(tempDb.connectionString); - }, 20_000); + }, EMBEDDED_POSTGRES_TEST_TIMEOUT_MS); afterEach(async () => { await db.delete(activityLog); @@ -261,6 +262,7 @@ describeEmbeddedPostgres("reopen archived isolated execution workspace", () => { workspaceId, issue: { id: issueId, companyId, projectId }, actor: { agentId: null, actorType: "user" }, + authorizationActor: { type: "board", source: "local_implicit", isInstanceAdmin: true }, }); expect(result.ok).toBe(true); @@ -282,6 +284,22 @@ describeEmbeddedPostgres("reopen archived isolated execution workspace", () => { expect(issueRow?.executionWorkspaceId).toBe(workspaceId); }); + it("reopens the persisted source after the task leaves its organizational project", async () => { + const { companyId, projectId, projectWorkspaceId } = await seedCompanyProject(); + const cwd = await makeExistingDir(); + const workspaceId = await seedClosedWorkspace({ companyId, projectId, projectWorkspaceId, cwd }); + const issueId = await seedIssue({ companyId, projectId, workspaceId, issueNumber: 4105 }); + await db.update(issues).set({ projectId: null }).where(eq(issues.id, issueId)); + const result = await executionWorkspaceService(db).reopenClosedIsolatedExecutionWorkspaceForIssue({ + workspaceId, + issue: { id: issueId, companyId, projectId: null }, + actor: { agentId: null, actorType: "user" }, + authorizationActor: { type: "board", source: "local_implicit", isInstanceAdmin: true }, + }); + expect(result.ok).toBe(true); + expect((await readWorkspace(workspaceId))?.projectId).toBe(projectId); + }); + it("keeps access for every issue that shares the reopened row", async () => { const { companyId, projectId, projectWorkspaceId } = await seedCompanyProject(); const cwd = await makeExistingDir(); @@ -294,6 +312,7 @@ describeEmbeddedPostgres("reopen archived isolated execution workspace", () => { workspaceId, issue: { id: firstIssueId, companyId, projectId }, actor: { agentId: null, actorType: "user" }, + authorizationActor: { type: "board", source: "local_implicit", isInstanceAdmin: true }, }); expect(result.ok).toBe(true); @@ -324,6 +343,7 @@ describeEmbeddedPostgres("reopen archived isolated execution workspace", () => { workspaceId, issue: { id: issueId, companyId, projectId }, actor: { agentId: null, actorType: "user" }, + authorizationActor: { type: "board", source: "local_implicit", isInstanceAdmin: true }, }); expect(result.ok).toBe(false); @@ -369,6 +389,7 @@ describeEmbeddedPostgres("reopen archived isolated execution workspace", () => { workspaceId, issue: { id: issueId, companyId, projectId }, actor: { agentId: null, actorType: "user" }, + authorizationActor: { type: "board", source: "local_implicit", isInstanceAdmin: true }, }); expect(result.ok).toBe(true); @@ -406,6 +427,7 @@ describeEmbeddedPostgres("reopen archived isolated execution workspace", () => { // The issue belongs to a different company than the workspace. issue: { id: randomUUID(), companyId: second.companyId, projectId: second.projectId }, actor: { agentId: null, actorType: "user" }, + authorizationActor: { type: "board", source: "local_implicit", isInstanceAdmin: true }, }); expect(result.ok).toBe(false); @@ -434,6 +456,7 @@ describeEmbeddedPostgres("reopen archived isolated execution workspace", () => { workspaceId, issue: { id: issueId, companyId, projectId }, actor: { agentId: null, actorType: "user" }, + authorizationActor: { type: "board", source: "local_implicit", isInstanceAdmin: true }, }); expect(result.ok).toBe(true); @@ -497,6 +520,7 @@ describeEmbeddedPostgres("reopen archived isolated execution workspace", () => { workspaceId, issue: { id: issueId, companyId, projectId }, actor: { agentId: null, actorType: "user" }, + authorizationActor: { type: "board", source: "local_implicit", isInstanceAdmin: true }, }); expect(reopenResult.ok).toBe(true); if (!reopenResult.ok) throw new Error("reopen failed"); @@ -558,6 +582,7 @@ describeEmbeddedPostgres("reopen archived isolated execution workspace", () => { workspaceId, issue: { id: issueId, companyId, projectId }, actor: { agentId: null, actorType: "user" }, + authorizationActor: { type: "board", source: "local_implicit", isInstanceAdmin: true }, }); expect(reopenResult.ok).toBe(true); if (!reopenResult.ok) throw new Error("reopen failed"); diff --git a/server/src/__tests__/execution-workspaces-service.test.ts b/server/src/__tests__/execution-workspaces-service.test.ts index 504d9ba82f8..3563262e0ce 100644 --- a/server/src/__tests__/execution-workspaces-service.test.ts +++ b/server/src/__tests__/execution-workspaces-service.test.ts @@ -1,3 +1,5 @@ +import * as localServiceSupervisor from "../services/local-service-supervisor.js"; +import { EMBEDDED_POSTGRES_TEST_TIMEOUT_MS } from "@paperclipai/db"; import { execFile } from "node:child_process"; import { createHash } from "node:crypto"; import fs from "node:fs/promises"; @@ -264,7 +266,7 @@ describeEmbeddedPostgres("executionWorkspaceService.getCloseReadiness", () => { // tests further down. workspaceReaperCooldownDays: 0, }); - }, 20_000); + }, EMBEDDED_POSTGRES_TEST_TIMEOUT_MS); afterEach(async () => { await db.delete(workspaceRuntimeServices); @@ -3704,6 +3706,11 @@ describeEmbeddedPostgres("executionWorkspaceService.getCloseReadiness", () => { ].join(" "); const command = `${JSON.stringify(process.execPath)} -e ${JSON.stringify(serverScript)}`; + // This case proves the DB reservation/start race, not host-wide lsof latency. + // Keep the real process and HTTP readiness; its freshly allocated port has no prior owner. + const portOwner = vi.spyOn(localServiceSupervisor, "readLocalServicePortOwner").mockResolvedValue(null); + const adoption = vi.spyOn(localServiceSupervisor, "findAdoptableLocalService").mockResolvedValue(null); + const runtimeOutput: string[] = []; let startedServices: Awaited> = []; try { const startPromise = startRuntimeServicesForWorkspaceControl({ @@ -3750,9 +3757,11 @@ describeEmbeddedPostgres("executionWorkspaceService.getCloseReadiness", () => { }, }, adapterEnv: {}, + onLog: async (stream, chunk) => { runtimeOutput.push(`${stream}: ${chunk}`); }, }); - await Promise.race([ + try { + await Promise.race([ waitForPath(runtimeStartedMarker), startPromise.then( () => { @@ -3762,7 +3771,13 @@ describeEmbeddedPostgres("executionWorkspaceService.getCloseReadiness", () => { throw error; }, ), - ]); + ]); + } catch (error) { + const activity = await db.execute(sql`select state, wait_event_type, wait_event, + left(query, 240) as query from pg_stat_activity + where datname = current_database() and pid <> pg_backend_pid()`); + throw new Error(`Runtime activation did not reach its process marker. Output: ${runtimeOutput.join("") || ""}. Database activity: ${JSON.stringify(activity)}`, { cause: error }); + } const reconcileErrorPromise = svc.reconcileExecutionWorkspaceBranch(executionWorkspaceId, { mode: "override", @@ -3800,12 +3815,17 @@ describeEmbeddedPostgres("executionWorkspaceService.getCloseReadiness", () => { }, }); } finally { - await stopRuntimeServicesForExecutionWorkspace({ - db, - executionWorkspaceId, - workspaceCwd: worktreePath, - }); - await fs.rm(runtimeStartedMarker, { force: true }); + try { + await stopRuntimeServicesForExecutionWorkspace({ + db, + executionWorkspaceId, + workspaceCwd: worktreePath, + }); + await fs.rm(runtimeStartedMarker, { force: true }); + } finally { + portOwner.mockRestore(); + adoption.mockRestore(); + } } const [workspace] = await db diff --git a/server/src/__tests__/file-resources.test.ts b/server/src/__tests__/file-resources.test.ts index e1aea92c813..efc8e597639 100644 --- a/server/src/__tests__/file-resources.test.ts +++ b/server/src/__tests__/file-resources.test.ts @@ -200,12 +200,14 @@ describeEmbeddedPostgres("workspace file resources", () => { let db: Db; beforeAll(async () => { + vi.stubEnv("PAPERCLIP_ISSUE_PRIVACY_MODE", "enforce"); tempDb = await startEmbeddedPostgresTestDatabase("paperclip-file-resources-"); db = createDb(tempDb.connectionString); }, 60_000); afterAll(async () => { await tempDb?.cleanup(); + vi.unstubAllEnvs(); }); it("withholds private task files and private cross-project targets", async () => { @@ -311,6 +313,36 @@ describeEmbeddedPostgres("workspace file resources", () => { expect(resolved.capabilities.preview).toBe(true); }); + it("reads projectless task files before and after organizational project assignment", async () => { + const { projectRoot, executionRoot } = await makeWorkspace(); + const graph = await seedGraph(db, { projectRoot, executionRoot }); + await db.update(issues).set({ projectId: null, projectWorkspaceId: null }).where(eq(issues.id, graph.issueId)); + await db.update(executionWorkspaces).set({ projectId: null, projectWorkspaceId: null, strategyType: "project_primary", providerType: "local_fs" }).where(eq(executionWorkspaces.sourceIssueId, graph.issueId)); + await fs.writeFile(path.join(executionRoot, "report.txt"), "Task-owned report", "utf8"); + const app = createApp(db, { type: "board", userId: "board-user", companyIds: [graph.companyId], source: "session", isInstanceAdmin: false }); + for (const projectId of [null, graph.targetProjectId]) { + await db.update(issues).set({ projectId }).where(eq(issues.id, graph.issueId)); + const result = await request(app).get(`/api/issues/${graph.issueId}/file-resources/content`).query({ workspace: "execution", path: "report.txt" }).expect(200); + expect(result.body.content.data).toBe("Task-owned report"); + expect(result.body.resource.workspaceKind).toBe("execution_workspace"); + } + }); + + it("reads a selected source independently of task project while retaining source privacy", async () => { + const { projectRoot, targetProjectRoot, executionRoot } = await makeWorkspace(); + const graph = await seedGraph(db, { projectRoot, targetProjectRoot, executionRoot }); + await fs.writeFile(path.join(projectRoot, "source.txt"), "Selected source", "utf8"); + const app = createApp(db, { type: "board", userId: "board-user", companyIds: [graph.companyId], source: "session", isInstanceAdmin: false }); + for (const projectId of [null, graph.targetProjectId]) { + await db.update(issues).set({ projectId }).where(eq(issues.id, graph.issueId)); + const result = await request(app).get(`/api/issues/${graph.issueId}/file-resources/content`).query({ workspace: "project", path: "source.txt" }).expect(200); + expect(result.body.content.data).toBe("Selected source"); + expect(result.body.resource.workspaceId).toBe(graph.projectWorkspaceId); + } + await db.update(projects).set({ visibility: "private" }).where(eq(projects.id, graph.projectId)); + await request(app).get(`/api/issues/${graph.issueId}/file-resources/content`).query({ workspace: "project", path: "source.txt" }).expect(404); + }); + it("auto-discovers unhinted same-company project files when issue workspaces miss", async () => { const { projectRoot, targetProjectRoot, executionRoot } = await makeWorkspace(); const graph = await seedGraph(db, { projectRoot, targetProjectRoot, executionRoot }); diff --git a/server/src/__tests__/heartbeat-project-repositories.test.ts b/server/src/__tests__/heartbeat-project-repositories.test.ts index a619a67cef5..a04ea036543 100644 --- a/server/src/__tests__/heartbeat-project-repositories.test.ts +++ b/server/src/__tests__/heartbeat-project-repositories.test.ts @@ -1,3 +1,4 @@ +import { EMBEDDED_POSTGRES_TEST_TIMEOUT_MS } from "@paperclipai/db"; import { randomUUID } from "node:crypto"; import { and, eq } from "drizzle-orm"; import { execFileSync } from "node:child_process"; @@ -6,7 +7,7 @@ import os from "node:os"; import path from "node:path"; import { pathToFileURL } from "node:url"; import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest"; -import { agents, companies, createDb, environments, executionWorkspaces, heartbeatRuns, issues, projects, projectWorkspaces } from "@paperclipai/db"; +import { agents, companies, companyMemberships, createDb, environments, executionWorkspaces, heartbeatRuns, issues, projects, projectWorkspaces } from "@paperclipai/db"; import { setExpensiveWorkspaceGitExecutor } from "@paperclipai/adapter-utils/git-workspace-sync"; import { buildProjectMentionHref } from "@paperclipai/shared"; import { createWorkspaceGitOperationScheduler, WorkspaceGitScanError } from "../services/workspace-git-operation-scheduler.js"; @@ -41,7 +42,7 @@ suite("task project repository provisioning", () => { await db.update(issues).set({ status: "done" }).where(eq(issues.id, input.context.issueId)); return { exitCode: 0, signal: null, timedOut: false }; }); - }, 30_000); + }, EMBEDDED_POSTGRES_TEST_TIMEOUT_MS); afterAll(async () => { if (db && heartbeat) await drainHeartbeatRunsToQuiescence(db, heartbeat); await db?.$client.end({ timeout: 5 }); @@ -65,6 +66,7 @@ suite("task project repository provisioning", () => { vi.stubEnv("PAPERCLIP_MULTI_PROJECT_WORKSPACE_SYNC", "true"); await instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: true }); await db.insert(companies).values({ id: companyId, name: "Email company", issuePrefix: `E${companyId.slice(0, 6)}`, defaultResponsibleUserId: "responsible-user" }); + await db.insert(companyMemberships).values({ companyId, principalType: "user", principalId: "responsible-user", status: "active", membershipRole: "owner" }); await db.insert(projects).values({ id: projectId, companyId, name: "Onboarding" }); for (const id of [referencedProjectId, deniedProjectId]) { const source = path.join(root, companyId, id); @@ -147,6 +149,7 @@ suite("task project repository provisioning", () => { enableIsolatedWorkspacesByDefault: true, }); await db.insert(companies).values({ id: companyId, name: "Research", issuePrefix: `R${companyId.slice(0, 6)}`, defaultResponsibleUserId: "responsible-user" }); + await db.insert(companyMemberships).values({ companyId, principalType: "user", principalId: "responsible-user", status: "active", membershipRole: "owner" }); await db.insert(projects).values({ id: projectId, companyId, name: "Research", status: "in_progress", executionWorkspacePolicy: explicitIsolation === "project" ? { enabled: true, defaultMode: "isolated_workspace" } : null, @@ -186,7 +189,7 @@ suite("task project repository provisioning", () => { expect(workspace.strategy).toBe("git_worktree"); expect(execFileSync("git", ["rev-parse", "--is-inside-work-tree"], { cwd: workspace.cwd, encoding: "utf8" }).trim()).toBe("true"); } else { - expect(workspace.cwd).toContain(`${projectId}/_default`); + expect(workspace.cwd).toContain(`/isolated-workspaces/${companyId}/${issueId}`); } } }, 25_000); @@ -212,6 +215,7 @@ suite("task project repository provisioning", () => { await writeFile(path.join(source, "README.md"), "preserved dirty work"); await writeFile(path.join(source, "private.secret"), "must not copy"); await db.insert(companies).values({ id: companyId, name: "Bootstrap recovery", issuePrefix: `R${companyId.slice(0, 6)}`, defaultResponsibleUserId: "responsible-user" }); + await db.insert(companyMemberships).values({ companyId, principalType: "user", principalId: "responsible-user", status: "active", membershipRole: "owner" }); await db.insert(projects).values({ id: projectId, companyId, name: "Local source", status: "in_progress" }); await db.insert(projectWorkspaces).values([ { id: randomUUID(), companyId, projectId, name: "Anchor", sourceType: "local_path", cwd: source, isPrimary: true, createdAt: new Date(Date.now() - 1000) }, @@ -313,6 +317,7 @@ suite("task project repository provisioning", () => { repositoryRows.push({ id: randomUUID(), companyId, projectId, name: `Repo ${index}`, sourceType: "git_repo", repoUrl: pathToFileURL(source).href, cwd: null, isPrimary: index === 0 }); } await db.insert(companies).values({ id: companyId, name: "Repo test", issuePrefix: `R${companyId.slice(0, 6)}`, defaultResponsibleUserId: "responsible-user" }); + await db.insert(companyMemberships).values({ companyId, principalType: "user", principalId: "responsible-user", status: "active", membershipRole: "owner" }); await db.insert(projects).values({ id: projectId, companyId, name: "Multi-repo", status: "in_progress" }); await db.insert(projectWorkspaces).values(repositoryRows); await db.insert(agents).values({ id: agentId, companyId, name: "Test", role: "engineer", status: "idle", adapterType: "codex_local", adapterConfig: {}, runtimeConfig: {}, permissions: {} }); diff --git a/server/src/__tests__/heartbeat-workspace-busy.test.ts b/server/src/__tests__/heartbeat-workspace-busy.test.ts index af0d1f63c94..5d18efe79e2 100644 --- a/server/src/__tests__/heartbeat-workspace-busy.test.ts +++ b/server/src/__tests__/heartbeat-workspace-busy.test.ts @@ -525,7 +525,7 @@ describeEmbeddedPostgres("shared-workspace run serialization", () => { expect(retryRuns).toHaveLength(0); }); - it("allow passes the busy gate for a sandbox environment and adds coordination context", async () => { + it("rejects unproven concurrent physical sharing in a sandbox environment", async () => { const fixture = await seedWorkspaceFixture({ issueWorkspaceSettings: { sharedWorkspaceConcurrency: "allow" }, agentEnvironmentDriver: "sandbox", @@ -540,11 +540,8 @@ describeEmbeddedPostgres("shared-workspace run serialization", () => { expect(run).not.toBeNull(); const finishedRun = await waitForRunToLeaveActiveStates(run!.id); - expect(finishedRun?.errorCode).not.toBe(WORKSPACE_BUSY_ERROR_CODE); - expect(executedRunIds).toContain(run!.id); - expect((finishedRun?.contextSnapshot as Record)?.paperclipTaskMarkdown).toContain( - `shared workspace is concurrently held by run ${fixture.holderRunId}`, - ); + expect(executedRunIds).not.toContain(run!.id); + expect(finishedRun?.error).toContain("cannot place concurrent agents in one physical shared folder"); const retryRuns = await db .select({ id: heartbeatRuns.id }) .from(heartbeatRuns) diff --git a/server/src/__tests__/heartbeat-workspace-session.test.ts b/server/src/__tests__/heartbeat-workspace-session.test.ts index 906a7d23c6e..bea971bc2fa 100644 --- a/server/src/__tests__/heartbeat-workspace-session.test.ts +++ b/server/src/__tests__/heartbeat-workspace-session.test.ts @@ -250,19 +250,19 @@ function buildIssueAncestryDb(rows: Array<{ id: string; companyId: string; paren } describe("assertGitSensitiveAdapterWorkspaceValid", () => { - it("rejects a project-workspace-linked issue that is missing its project id before adapter launch", async () => { - await expectWorkspaceValidationFailure( - buildWorkspaceValidationInput({ - issue: { - id: "issue-1", - identifier: "PAP-1", - projectId: null, - projectWorkspaceId: "workspace-1", - }, - }), - "missing_project_id", - "linked to a project workspace but has no project id", - ); + it("accepts a projectless issue using an authorized persisted source workspace", async () => { + await expect(assertGitSensitiveAdapterWorkspaceValid(buildWorkspaceValidationInput({ + issue: { id: "issue-1", identifier: "PAP-1", projectId: null, projectWorkspaceId: "workspace-1" }, + resolvedWorkspace: buildResolvedWorkspace({ localPathOnlyWorkspace: true }), + }))).resolves.toBeUndefined(); + }); + + it("rejects a configured source without source-project evidence", async () => { + const input = buildWorkspaceValidationInput(); + await expectWorkspaceValidationFailure(buildWorkspaceValidationInput({ + issue: { id: "issue-1", identifier: "PAP-1", projectId: null, projectWorkspaceId: "workspace-1" }, + persistedExecutionWorkspace: { ...input.persistedExecutionWorkspace!, projectId: null }, + }), "missing_project_id", "linked to a project workspace but has no project id"); }); it("rejects a git-sensitive local adapter when effective cwd differs from the persisted workspace cwd", async () => { diff --git a/server/src/__tests__/issue-closed-workspace-routes.test.ts b/server/src/__tests__/issue-closed-workspace-routes.test.ts index 22dfa841b5d..c7cd4051044 100644 --- a/server/src/__tests__/issue-closed-workspace-routes.test.ts +++ b/server/src/__tests__/issue-closed-workspace-routes.test.ts @@ -267,6 +267,7 @@ describe("closed isolated workspace issue routes", () => { workspaceId: closedWorkspaceId, issue: { id: issueId, companyId: "company-1", projectId: null }, actor: expect.objectContaining({ actorType: "user" }), + authorizationActor: expect.objectContaining({ type: "board", source: "local_implicit" }), }); // The closed-workspace dead end is gone: the comment is accepted. expect(res.status).toBe(201); diff --git a/server/src/__tests__/managed-repository-checkout.test.ts b/server/src/__tests__/managed-repository-checkout.test.ts new file mode 100644 index 00000000000..f8927bd1404 --- /dev/null +++ b/server/src/__tests__/managed-repository-checkout.test.ts @@ -0,0 +1,54 @@ +import { execFile as execFileCallback } from "node:child_process"; +import { promisify } from "node:util"; +import { mkdtemp, mkdir, writeFile, readFile, readdir, rm, stat } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { materializeManagedProjectWorkspace, ensureManagedRepositoriesIgnored } from "../services/managed-repository-checkout.js"; + +const execFile = promisify(execFileCallback); +const directories: string[] = []; +afterEach(async () => { await Promise.all(directories.splice(0).map(dir => rm(dir, { recursive: true, force: true }))); }); +async function fixture() { + const root = await mkdtemp(path.join(tmpdir(), "paperclip-repository-publication-")); + directories.push(root); + const source = path.join(root, "source"); + await mkdir(source); + await execFile("git", ["init", "--initial-branch=main", source]); + await writeFile(path.join(source, "source.txt"), "initial\n"); + await execFile("git", ["-C", source, "add", "."]); + await execFile("git", ["-C", source, "-c", "user.name=Fixture", "-c", "user.email=fixture@example.test", "commit", "-m", "initial"]); + return { root, source, target: path.join(root, "target") }; +} +describe("atomic managed repository publication", () => { + it("keeps managed repositories separate without requiring Git or touching an ancestor", async () => { + const { source, target } = await fixture(); + await mkdir(target); + await expect(ensureManagedRepositoriesIgnored(target)).resolves.toBeUndefined(); + const nestedPlainRoot = path.join(source, "plain-task"); + await mkdir(nestedPlainRoot); + const original = await readFile(path.join(source, ".git", "info", "exclude"), "utf8"); + await ensureManagedRepositoriesIgnored(nestedPlainRoot); + expect(await readFile(path.join(source, ".git", "info", "exclude"), "utf8")).toBe(original); + await ensureManagedRepositoriesIgnored(source); + expect(await readFile(path.join(source, ".git", "info", "exclude"), "utf8")).toContain("/.paperclip-repositories/"); + }); + it("writes the pinned receipt inside the clone before making it visible", async () => { + const { source, target } = await fixture(); + const result = await materializeManagedProjectWorkspace(target, { repoUrl: "https://github.com/test/source", localSource: source, beforePublish: async clone => { + expect(await stat(target).catch(() => null)).toBeNull(); + const commit = (await execFile("git", ["-C", clone, "rev-parse", "HEAD"])).stdout.trim(); + await writeFile(path.join(clone, ".git", "paperclip-workspace-owner.json"), JSON.stringify({ repositoryId: "receipt", pinnedCommit: commit })); + } }); + expect(result.warning).toBeNull(); + expect(JSON.parse(await readFile(path.join(target, ".git", "paperclip-workspace-owner.json"), "utf8"))).toMatchObject({ repositoryId: "receipt", pinnedCommit: expect.stringMatching(/^[a-f0-9]{40}$/) }); + expect(await readFile(path.join(target, "source.txt"), "utf8")).toBe("initial\n"); + }); + it("does not publish an incomplete checkout when its receipt cannot be committed", async () => { + const { root, source, target } = await fixture(); + await expect(materializeManagedProjectWorkspace(target, { repoUrl: "https://github.com/test/source", localSource: source, beforePublish: async () => { throw new Error("receipt failed"); } })).rejects.toThrow(/receipt failed/); + expect(await stat(target).catch(() => null)).toBeNull(); + expect((await readdir(root)).filter(name => name.startsWith("target.clone-"))).toEqual([]); + expect(await readFile(path.join(source, "source.txt"), "utf8")).toBe("initial\n"); + }); +}); diff --git a/server/src/__tests__/native-workspace-sync-history.test.ts b/server/src/__tests__/native-workspace-sync-history.test.ts index d8c2e46f8a5..56b4a66c2f2 100644 --- a/server/src/__tests__/native-workspace-sync-history.test.ts +++ b/server/src/__tests__/native-workspace-sync-history.test.ts @@ -8,7 +8,7 @@ import type { EnvironmentLease } from "@paperclipai/shared"; import type { AdapterSandboxExecutionTarget } from "@paperclipai/adapter-utils/execution-target"; import type { CommandManagedRuntimeRunner } from "@paperclipai/adapter-utils/command-managed-runtime"; import { runLocalGit } from "@paperclipai/adapter-utils/git-workspace-sync"; -import { prepareNativeWorkspaceSync } from "../services/native-runtime/native-workspace-sync.js"; +import { prepareNativeWorkspaceSync, nativeWorkspaceSyncInternals } from "../services/native-runtime/native-workspace-sync.js"; const runner: CommandManagedRuntimeRunner = { execute: (input) => new Promise((resolve, reject) => { @@ -44,7 +44,7 @@ describe("native warm workspace Git history", () => { await Promise.all(roots.splice(0).map(root => rm(root, { recursive: true, force: true }))); }); - it.each(["unchanged", "host_commit", "host_branch", "remote_commit", "nested_commit"] as const)( + it.each(["unchanged", "host_commit", "host_branch", "host_origin", "remote_commit", "nested_commit", "plain_nested_commit"] as const)( "checks Git identity before reusing a warm sandbox: %s", async (change) => { const root = await mkdtemp(path.join(os.tmpdir(), "paperclip-warm-history-")); roots.push(root); @@ -61,9 +61,10 @@ describe("native warm workspace Git history", () => { await git(dir, "add", "."); await git(dir, "commit", "-m", "base"); } - await initRepo(host); + if (change === "plain_nested_commit") await mkdir(host, { recursive: true }); + else await initRepo(host); const nestedPath = path.join(".paperclip-repositories", "nested"); - if (change === "nested_commit") await initRepo(path.join(host, nestedPath)); + if (change === "nested_commit" || change === "plain_nested_commit") await initRepo(path.join(host, nestedPath)); const lease = { id: "lease", providerLeaseId: "sandbox", metadata: {} } as EnvironmentLease; let runnerProfileJson: Record = {}; // Persist the real service's descriptors and stamps; only the DB query @@ -94,29 +95,40 @@ describe("native warm workspace Git history", () => { const input = { db: db as unknown as Db, companyId: "company", workspaceId: "workspace", workspaceLocalDir: host, lease, target }; const first = (await prepareNativeWorkspaceSync({ ...input, runId: "first" }))!; expect(first.mode).toBe("host_current"); + expect(first.reference.schema).toBe("paperclip.native-workspace-sync/v3"); + if (change === "plain_nested_commit") { + const recovered = await nativeWorkspaceSyncInternals.readDescriptor({ runId: "first", reference: first.reference }); + expect(recovered.descriptor.gitSnapshot).toBeNull(); + expect(recovered.descriptor.repositories?.[0]?.path).toBe(nestedPath); + } await first.restoreWorkspace(); await first.cleanup(); - if (change === "host_commit" || change === "nested_commit") { - await git(change === "nested_commit" ? path.join(host, nestedPath) : host, "commit", "--allow-empty", "-m", "host only"); + if (change === "host_commit" || change === "nested_commit" || change === "plain_nested_commit") { + await git(change === "nested_commit" || change === "plain_nested_commit" ? path.join(host, nestedPath) : host, "commit", "--allow-empty", "-m", "host only"); } else if (change === "host_branch") { await git(host, "checkout", "-b", "other"); + } else if (change === "host_origin") { + await git(host, "remote", "add", "origin", "https://github.com/paperclipai/new-source.git"); } else if (change === "remote_commit") { await git(remote, "-c", "user.name=Test", "-c", "user.email=test@paperclip.dev", "commit", "--allow-empty", "-m", "remote only"); } - const expectedHead = await git(host, "rev-parse", "HEAD"); - const expectedBranch = await git(host, "symbolic-ref", "--short", "HEAD"); - const nestedHead = change === "nested_commit" ? await git(path.join(host, nestedPath), "rev-parse", "HEAD") : null; + const hostRepo = change === "plain_nested_commit" ? path.join(host, nestedPath) : host; + const remoteRepo = change === "plain_nested_commit" ? path.join(remote, nestedPath) : remote; + const expectedHead = await git(hostRepo, "rev-parse", "HEAD"); + const expectedBranch = await git(hostRepo, "symbolic-ref", "--short", "HEAD"); + const nestedHead = change === "nested_commit" || change === "plain_nested_commit" ? await git(path.join(host, nestedPath), "rev-parse", "HEAD") : null; runnerProfileJson = {}; const second = (await prepareNativeWorkspaceSync({ ...input, runId: "second", target: { ...target, sandboxLeaseAcquisition: { outcome: "resumed", providerLeaseId: "sandbox" }, } }))!; try { expect(second.mode).toBe(change === "unchanged" ? "adopt_remote" : "host_current"); - expect(await git(remote, "rev-parse", "HEAD")).toBe(expectedHead); - expect(await git(remote, "symbolic-ref", "--short", "HEAD")).toBe(expectedBranch); + expect(await git(remoteRepo, "rev-parse", "HEAD")).toBe(expectedHead); + expect(await git(remoteRepo, "symbolic-ref", "--short", "HEAD")).toBe(expectedBranch); + if (change === "host_origin") expect(await git(remote, "remote", "get-url", "origin")).toBe("https://github.com/paperclipai/new-source.git"); await second.restoreWorkspace(); - expect(await git(host, "rev-parse", "HEAD")).toBe(expectedHead); + expect(await git(hostRepo, "rev-parse", "HEAD")).toBe(expectedHead); if (nestedHead) { expect(await git(path.join(remote, nestedPath), "rev-parse", "HEAD")).toBe(nestedHead); expect(await git(path.join(host, nestedPath), "rev-parse", "HEAD")).toBe(nestedHead); diff --git a/server/src/__tests__/task-workspace-admission.test.ts b/server/src/__tests__/task-workspace-admission.test.ts new file mode 100644 index 00000000000..cb8c3bd14e8 --- /dev/null +++ b/server/src/__tests__/task-workspace-admission.test.ts @@ -0,0 +1,37 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { ensurePersistedExecutionWorkspaceAvailable } from "../services/workspace-runtime.js"; +import { describe, expect, it } from "vitest"; +import { canApplyTaskWorkspaceSelectionAtAdmission, taskWorkspaceRuntimeSelectionEnabled, parseIssueExecutionWorkspaceSettings, resolveExecutionWorkspaceMode } from "../services/execution-workspace-policy.js"; + +describe("task workspace admission authority", () => { + it.each([{ hasTypedSelection: true, hasBinding: false }, { hasTypedSelection: false, hasBinding: true }])("keeps isolated intent authoritative with the legacy UI disabled: %j", flags => { + const enabled = taskWorkspaceRuntimeSelectionEnabled({ legacyUiEnabled: false, ...flags }); + const settings = enabled ? parseIssueExecutionWorkspaceSettings({ mode: "isolated_workspace" }) : null; + expect(resolveExecutionWorkspaceMode({ projectPolicy: null, issueSettings: settings, legacyUseProjectWorkspace: null })).toBe("isolated_workspace"); + }); + it.each([ + { admittedInput: { schema: "paperclip.native-execution-input.v3", binding: { executionWorkspaceId: "old-projectless-run" } }, restarting: false, hasLeaseOwner: false }, + { admittedInput: null, restarting: true, hasLeaseOwner: false }, + { admittedInput: null, restarting: false, hasLeaseOwner: true }, + ])("defers pending root changes while immutable execution/recovery owns the root", admission => { + expect(canApplyTaskWorkspaceSelectionAtAdmission(admission)).toBe(false); + }); + it("keeps a persisted task-owned source projectless when current organizational defaults have a project", async () => { + const cwd = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-source-authority-")); + try { + const restored = await ensurePersistedExecutionWorkspaceAvailable({ + base: { baseCwd: cwd, source: "project_primary", projectId: "org-project", workspaceId: "org-source", repoUrl: null, repoRef: null }, + workspace: { mode: "shared_workspace", strategyType: "project_primary", cwd, providerRef: cwd, + projectId: null, projectWorkspaceId: null, repoUrl: null, baseRef: null, branchName: null }, + issue: { id: "task", identifier: null, title: "Retain files" }, + agent: { id: "agent", companyId: "company", name: "Agent" }, + }); + expect(restored).toMatchObject({ cwd, projectId: null, workspaceId: null }); + } finally { await fs.rm(cwd, { recursive: true, force: true }); } + }); + it("permits new admission to apply explicit pending intent", () => { + expect(canApplyTaskWorkspaceSelectionAtAdmission({ admittedInput: null, restarting: false, hasLeaseOwner: false })).toBe(true); + }); +}); diff --git a/server/src/__tests__/task-workspace-api.test.ts b/server/src/__tests__/task-workspace-api.test.ts new file mode 100644 index 00000000000..7af818612ca --- /dev/null +++ b/server/src/__tests__/task-workspace-api.test.ts @@ -0,0 +1,39 @@ +import { randomUUID } from "node:crypto"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { eq } from "drizzle-orm"; +import { executionWorkspaces, issues, heartbeatRuns } from "@paperclipai/db"; +import { startRunnerApiTestServer } from "./helpers/runner-api-server.js"; +import { getEmbeddedPostgresTestSupport } from "./helpers/embedded-postgres.js"; +import { PaperclipRunnerToolAuthority } from "../services/native-runtime/paperclip-runner-tool-authority.js"; + +const support = await getEmbeddedPostgresTestSupport(); +(support.supported ? describe : describe.skip)("task workspace API and native semantic authority", () => { + let server: Awaited>; + const originalSecret = process.env.PAPERCLIP_AGENT_JWT_SECRET; + beforeAll(async () => { process.env.PAPERCLIP_AGENT_JWT_SECRET = randomUUID(); server = await startRunnerApiTestServer(); }, 60_000); + afterAll(async () => { await server?.close(); if (originalSecret === undefined) delete process.env.PAPERCLIP_AGENT_JWT_SECRET; else process.env.PAPERCLIP_AGENT_JWT_SECRET = originalSecret; }); + it("inspects admitted files, durably requests a repository, and stages root selection without interrupting", async () => { + const f = await server.fixture({ conversation: true, disableWakeOnDemand: true }); + const [workspace] = await server.db.insert(executionWorkspaces).values({ companyId: f.companyId, projectId: null, sourceIssueId: f.issueId, name: "Task files", cwd: f.workspace, mode: "shared_workspace", strategyType: "task_directory" }).returning(); + await server.db.update(issues).set({ projectId: null, projectWorkspaceId: null, executionWorkspaceId: workspace.id }).where(eq(issues.id, f.issueId)); + const authority = new PaperclipRunnerToolAuthority(server.db, { companyId: f.companyId, issueId: f.issueId, agentId: f.agentId, runId: f.runId, apiUrl: server.apiUrl, workspaceRoot: "/admitted/task" }); + const call = (tool: string, args: Record) => authority.execute({ tool, arguments: args, callId: randomUUID() }); + const view = await call("get_workspace", {}) as { bindingRevision: number; cwd: string; workspace: { id: string } }; + expect(view).toMatchObject({ cwd: "/admitted/task", workspace: { id: workspace.id } }); + const args = { repository: { kind: "url", url: "https://github.com/public/example" }, requestKey: "repository-one" }; + const first = await call("prepare_repository", args) as { preparationId: string; kind: string }; + expect(first.kind).toBe("requires_next_admission"); + expect(await call("prepare_repository", args)).toMatchObject({ preparationId: first.preparationId }); + expect(await call("select_workspace", { selection: { kind: "task_directory" }, expectedBindingRevision: view.bindingRevision, requestKey: "root-one" })).toMatchObject({ kind: "scheduled", applies: "next_normal_admission" }); + const [task] = await server.db.select().from(issues).where(eq(issues.id, f.issueId)); + expect(task).toMatchObject({ executionWorkspaceId: workspace.id, projectId: null, workspacePendingSelection: { requestKey: "root-one" } }); + const runs = await server.db.select().from(heartbeatRuns).where(eq(heartbeatRuns.companyId, f.companyId)); + expect(runs).toHaveLength(1); + expect(runs[0]).toMatchObject({ id: f.runId, status: "running" }); + }); + it("does not expose repository preparation or workspace selection to Ask mode", async () => { + const f = await server.fixture({ mode: "ask", conversation: true, disableWakeOnDemand: true }); + await expect(f.authority.execute({ tool: "prepare_repository", callId: randomUUID(), arguments: { repository: { kind: "url", url: "https://github.com/public/example" }, requestKey: "denied" } })).rejects.toThrow(); + await expect(f.authority.execute({ tool: "select_workspace", callId: randomUUID(), arguments: { selection: { kind: "task_directory" }, expectedBindingRevision: 0, requestKey: "denied" } })).rejects.toThrow(); + }); +}); diff --git a/server/src/__tests__/task-workspace-binding.test.ts b/server/src/__tests__/task-workspace-binding.test.ts new file mode 100644 index 00000000000..4acb55fb7e1 --- /dev/null +++ b/server/src/__tests__/task-workspace-binding.test.ts @@ -0,0 +1,138 @@ +import { findNativeChatWorkspaceScope } from "../services/native-runtime/native-chat-workspace.js"; +import { EMBEDDED_POSTGRES_TEST_TIMEOUT_MS } from "@paperclipai/db"; +import { createHeartbeatWorkspaceResolver } from "../services/heartbeat/workspaces.js"; +import { resolveDefaultAgentWorkspaceDir } from "../home-paths.js"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { randomUUID } from "node:crypto"; +import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; +import { eq } from "drizzle-orm"; +import { agents, companies, createDb, executionWorkspaces, issues, projects } from "@paperclipai/db"; +import { executionWorkspaceService } from "../services/execution-workspaces.js"; +import { deleteCompany } from "../services/company-deletion.js"; +import { issueService } from "../services/issues.js"; +import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js"; + +const support = await getEmbeddedPostgresTestSupport(); +(support.supported ? describe : describe.skip)("task-owned workspace bindings", () => { + let temporary: Awaited>; + let db: ReturnType; + beforeAll(async () => { + temporary = await startEmbeddedPostgresTestDatabase("paperclip-task-workspace-"); + db = createDb(temporary.connectionString); + }, EMBEDDED_POSTGRES_TEST_TIMEOUT_MS); + afterAll(async () => { await temporary?.cleanup(); }); + + async function fixture() { + const companyId = randomUUID(), issueId = randomUUID(), workspaceId = randomUUID(); + await db.insert(companies).values({ id: companyId, name: "Task files", issuePrefix: `T${companyId.slice(0, 6)}` }); + await db.insert(issues).values({ id: issueId, companyId, title: "Write a report" }); + await db.insert(executionWorkspaces).values({ id: workspaceId, companyId, projectId: null, sourceIssueId: issueId, + mode: "shared_workspace", strategyType: "project_primary", name: "Task files", cwd: `/tmp/task-${issueId}` }); + return { companyId, issueId, workspaceId, actor: { type: "board" as const, source: "local_implicit" as const, isInstanceAdmin: true } }; + } + + it("persists a projectless binding, preserves it on project edits, and lists it in overview", async () => { + const f = await fixture(), svc = executionWorkspaceService(db); + await svc.bindTaskWorkspace(f.companyId, f.issueId, f.workspaceId); + const projectId = randomUUID(); + await db.insert(projects).values({ id: projectId, companyId: f.companyId, name: "Reports" }); + const updated = await issueService(db).update(f.issueId, { projectId }); + expect(updated?.executionWorkspaceId).toBe(f.workspaceId); + const view = await svc.inspectTaskWorkspace(f.companyId, f.issueId, f.actor); + expect(view.workspace?.projectId).toBeNull(); + expect(view.workspace?.cwd).toBe(`/tmp/task-${f.issueId}`); + expect(view.bindingRevision).toBe(1); + const overview = await svc.listOverview(f.companyId, { limit: 20, offset: 0 }); + expect(overview.items).toEqual(expect.arrayContaining([expect.objectContaining({ workspaceId: f.workspaceId, projectId: null })])); + }); + + it("queues explicit root changes without changing the current binding and applies at admission", async () => { + const f = await fixture(), svc = executionWorkspaceService(db); + await svc.bindTaskWorkspace(f.companyId, f.issueId, f.workspaceId); + const request = { ...f, selection: { kind: "task_directory" as const }, expectedBindingRevision: 1, requestKey: "choose-task-directory" }; + expect(await svc.selectTaskWorkspace(request)).toMatchObject({ kind: "scheduled", applies: "next_normal_admission" }); + expect(await svc.selectTaskWorkspace(request)).toMatchObject({ kind: "scheduled" }); + await expect(svc.selectTaskWorkspace({ ...request, selection: { kind: "existing", workspaceId: f.workspaceId } })).rejects.toThrow("different intent"); + expect((await svc.inspectTaskWorkspace(f.companyId, f.issueId, f.actor)).workspace?.id).toBe(f.workspaceId); + await svc.applyPendingTaskWorkspaceSelection({ ...f, runId: randomUUID() }); + const view = await svc.inspectTaskWorkspace(f.companyId, f.issueId, f.actor); + expect(view.workspace).toBeNull(); + expect(view.selection).toMatchObject({ selection: { kind: "task_directory" } }); + expect(view.bindingRevision).toBe(2); + expect(await svc.selectTaskWorkspace(request)).toMatchObject({ kind: "applied", bindingRevision: 2 }); + expect((await db.select().from(executionWorkspaces).where(eq(executionWorkspaces.id, f.workspaceId)))[0]?.metadata) + .toMatchObject({ _issuePrivacySources: { [f.issueId]: true } }); + await expect(svc.selectTaskWorkspace({ ...request, requestKey: "stale" })).rejects.toThrow("binding changed"); + }); + + it("defaults app and chat tasks to independent directories without hydrating legacy agent files", async () => { + const f = await fixture(); + const root = await fs.mkdtemp(path.join(os.tmpdir(), "task-workspace-default-")); + vi.stubEnv("PAPERCLIP_HOME", root); + try { + const [agent] = await db.insert(agents).values({ companyId: f.companyId, name: "File worker", adapterType: "process", status: "idle" }).returning(); + const legacy = resolveDefaultAgentWorkspaceDir(agent!.id); + await fs.mkdir(legacy, { recursive: true }); + const old = await fs.open(path.join(legacy, "unrelated.bin"), "w"); + await old.truncate(1_200_000_000); await old.close(); + const resolver = createHeartbeatWorkspaceResolver(db); + const first = await resolver.resolveWorkspaceForRun(agent!, { issueId: f.issueId }, null); + expect(first.cwd).not.toBe(legacy); + expect(await fs.readdir(first.cwd)).toEqual([]); + await fs.writeFile(path.join(first.cwd, "report.txt"), "task output"); + const again = await resolver.resolveWorkspaceForRun(agent!, { issueId: f.issueId }, null); + expect(again.cwd).toBe(first.cwd); + const chatId = randomUUID(); + await db.insert(issues).values({ id: chatId, companyId: f.companyId, title: "Chat report", originKind: "chat_channel" }); + const chat = await resolver.resolveWorkspaceForRun(agent!, { issueId: chatId }, null); + expect(chat.cwd).not.toBe(first.cwd); + expect(path.dirname(chat.cwd)).toBe(path.dirname(first.cwd)); + const organizationId = randomUUID(); + await db.insert(projects).values({ id: organizationId, companyId: f.companyId, name: "Organization only" }); + await db.update(issues).set({ projectId: organizationId }).where(eq(issues.id, chatId)); + const organized = await resolver.resolveWorkspaceForRun(agent!, { issueId: chatId }, null); + expect(organized.cwd).toBe(chat.cwd); + expect(organized.projectId).toBeNull(); + const nativeChatScope = await findNativeChatWorkspaceScope(db, { + adapterType: "paperclip_runner", environmentDriver: "local", companyId: f.companyId, + agentId: agent!.id, issueId: chatId, instanceRoot: root, + }); + expect(nativeChatScope?.projectId).toBeNull(); + expect((await fs.stat(path.join(legacy, "unrelated.bin"))).size).toBe(1_200_000_000); + } finally { vi.unstubAllEnvs(); await fs.rm(root, { recursive: true, force: true }); } + }); + + it("retains every task privacy source when simultaneous tasks bind the same folder", async () => { + const f = await fixture(), secondIssueId = randomUUID(), svc = executionWorkspaceService(db); + await db.insert(issues).values({ id: secondIssueId, companyId: f.companyId, title: "Second task" }); + await Promise.all([svc.bindTaskWorkspace(f.companyId, f.issueId, f.workspaceId), svc.bindTaskWorkspace(f.companyId, secondIssueId, f.workspaceId)]); + const [workspace] = await db.select().from(executionWorkspaces).where(eq(executionWorkspaces.id, f.workspaceId)); + expect(workspace?.metadata).toMatchObject({ _issuePrivacySources: { [f.issueId]: true, [secondIssueId]: true } }); + }); + + it("deletes a whole company in source-workspace dependency order", async () => { + const f = await fixture(), sourceProjectId = randomUUID(); + await db.insert(projects).values({ id: sourceProjectId, companyId: f.companyId, name: "Source policy" }); + await db.update(executionWorkspaces).set({ projectId: sourceProjectId }).where(eq(executionWorkspaces.id, f.workspaceId)); + expect(await deleteCompany(db, f.companyId)).toMatchObject({ id: f.companyId }); + expect(await db.select().from(executionWorkspaces).where(eq(executionWorkspaces.id, f.workspaceId))).toEqual([]); + expect(await db.select().from(projects).where(eq(projects.id, sourceProjectId))).toEqual([]); + }); + + it("retains source-project policy until workspaces are explicitly cleaned up", async () => { + const f = await fixture(), sourceProjectId = randomUUID(); + await db.insert(projects).values({ id: sourceProjectId, companyId: f.companyId, name: "Source policy" }); + await db.update(executionWorkspaces).set({ projectId: sourceProjectId }).where(eq(executionWorkspaces.id, f.workspaceId)); + await expect(db.delete(projects).where(eq(projects.id, sourceProjectId))).rejects.toThrow(); + expect((await db.select().from(executionWorkspaces).where(eq(executionWorkspaces.id, f.workspaceId)))[0]?.projectId).toBe(sourceProjectId); + }); + + it("rejects foreign-company selections before changing pending state", async () => { + const f = await fixture(), other = await fixture(), svc = executionWorkspaceService(db); + await expect(svc.selectTaskWorkspace({ ...f, expectedBindingRevision: 0, requestKey: "foreign", + selection: { kind: "existing", workspaceId: other.workspaceId } })).rejects.toThrow("unavailable or inaccessible"); + expect((await svc.inspectTaskWorkspace(f.companyId, f.issueId, f.actor)).pendingSelection).toBeNull(); + }); +}); diff --git a/server/src/__tests__/task-workspace-repositories-persistence.test.ts b/server/src/__tests__/task-workspace-repositories-persistence.test.ts new file mode 100644 index 00000000000..84689ada8a3 --- /dev/null +++ b/server/src/__tests__/task-workspace-repositories-persistence.test.ts @@ -0,0 +1,133 @@ +import { EMBEDDED_POSTGRES_TEST_TIMEOUT_MS } from "@paperclipai/db"; +import { execFile as execFileCallback } from "node:child_process"; +import { promisify } from "node:util"; +import { randomUUID } from "node:crypto"; +import { mkdtemp, rm, mkdir, writeFile, readFile, realpath, open } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { constants as fsConstants } from "node:fs"; +import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; +import { and, eq } from "drizzle-orm"; +import { agents, companies, createDb, executionWorkspaces, executionWorkspaceRepositories, issues } from "@paperclipai/db"; +import { executionWorkspaceService } from "../services/execution-workspaces.js"; +import { executionWorkspaceRepositoryService } from "../services/execution-workspace-repositories.js"; +import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js"; + +const catalog = vi.hoisted(() => ({ available: true })); +vi.mock("../services/tool-access.js", () => ({ toolAccessService: () => ({ listProjectRepositories: async () => ({ repositories: catalog.available ? [{ id: "123", fullName: "team/source", url: "https://github.com/team/source", connections: [] }] : [] }) }) })); +const support = await getEmbeddedPostgresTestSupport(); +(support.supported ? describe : describe.skip)("durable task repository receipts", () => { + let temporary: Awaited>; + let db: ReturnType; + let companyId: string, issueId: string, workspaceId: string, cwd: string; + const actor = { type: "board" as const, source: "local_implicit" as const, userId: "local-board" }; + beforeAll(async () => { + vi.stubEnv("PAPERCLIP_ISSUE_PRIVACY_MODE", "enforce"); + temporary = await startEmbeddedPostgresTestDatabase("paperclip-task-repositories-"); + db = createDb(temporary.connectionString); + cwd = await mkdtemp(path.join(tmpdir(), "paperclip-task-repositories-files-")); + [companyId] = (await db.insert(companies).values({ name: "Task repositories", issuePrefix: "TREPO" }).returning()).map(row => row.id); + [workspaceId] = (await db.insert(executionWorkspaces).values({ companyId, projectId: null, name: "Task files", cwd, mode: "shared_workspace", strategyType: "task_directory" }).returning()).map(row => row.id); + [issueId] = (await db.insert(issues).values({ companyId, title: "Projectless task", executionWorkspaceId: workspaceId }).returning()).map(row => row.id); + }, EMBEDDED_POSTGRES_TEST_TIMEOUT_MS); + afterAll(async () => { vi.unstubAllEnvs(); await temporary?.cleanup(); if (cwd) await rm(cwd, { recursive: true, force: true }); }); + const request = (requestKey: string, ref?: string) => executionWorkspaceRepositoryService(db).request({ companyId, issueId, actor, request: { repository: { kind: "catalog", id: "123" }, requestKey, ...(ref ? { ref } : {}) } }); + it("converges concurrent requests and persists each retry key without creating a project", async () => { + const [peerIssue] = await db.insert(issues).values({ companyId, title: "Peer task sharing the root", executionWorkspaceId: workspaceId }).returning(); + await db.update(executionWorkspaces).set({ sourceIssueId: issueId }).where(eq(executionWorkspaces.id, workspaceId)); + const [first, second] = await Promise.all([ + request("one"), + executionWorkspaceRepositoryService(db).request({ companyId, issueId: peerIssue.id, actor, request: { repository: { kind: "catalog", id: "123" }, requestKey: "two" } }), + ]); + expect(first.operationId).toBe(second.operationId); + expect(first).toMatchObject({ kind: "requires_next_admission", applies: "next_normal_admission" }); + const rows = await db.select().from(executionWorkspaceRepositories).where(eq(executionWorkspaceRepositories.executionWorkspaceId, workspaceId)); + expect(rows).toHaveLength(1); + expect(rows[0].requestKeys.sort()).toEqual(["one", "two"]); + expect((await db.select().from(issues).where(eq(issues.id, issueId)))[0]).toMatchObject({ projectId: null, executionWorkspaceId: workspaceId }); + await expect(request("two", "different-ref")).rejects.toThrow(/conflicts/); + await expect(executionWorkspaceRepositoryService(db).request({ companyId, issueId, actor, request: { repository: { kind: "url", url: "https://github.com/other/source" }, requestKey: "two" } })).rejects.toThrow(/conflicts/); + }); + it("rejects cross-company task IDs and revoked catalog permission even with a saved receipt", async () => { + const [other] = await db.insert(companies).values({ name: "Foreign", issuePrefix: "FOREIGN" }).returning(); + await expect(executionWorkspaceRepositoryService(db).request({ companyId: other.id, issueId, actor, request: { repository: { kind: "catalog", id: "123" }, requestKey: "foreign" } })).rejects.toThrow(/not found/); + catalog.available = false; + await expect(request("one")).rejects.toThrow(/no longer available/); + await expect(executionWorkspaceRepositoryService(db).prepareForAdmission({ companyId, issueId, workspaceId, cwd, agentId: randomUUID(), runId: randomUUID(), responsibleUserId: "local-board" })).rejects.toThrow(/no longer available/); + catalog.available = true; + }); + it("retains durable ownership when Git bundle restoration omits the temporary receipt", async () => { + const [inventory] = await db.select().from(executionWorkspaceRepositories).where(eq(executionWorkspaceRepositories.executionWorkspaceId, workspaceId)); + const checkout = path.join(cwd, inventory.relativePath); + await mkdir(checkout, { recursive: true }); + const git = async (...args: string[]) => (await promisify(execFileCallback)("git", ["-C", checkout, ...args])).stdout.trim(); + await git("init", "--initial-branch=main"); + await git("remote", "add", "origin", inventory.repoUrl); + await writeFile(path.join(checkout, "source.txt"), "restored repository\n"); + await git("add", "."); + await git("-c", "user.name=Fixture", "-c", "user.email=fixture@example.test", "commit", "-m", "restored"); + const commit = await git("rev-parse", "HEAD"); + await db.update(executionWorkspaceRepositories).set({ pinnedCommit: commit, state: "ready" }).where(eq(executionWorkspaceRepositories.id, inventory.id)); + await writeFile(path.join(checkout, "source.txt"), "uncommitted work\n"); + const prepared = await executionWorkspaceRepositoryService(db).prepareForAdmission({ companyId, issueId, workspaceId, cwd, agentId: randomUUID(), runId: randomUUID(), responsibleUserId: "local-board" }); + expect(prepared).toHaveLength(1); + expect(prepared[0].pinnedCommit).toBe(commit); + expect(await readFile(path.join(checkout, "source.txt"), "utf8")).toBe("uncommitted work\n"); + // A malicious/non-regular receipt cannot block admission. The durable DB + // pin remains authoritative, so this invalid receipt is ignored. + if (process.platform !== "win32") { + const receiptPath = path.join(checkout, ".git", "paperclip-workspace-owner.json"); + await promisify(execFileCallback)("mkfifo", [receiptPath]); + let deadline: ReturnType | undefined; + try { + await Promise.race([ + executionWorkspaceRepositoryService(db).prepareForAdmission({ companyId, issueId, workspaceId, cwd, agentId: randomUUID(), runId: randomUUID(), responsibleUserId: "local-board" }), + new Promise((_, reject) => { deadline = setTimeout(() => reject(new Error("FIFO receipt blocked admission")), 3000); }), + ]); + } finally { + clearTimeout(deadline); + // Release a blocked reader if this regression ever returns, so a failed + // assertion cannot leave the test process stuck in its filesystem pool. + const writer = await open(receiptPath, fsConstants.O_WRONLY | fsConstants.O_NONBLOCK).catch(() => null); + await writer?.close(); + await rm(receiptPath, { force: true }); + } + } + }); + it("reuses the same physical checkout across logical workspaces and rechecks retained privacy", async () => { + const [secondAgent] = await db.insert(agents).values({ companyId, name: "Second agent", adapterType: "process" }).returning(); + const [secondWorkspace] = await db.insert(executionWorkspaces).values({ companyId, projectId: null, name: "Same configured folder", cwd, mode: "shared_workspace", strategyType: "operator_directory" }).returning(); + const [secondIssue] = await db.insert(issues).values({ companyId, title: "Second task", assigneeAgentId: secondAgent.id, executionWorkspaceId: secondWorkspace.id }).returning(); + const service = executionWorkspaceRepositoryService(db); + const intent = await service.request({ companyId, issueId: secondIssue.id, actor, request: { repository: { kind: "catalog", id: "123" }, requestKey: "shared-second" } }); + const admission = { companyId, issueId: secondIssue.id, workspaceId: secondWorkspace.id, cwd, agentId: secondAgent.id, runId: randomUUID(), responsibleUserId: null }; + const [original] = await db.select().from(executionWorkspaceRepositories).where(eq(executionWorkspaceRepositories.executionWorkspaceId, workspaceId)); + const prepared = await service.prepareForAdmission(admission); + expect(prepared[0]).toMatchObject({ id: intent.operationId, cwd: path.join(await realpath(cwd), original.relativePath), pinnedCommit: original.pinnedCommit }); + expect(await readFile(path.join(prepared[0].cwd, "source.txt"), "utf8")).toBe("uncommitted work\n"); + // There is still one physical checkout and neither its files nor Git metadata were rewritten. + expect(await readFile(path.join(prepared[0].cwd, ".git", "paperclip-workspace-owner.json"), "utf8").catch(() => null)).toBeNull(); + const [differentRefWorkspace] = await db.insert(executionWorkspaces).values({ companyId, name: "Conflicting ref", cwd, mode: "shared_workspace", strategyType: "operator_directory" }).returning(); + const [differentRefIssue] = await db.insert(issues).values({ companyId, title: "Different ref", executionWorkspaceId: differentRefWorkspace.id }).returning(); + await service.request({ companyId, issueId: differentRefIssue.id, actor, request: { repository: { kind: "catalog", id: "123" }, ref: "release", requestKey: "conflicting-ref" } }); + await expect(service.prepareForAdmission({ ...admission, issueId: differentRefIssue.id, workspaceId: differentRefWorkspace.id })).rejects.toThrow(/different ref/); + const [privateSource] = await db.insert(issues).values({ companyId, title: "Retained private source", visibility: "private" }).returning(); + const owner = executionWorkspaceService(db); + await owner.bindTaskWorkspace(companyId, privateSource.id, workspaceId); + const [movedWorkspace] = await db.insert(executionWorkspaces).values({ companyId, name: "Private task moved away", cwd: path.join(cwd, "moved-private-task"), mode: "shared_workspace", strategyType: "task_directory" }).returning(); + await owner.bindTaskWorkspace(companyId, privateSource.id, movedWorkspace.id); + const [retainedWorkspace] = await db.select().from(executionWorkspaces).where(eq(executionWorkspaces.id, workspaceId)); + expect(retainedWorkspace.metadata?._issuePrivacySources).toMatchObject({ [privateSource.id]: true }); + // Reauthorization also applies after the second inventory has a durable pin. + await expect(service.prepareForAdmission(admission)).rejects.toThrow(/outside this actor's access/); + expect(await readFile(path.join(prepared[0].cwd, "source.txt"), "utf8")).toBe("uncommitted work\n"); + }); + it("stages a root change without replacing active files or adding a wake", async () => { + const owner = executionWorkspaceService(db); + await expect(owner.selectTaskWorkspace({ companyId, issueId, actor, selection: { kind: "task_directory" }, expectedBindingRevision: 0, requestKey: "move" })).resolves.toMatchObject({ kind: "scheduled", applies: "next_normal_admission" }); + const [issue] = await db.select().from(issues).where(and(eq(issues.companyId, companyId), eq(issues.id, issueId))); + expect(issue.executionWorkspaceId).toBe(workspaceId); + expect(issue.workspacePendingSelection?.requestKey).toBe("move"); + expect(issue.workspaceBindingRevision).toBe(0); + }); +}); diff --git a/server/src/__tests__/task-workspace-repositories.test.ts b/server/src/__tests__/task-workspace-repositories.test.ts new file mode 100644 index 00000000000..57e4e87d789 --- /dev/null +++ b/server/src/__tests__/task-workspace-repositories.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from "vitest"; +import { prepareWorkspaceRepositorySchema } from "@paperclipai/shared"; +import { resolveTaskRepository, taskRepositoryRelativePath, assertRepositoryRetryMatches } from "../services/execution-workspace-repositories.js"; + +const repository = { id: "123", fullName: "team/source", url: "https://github.com/team/source", connections: [] }; +describe("task repository acquisition authority", () => { + it("requires a currently authorized catalog ID, including on retry after revocation", () => { + expect(resolveTaskRepository({ kind: "catalog", id: "123" }, [repository])).toMatchObject({ catalogRepositoryId: "123" }); + expect(() => resolveTaskRepository({ kind: "catalog", id: "123" }, [])).toThrow(/no longer available/); + expect(() => resolveTaskRepository({ kind: "catalog", id: "other-company-repo" }, [repository])).toThrow(/no longer available/); + }); + it("normalizes HTTPS URLs and never accepts credentials, alternate hosts, or shell paths", () => { + expect(resolveTaskRepository({ kind: "url", url: "https://github.com/team/source.git" }, [repository])).toMatchObject({ catalogRepositoryId: "123", repositoryIdentity: repository.url }); + expect(resolveTaskRepository({ kind: "url", url: "https://github.com/public/repo" }, [])).toMatchObject({ catalogRepositoryId: null }); + for (const url of ["git@github.com:team/source", "https://token@github.com/team/source", "https://evil.example/team/source", "file:///tmp/source", "https://github.com/team/source?q=secret"]) { + expect(() => resolveTaskRepository({ kind: "url", url }, [])).toThrow(/HTTPS GitHub/); + } + }); + it("converges repository aliases into a stable contained path and rejects conflicting refs", () => { + const source = resolveTaskRepository({ kind: "catalog", id: "123" }, [repository]); + const alias = resolveTaskRepository({ kind: "url", url: "https://github.com/TEAM/SOURCE.git" }, [repository]); + expect(taskRepositoryRelativePath(source.repositoryIdentity)).toBe(taskRepositoryRelativePath(alias.repositoryIdentity)); + expect(taskRepositoryRelativePath(source.repositoryIdentity)).toMatch(/^\.paperclip-repositories\/task-repo-[a-f0-9]{24}$/); + const receipt = { repositoryIdentity: source.repositoryIdentity, requestedRef: "main" }; + expect(() => assertRepositoryRetryMatches(receipt, source.repositoryIdentity, "main")).not.toThrow(); + expect(() => assertRepositoryRetryMatches(receipt, source.repositoryIdentity, "release")).toThrow(/different ref/); + expect(() => assertRepositoryRetryMatches(receipt, "another", "main")).toThrow(/conflicts/); + }); + it("accepts refs as data while rejecting options and traversal", () => { + const parse = (ref: string) => prepareWorkspaceRepositorySchema.parse({ repository: { kind: "catalog", id: "123" }, requestKey: "retry", ref }); + expect(parse("feature/task-files").ref).toBe("feature/task-files"); + for (const ref of ["--upload-pack=sh", "../main", "a..b", "main;echo x", "refs//heads/main"]) expect(() => parse(ref)).toThrow(); + }); +}); diff --git a/server/src/__tests__/task-workspace-tools.test.ts b/server/src/__tests__/task-workspace-tools.test.ts new file mode 100644 index 00000000000..e1fc96868b1 --- /dev/null +++ b/server/src/__tests__/task-workspace-tools.test.ts @@ -0,0 +1,23 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { callTaskWorkspaceTool } from "../services/task-workspace-tools.js"; + +afterEach(() => vi.unstubAllGlobals()); +const base = { apiUrl: "https://paperclip.example/api", token: "run-token", companyId: "company", issueId: "task" }; +describe("task workspace semantic transport", () => { + it("uses the normal bound task routes without passing caller identities or interrupt requests", async () => { + const fetcher = vi.fn(async () => Response.json({ kind: "scheduled", applies: "next_normal_admission" })); + vi.stubGlobal("fetch", fetcher); + await expect(callTaskWorkspaceTool({ ...base, name: "select_workspace", arguments: { selection: { kind: "task_directory" }, expectedBindingRevision: 3, requestKey: "move-1" } })).resolves.toMatchObject({ applies: "next_normal_admission" }); + expect(fetcher).toHaveBeenCalledTimes(1); + expect(fetcher.mock.calls[0]).toEqual(["https://paperclip.example/api/issues/task/workspace", expect.objectContaining({ method: "PUT", body: JSON.stringify({ selection: { kind: "task_directory" }, expectedBindingRevision: 3, requestKey: "move-1" }) })]); + }); + it("prepares a repository without creating a project or choosing a host path", async () => { + const fetcher = vi.fn(async () => Response.json({ kind: "requires_next_admission" })); + vi.stubGlobal("fetch", fetcher); + const args = { repository: { kind: "catalog", id: "repo" }, requestKey: "acquire-1" }; + await callTaskWorkspaceTool({ ...base, name: "prepare_repository", arguments: args }); + expect(fetcher.mock.calls[0]).toEqual(["https://paperclip.example/api/issues/task/workspace/repositories", expect.objectContaining({ method: "POST", body: JSON.stringify(args) })]); + await expect(callTaskWorkspaceTool({ ...base, name: "prepare_repository", arguments: { ...args, cwd: "/home" } })).rejects.toThrow(); + expect(fetcher).toHaveBeenCalledTimes(1); + }); +}); diff --git a/server/src/app.ts b/server/src/app.ts index ed52a4c112a..7fb301588e7 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -21,6 +21,7 @@ import { publicMcpIngressRoutes, publicMcpManagementRoutes } from "./routes/publ import { agentAvatarRoutes } from "./routes/agent-avatars.js"; import { decisionModelRoutes } from "./routes/decision-models.js"; import { aiConnectionRoutes } from "./routes/ai-connections.js"; +import { taskWorkspaceRoutes } from "./routes/task-workspaces.js"; import { projectToolRoutes } from "./routes/project-tools.js"; import { emailChannelService } from "./services/email-channels.js"; import { emailRoutes, emailWebhookRoutes } from "./routes/email.js"; @@ -806,6 +807,7 @@ export async function createApp( api.use(assetRoutes(db, opts.storageService)); api.use(agentProfileAvatarRoutes(db, opts.storageService)); api.use(projectToolRoutes(db)); + api.use(taskWorkspaceRoutes(db)); api.use(projectRoutes(db)); api.use(caseRoutes(db, opts.storageService)); api.use(issueTreeControlRoutes(db, { pluginWorkerManager: workerManager })); diff --git a/server/src/redaction.ts b/server/src/redaction.ts index 31a7917a5d3..03efe939d53 100644 --- a/server/src/redaction.ts +++ b/server/src/redaction.ts @@ -341,7 +341,9 @@ const NATIVE_RUN_SPAN_NAMES = new Set([ "runner.turn.submit", "task.prepare", "task.run", + "task.provider_session", "task.run.measured", + "task.provider_session.measured", "task.settle", ]); const CLI_SECRET_FLAG_RE = new RegExp( diff --git a/server/src/routes/execution-workspaces.ts b/server/src/routes/execution-workspaces.ts index 280d9bc01ed..6765d670d94 100644 --- a/server/src/routes/execution-workspaces.ts +++ b/server/src/routes/execution-workspaces.ts @@ -3,7 +3,7 @@ import { canActorReadExecutionWorkspace, executionWorkspaceReadSqlCondition } fr import { spawn } from "node:child_process"; import { accessSync, constants as fsConstants, existsSync, readFileSync } from "node:fs"; import path from "node:path"; -import { and, eq } from "drizzle-orm"; +import { and, eq, sql } from "drizzle-orm"; import { Router, type Request, type Response } from "express"; import type { Db } from "@paperclipai/db"; import { issues, projects, projectWorkspaces } from "@paperclipai/db"; @@ -321,7 +321,7 @@ export function executionWorkspaceRoutes(db: Db, opts: { pluginWorkerManager?: P and( eq(projectWorkspaces.id, existing.projectWorkspaceId), eq(projectWorkspaces.companyId, existing.companyId), - eq(projectWorkspaces.projectId, existing.projectId), + existing.projectId ? eq(projectWorkspaces.projectId, existing.projectId) : sql`false`, ), ) .then((rows) => rows[0] ?? null) diff --git a/server/src/routes/issues.ts b/server/src/routes/issues.ts index f738e91abac..5c898c2f9eb 100644 --- a/server/src/routes/issues.ts +++ b/server/src/routes/issues.ts @@ -3710,6 +3710,7 @@ export function issueRoutes( input: Record, ) { return ( + input.workspaceSelection !== undefined || input.parentId !== undefined || input.inheritExecutionWorkspaceFromIssueId !== undefined || input.projectWorkspaceId !== undefined || @@ -4765,31 +4766,12 @@ export function issueRoutes( executionWorkspacePreference?: string | null; executionWorkspaceSettings?: unknown; }) { - // Match create()'s project precedence before checking any assignment or - // trust policy. A workspace or explicit inheritance source can supply the - // project even when a caller omits projectId and the parent is projectless. + // Organizational project inheritance never comes from filesystem sources. if (input.projectId) return input.projectId; const sourceId = input.inheritExecutionWorkspaceFromIssueId ?? input.parentId; const source = sourceId ? await svc.getById(sourceId) : null; if (sourceId && (!source || source.companyId !== input.companyId)) throw notFound("Workspace inheritance issue not found"); - if (source?.projectId) return source.projectId; - const projectWorkspaceId = input.projectWorkspaceId ?? source?.projectWorkspaceId; - if (projectWorkspaceId) { - const [workspace] = await db.select({ projectId: projectWorkspaces.projectId }).from(projectWorkspaces) - .where(and(eq(projectWorkspaces.id, projectWorkspaceId), eq(projectWorkspaces.companyId, input.companyId))); - if (!workspace) throw notFound("Project workspace not found"); - return workspace.projectId; - } - const hasExecutionOverride = input.executionWorkspaceId !== undefined || - input.executionWorkspacePreference !== undefined || input.executionWorkspaceSettings !== undefined; - const executionWorkspaceId = input.executionWorkspaceId ?? (hasExecutionOverride ? null : source?.executionWorkspaceId); - if (executionWorkspaceId && (await instanceSettings.getExperimental()).enableIsolatedWorkspaces) { - const [workspace] = await db.select({ projectId: executionWorkspaces.projectId }).from(executionWorkspaces) - .where(and(eq(executionWorkspaces.id, executionWorkspaceId), eq(executionWorkspaces.companyId, input.companyId))); - if (!workspace) throw notFound("Execution workspace not found"); - return workspace.projectId; - } - return null; + return source?.projectId ?? null; } async function assertCanAssignTasks( @@ -7623,7 +7605,7 @@ export function issueRoutes( // must not install the guard, or it can clear the flag that // the other request still owns. // null - this function sent an error response, so the caller stops. - // The reopen is scoped to the issue company and project inside the service, and + // The reopen checks the persisted task binding and source access inside the service, and // it runs only after the route already authorized the request on the issue. async function reopenClosedIssueExecutionWorkspaceOrRespond( req: Request, @@ -7645,6 +7627,7 @@ export function issueRoutes( projectId: issue.projectId ?? null, }, actor: { agentId: actor.agentId, actorType: actor.actorType }, + authorizationActor: req.actor, }, ); if (result.ok) { @@ -12260,6 +12243,7 @@ export function issueRoutes( null; const createInput = { ...createBody, + workspaceSelectionActor: req.actor, projectId: createAssignmentScope.projectId, ...(taskBridgeOriginForActor(req) ?? {}), id: issueId, diff --git a/server/src/routes/task-workspaces.ts b/server/src/routes/task-workspaces.ts new file mode 100644 index 00000000000..ffed4d189cb --- /dev/null +++ b/server/src/routes/task-workspaces.ts @@ -0,0 +1,48 @@ +import { and, eq } from "drizzle-orm"; +import { issues, type Db } from "@paperclipai/db"; +import { Router, type Request } from "express"; +import { prepareWorkspaceRepositorySchema, selectTaskWorkspaceSchema } from "@paperclipai/shared"; +import { accessService } from "../services/access.js"; +import { executionWorkspaceService } from "../services/execution-workspaces.js"; +import { canActorReadExecutionWorkspace, issueReadSqlCondition } from "../services/authorization.js"; +import { projectToolContext } from "../services/project-tool-context.js"; +import { logActivity } from "../services/activity-log.js"; +import { forbidden, notFound } from "../errors.js"; +import { assertCompanyAccess, getActorInfo } from "./authz.js"; +import { validate } from "../middleware/validate.js"; + +/** App and agent transports share task/workspace authorization and the aggregate owner. */ +export function taskWorkspaceRoutes(db: Db) { + const router = Router(); + const workspaces = executionWorkspaceService(db); + async function task(req: Request, write = false) { + const [issue] = await db.select().from(issues).where(and(eq(issues.id, req.params.id as string), await issueReadSqlCondition(db, req.actor))); + if (!issue) throw notFound("Task not found"); + assertCompanyAccess(req, issue.companyId); + const decision = await accessService(db).decide({ actor: req.actor, action: write ? "issue:mutate" : "issue:read", resource: { type: "issue", companyId: issue.companyId, issueId: issue.id } }); + if (!decision.allowed) throw forbidden("Task workspace is outside this actor's authorization boundary"); + if (issue.executionWorkspaceId && !(await canActorReadExecutionWorkspace(db, req.actor, issue.executionWorkspaceId))) throw notFound("Execution workspace not found"); + if (write && req.actor.type === "agent") { + const context = await projectToolContext(db, req.actor, true, "Workspace"); + if (context.issue.id !== issue.id) throw forbidden("Workspace selection is scoped to the current task"); + } + return issue; + } + router.get("/issues/:id/workspace", async (req, res) => { + const issue = await task(req); + const view = await workspaces.inspectTaskWorkspace(issue.companyId, issue.id, req.actor); + res.json({ ...view, repositories: issue.executionWorkspaceId ? await workspaces.listTaskRepositories(issue.companyId, issue.executionWorkspaceId) : [], capabilities: { prepareRepository: "next_normal_admission", selectWorkspace: "next_normal_admission" }, fileLocations: { taskFiles: "workspace", personalInstructionsAndMemory: "AGENT_HOME", providerHome: "runtime_managed" } }); + }); + router.put("/issues/:id/workspace", validate(selectTaskWorkspaceSchema), async (req, res) => { + const issue = await task(req, true); + const result = await workspaces.selectTaskWorkspace({ companyId: issue.companyId, issueId: issue.id, actor: req.actor, ...req.body }); + const actor = getActorInfo(req); + await logActivity(db, { companyId: issue.companyId, ...actor, action: "issue.workspace.selection_requested", entityType: "issue", entityId: issue.id, details: { selection: req.body.selection, applies: "next_normal_admission" } }); + res.json(result); + }); + router.post("/issues/:id/workspace/repositories", validate(prepareWorkspaceRepositorySchema), async (req, res) => { + const issue = await task(req, true); + res.json(await workspaces.requestTaskRepository({ companyId: issue.companyId, issueId: issue.id, actor: req.actor, request: req.body })); + }); + return router; +} diff --git a/server/src/services/chat-channels.ts b/server/src/services/chat-channels.ts index 5b3e009c705..256e27a8b08 100644 --- a/server/src/services/chat-channels.ts +++ b/server/src/services/chat-channels.ts @@ -23,6 +23,7 @@ import { instanceSettingsService } from "./instance-settings.js"; import { registerSlackTaskAuthority, slackRunOrigin } from "./connectors/slack-authority.js"; import { captureRunIdentity } from "./run-identity.js"; import { buildChatCommunicationGuidance } from "./chat-communication-guidance.js"; +import { assertChatExecutionDefaultsAccess, resolveChatExecutionDefaults } from "./chat-execution-defaults.js"; function githubPolicyRecord(value: unknown): Record { return value && typeof value === "object" && !Array.isArray(value) ? value as Record : {}; } import { githubChatManagementService } from "./chat-github-management.js"; import { githubReviewCheckService } from "./chat-github-checks.js"; @@ -146,6 +147,7 @@ import { } from "@paperclipai/db"; import type { ChatAdapterCapabilities, + ChatExecutionDefaults, ChatEndpointCallbackSurfaces, ChatEventKind, ChatEndpoint, @@ -5839,6 +5841,7 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) { botLabel: endpoint.botDisplayName ?? row.assignedAgentName, botAvatarUrl: endpoint.botAvatarUrl, communicationInstructions: endpoint.communicationInstructions, + executionDefaults: endpoint.executionDefaults, ...(endpoint.provider === "imessage-photon" && endpoint.botExternalId ? { photonAllocation: endpoint.botExternalId.startsWith("photon-project:") ? "shared" as const : "dedicated" as const } : {}), allowDirectMessages: endpoint.allowDirectMessages, requireAtMention: endpoint.requireAtMention, @@ -6061,7 +6064,7 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) { ) { const initial = await endpointRecord(endpointId); if (!initial) throw notFound("Chat endpoint not found"); - if (initial.endpoint.provider === "agentmail") throw badRequest("Use the email inbox API for AgentMail"); + if (initial.endpoint.provider === "agentmail" && Object.keys(input).some((key) => key !== "executionDefaults")) throw badRequest("Use the email inbox API for AgentMail"); await withCredentialMutationLease( initial.endpoint, async (credentialLease) => { @@ -6121,6 +6124,14 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) { values.allowUnlinkedPeople = input.allowUnlinkedPeople; await db.transaction(async (tx) => { await credentialLease.assertOwned(tx); + if (input.executionDefaults !== undefined) { + await assertChatExecutionDefaultsAccess(db, { + companyId: existing.endpoint.companyId, + actor: { type: "board", userId: actorUserId, source: actorUserId ? "session" : "local_implicit" }, + defaults: input.executionDefaults, + }, tx); + values.executionDefaults = input.executionDefaults; + } await tx .update(chatEndpoints) .set(values) @@ -15938,6 +15949,20 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) { ) => { let conversation = existingConversation; if (!conversation) { + const [taskResource] = await taskTx.select({ executionDefaults: chatEndpointResources.executionDefaults }) + .from(chatEndpointResources).where(and(eq(chatEndpointResources.id, resource.id), eq(chatEndpointResources.endpointId, taskEndpoint.id))); + const executionDefaults = resolveChatExecutionDefaults(taskEndpoint.executionDefaults, taskResource?.executionDefaults); + const responsibleUserId = taskUserId ?? endpoint.sponsorUserId; + await assertChatExecutionDefaultsAccess(db, { + companyId: endpoint.companyId, + actor: responsibleUserId ? { type: "board", userId: responsibleUserId, source: "session", ignoreInstanceAdmin: true } : { type: "none" }, + defaults: executionDefaults, + }, taskTx); + await assertChatExecutionDefaultsAccess(db, { + companyId: endpoint.companyId, + actor: { type: "agent", agentId: endpoint.assignedAgentId, companyId: endpoint.companyId, onBehalfOfUserId: responsibleUserId, source: "agent_key" }, + defaults: executionDefaults, + }, taskTx); const sessionGeneration = isLinear ? (latestConversation?.sessionGeneration ?? 0) + 1 : 1; @@ -15959,6 +15984,8 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) { originKind: "chat_channel", originId: `${endpoint.id}:${thread.id}:${sessionGeneration}`, idempotencyKey: `chat:${endpoint.id}:${thread.id}:${sessionGeneration}`, + ...(executionDefaults.projectId !== undefined ? { projectId: executionDefaults.projectId } : {}), + ...(executionDefaults.workspace ? { workspaceSelection: executionDefaults.workspace, workspaceSelectionSource: "channel" as const } : {}), }, taskTx, ); @@ -28085,7 +28112,7 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) { async function replaceResources( endpointId: string, - updates: Array<{ id: string; enabled: boolean }>, + updates: Array<{ id: string; enabled?: boolean; executionDefaults?: ChatExecutionDefaults | null }>, actorUserId?: string | null, options?: { initialGitHubImport?: boolean }, ) { @@ -28100,7 +28127,7 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) { async function updateResourceSelection( endpointId: string, - updates: Array<{ id: string; enabled: boolean }>, + updates: Array<{ id: string; enabled?: boolean; executionDefaults?: ChatExecutionDefaults | null }>, actorUserId?: string | null, options?: { initialGitHubImport?: boolean; allGitHubRepositoriesEnabled?: boolean }, ) { @@ -28140,6 +28167,7 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) { id: chatEndpointResources.id, availability: chatEndpointResources.availability, enabled: chatEndpointResources.enabled, + executionDefaults: chatEndpointResources.executionDefaults, }) .from(chatEndpointResources) .where( @@ -28160,7 +28188,7 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) { .for("no key update"); if (!bulk && rows.length !== new Set(ids).size) throw unprocessable("Every resource must belong to this endpoint"); - const selectedUpdates = bulk + const selectedUpdates: typeof updates = bulk ? rows.map((row) => ({ id: row.id, enabled: options!.allGitHubRepositoriesEnabled! })) : updates; const availabilityById = new Map( @@ -28177,15 +28205,26 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) { "A destination must still be available from the provider before it can be enabled", { code: "chat_resource_unavailable", resourceId: unavailable.id }, ); - const finalEnabled = new Map( - selectedUpdates.map((entry) => [entry.id, entry.enabled]), - ); + const finalEnabled = new Map(rows.map((row) => [row.id, row.enabled])); + for (const entry of selectedUpdates) { + if (entry.enabled !== undefined) finalEnabled.set(entry.id, entry.enabled); + } + const finalDefaults = new Map(rows.map((row) => [row.id, row.executionDefaults])); + for (const entry of selectedUpdates) { + if (entry.executionDefaults === undefined) continue; + await assertChatExecutionDefaultsAccess(db, { + companyId: endpoint.companyId, + actor: { type: "board", userId: actorUserId, source: actorUserId ? "session" : "local_implicit" }, + defaults: entry.executionDefaults, + }, tx); + finalDefaults.set(entry.id, entry.executionDefaults); + } const changes = rows - .filter((row) => row.enabled !== finalEnabled.get(row.id)) + .filter((row) => row.enabled !== finalEnabled.get(row.id) || JSON.stringify(row.executionDefaults) !== JSON.stringify(finalDefaults.get(row.id))) .map((row) => ({ resourceId: row.id, - before: { enabled: row.enabled }, - after: { enabled: finalEnabled.get(row.id)! }, + before: { enabled: row.enabled, ...(JSON.stringify(row.executionDefaults) !== JSON.stringify(finalDefaults.get(row.id)) ? { executionDefaults: row.executionDefaults } : {}) }, + after: { enabled: finalEnabled.get(row.id)!, ...(JSON.stringify(row.executionDefaults) !== JSON.stringify(finalDefaults.get(row.id)) ? { executionDefaults: finalDefaults.get(row.id) } : {}) }, })); if (bulk) { // One atomic database update, independent of pagination/search and @@ -28203,7 +28242,7 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) { } else for (const entry of selectedUpdates) await tx .update(chatEndpointResources) - .set({ enabled: entry.enabled, updatedAt: new Date() }) + .set({ enabled: entry.enabled, ...(entry.executionDefaults !== undefined ? { executionDefaults: entry.executionDefaults } : {}), updatedAt: new Date() }) .where( and( eq(chatEndpointResources.companyId, endpoint.companyId), diff --git a/server/src/services/chat-execution-defaults.ts b/server/src/services/chat-execution-defaults.ts new file mode 100644 index 00000000000..e6708f053ae --- /dev/null +++ b/server/src/services/chat-execution-defaults.ts @@ -0,0 +1,42 @@ +import { chatExecutionDefaultsSchema, type ChatExecutionDefaults } from "@paperclipai/shared"; +import { type Db, projects } from "@paperclipai/db"; +import { and, eq } from "drizzle-orm"; +import { projectReadSqlCondition, type AuthorizationActor } from "./authorization.js"; +import { executionWorkspaceService } from "./execution-workspaces.js"; +import { notFound } from "../errors.js"; + +type DbTransaction = Parameters[0]>[0]; + +/** Configuration selects resources; it never grants their access to future senders. */ +export async function assertChatExecutionDefaultsAccess( + db: Db, + input: { companyId: string; actor: AuthorizationActor; defaults: ChatExecutionDefaults | null | undefined }, + reader: Db | DbTransaction = db, +): Promise { + const defaults = chatExecutionDefaultsSchema.parse(input.defaults ?? {}); + if (defaults.projectId) { + const [project] = await reader.select({ id: projects.id }).from(projects).where(and( + eq(projects.id, defaults.projectId), eq(projects.companyId, input.companyId), + await projectReadSqlCondition(reader, input.actor), + )); + if (!project) throw notFound("Default project is unavailable or inaccessible"); + } + if (defaults.workspace) await executionWorkspaceService(db).validateSelection({ + companyId: input.companyId, actor: input.actor, selection: defaults.workspace, + }, reader); +} + +/** Resolve once when a conversation creates its task. Later endpoint edits are irrelevant. */ +export function resolveChatExecutionDefaults( + endpoint: ChatExecutionDefaults | null | undefined, + resource: ChatExecutionDefaults | null | undefined, +): ChatExecutionDefaults { + const base = chatExecutionDefaultsSchema.parse(endpoint ?? {}); + const override = chatExecutionDefaultsSchema.parse(resource ?? {}); + return { + ...(base.projectId !== undefined ? { projectId: base.projectId } : {}), + ...(base.workspace !== undefined ? { workspace: base.workspace } : {}), + ...(override.projectId !== undefined ? { projectId: override.projectId } : {}), + ...(override.workspace !== undefined ? { workspace: override.workspace } : {}), + }; +} diff --git a/server/src/services/company-deletion.ts b/server/src/services/company-deletion.ts index 4ab40f03e69..538e9b50bd5 100644 --- a/server/src/services/company-deletion.ts +++ b/server/src/services/company-deletion.ts @@ -14,6 +14,7 @@ import { issues, issueComments, projects, + executionWorkspaces, goals, heartbeatRuns, runIdentityContexts, @@ -99,6 +100,9 @@ export async function deleteCompany(db: Db, id: string) { await tx.delete(companyLogos).where(eq(companyLogos.companyId, id)); await tx.delete(assets).where(eq(assets.companyId, id)); await tx.delete(goals).where(eq(goals.companyId, id)); + // A retained workspace protects its source project from individual deletion. + // Whole-company deletion explicitly removes both in dependency order. + await tx.delete(executionWorkspaces).where(eq(executionWorkspaces.companyId, id)); await tx.delete(projects).where(eq(projects.companyId, id)); await agentLifecycleCompanyDeletion.deleteCompanyData(tx, id); const rows = await tx diff --git a/server/src/services/email-channels.ts b/server/src/services/email-channels.ts index 830d5f921f9..2591818b7bb 100644 --- a/server/src/services/email-channels.ts +++ b/server/src/services/email-channels.ts @@ -45,6 +45,7 @@ import { resolveExecutionWorkspaceEnvironmentId } from "./execution-workspace-po import { emailConnectionService } from "./email-connections.js"; import { secretService } from "./secrets.js"; import { authorizationService } from "./authorization.js"; +import { assertChatExecutionDefaultsAccess, resolveChatExecutionDefaults } from "./chat-execution-defaults.js"; import { issueService } from "./issues.js"; import { logActivity } from "./activity-log.js"; import { instanceSettingsService } from "./instance-settings.js"; @@ -1318,10 +1319,27 @@ export function emailChannelService(db: Db, options: EmailChannelOptions) { endpoint.companyId, endpoint.assignedAgentId, ); + const defaults = resolveChatExecutionDefaults(endpoint.executionDefaults, null); + // A channel default cannot widen the receiving agent's trust boundary. + if ("projectId" in placement && defaults.projectId !== undefined && defaults.projectId !== placement.projectId) { + throw forbidden("Default project conflicts with the assigned agent's trust boundary"); + } + await assertChatExecutionDefaultsAccess(db, { + companyId: endpoint.companyId, + actor: endpoint.sponsorUserId ? { type: "board", userId: endpoint.sponsorUserId, source: "session", ignoreInstanceAdmin: true } : { type: "none" }, + defaults, + }, tx); + await assertChatExecutionDefaultsAccess(db, { + companyId: endpoint.companyId, + actor: { type: "agent", agentId: endpoint.assignedAgentId, companyId: endpoint.companyId, onBehalfOfUserId: endpoint.sponsorUserId, source: "agent_key" }, + defaults, + }, tx); const task = await issueService(db).create( endpoint.companyId, { + ...(defaults.projectId !== undefined ? { projectId: defaults.projectId } : {}), ...placement, + ...(defaults.workspace ? { workspaceSelection: defaults.workspace, workspaceSelectionSource: "channel" as const } : {}), title: message.subject.slice(0, 200), description: `Email conversation for ${endpoint.botExternalId}`, status: "todo", diff --git a/server/src/services/execution-workspace-policy.ts b/server/src/services/execution-workspace-policy.ts index dd36825a2a6..50e44ab19f3 100644 --- a/server/src/services/execution-workspace-policy.ts +++ b/server/src/services/execution-workspace-policy.ts @@ -446,3 +446,21 @@ export function buildExecutionWorkspaceAdapterConfig(input: { return nextConfig; } + +/** Persisted filesystem authority survives UI feature-flag changes. */ +export function taskWorkspaceRuntimeSelectionEnabled(input: { + legacyUiEnabled: boolean; + hasTypedSelection: boolean; + hasBinding: boolean; +}): boolean { + return input.legacyUiEnabled || input.hasTypedSelection || input.hasBinding; +} + +/** Defaults and pending intent never rewrite an already admitted native run. */ +export function canApplyTaskWorkspaceSelectionAtAdmission(input: { + admittedInput: unknown; + restarting: boolean; + hasLeaseOwner: boolean; +}): boolean { + return input.admittedInput == null && !input.restarting && !input.hasLeaseOwner; +} diff --git a/server/src/services/execution-workspace-repositories.ts b/server/src/services/execution-workspace-repositories.ts new file mode 100644 index 00000000000..fdb6dc00882 --- /dev/null +++ b/server/src/services/execution-workspace-repositories.ts @@ -0,0 +1,194 @@ +import { createHash } from "node:crypto"; +import { execFile as execFileCallback } from "node:child_process"; +import { promisify } from "node:util"; +import fs from "node:fs/promises"; +import { constants as fsConstants } from "node:fs"; +import path from "node:path"; +import { and, eq, or, sql } from "drizzle-orm"; +import { activityLog, executionWorkspaceRepositories, executionWorkspaces, issues, type Db } from "@paperclipai/db"; +import { prepareWorkspaceRepositorySchema, type PrepareWorkspaceRepository, type ProjectRepository } from "@paperclipai/shared"; +import { withDirectoryMergeLock } from "@paperclipai/adapter-utils/workspace-restore-merge"; +import { conflict, forbidden, notFound } from "../errors.js"; +import { accessService } from "./access.js"; +import { canActorReadExecutionWorkspace, issueReadSqlCondition, type AuthorizationActor } from "./authorization.js"; +import { captureRunIdentity } from "./run-identity.js"; +import { toolAccessService } from "./tool-access.js"; +import { normalizeProjectRepositoryUrl } from "./project-repositories.js"; +import { buildGitAuthInvocation, createGitRemoteAuthProvider } from "./git-credentials.js"; +import { materializeManagedProjectWorkspace, ensureManagedRepositoriesIgnored } from "./managed-repository-checkout.js"; + +const execFile = promisify(execFileCallback); +type RepositoryRow = typeof executionWorkspaceRepositories.$inferSelect; + +/** The catalog is refreshed on request and admission; saved IDs are not grants. */ +export function resolveTaskRepository(request: PrepareWorkspaceRepository["repository"], available: ProjectRepository[]) { + const selected = request.kind === "catalog" ? available.find(repo => repo.id === request.id) : null; + if (request.kind === "catalog" && !selected) throw forbidden("Repository access is no longer available; refresh the repository catalog"); + const source = normalizeProjectRepositoryUrl(selected?.url ?? (request.kind === "url" ? request.url : "")); + const match = selected ?? available.find(repo => normalizeProjectRepositoryUrl(repo.url).url.toLowerCase() === source.url.toLowerCase()); + return { repoUrl: source.url, repositoryIdentity: source.url.toLowerCase(), catalogRepositoryId: match?.id ?? null }; +} + +export function taskRepositoryRelativePath(identity: string) { + return `.paperclip-repositories/task-repo-${createHash("sha256").update(identity).digest("hex").slice(0, 24)}`; +} + +export function assertRepositoryRetryMatches(row: Pick, identity: string, ref: string) { + if (row.repositoryIdentity !== identity || row.requestedRef !== ref) { + throw conflict("Repository preparation conflicts with an existing receipt. A different ref requires a separate workspace; existing files are never switched automatically."); + } +} + +/** Workspace aggregate child. It owns acquisition receipts, never task binding or provider sessions. */ +export function executionWorkspaceRepositoryService(db: Db) { + async function catalog(companyId: string, userId: string | null, localTrusted: boolean) { + return (await toolAccessService(db).listProjectRepositories(companyId, userId, localTrusted)).repositories; + } + async function request(input: { companyId: string; issueId: string; actor: AuthorizationActor; request: PrepareWorkspaceRepository }) { + const parsed = prepareWorkspaceRepositorySchema.parse(input.request); + const decision = await accessService(db).decide({ actor: input.actor, action: "issue:mutate", resource: { type: "issue", companyId: input.companyId, issueId: input.issueId } }); + if (!decision.allowed) throw forbidden("Task workspace modification is not allowed"); + let userId = input.actor.userId ?? null; + let localTrusted = input.actor.source === "local_implicit"; + if (input.actor.type === "agent") { + if (input.actor.source !== "agent_jwt" || !input.actor.agentId || !input.actor.runId || input.actor.companyId !== input.companyId) throw forbidden("Repository preparation requires an authenticated task run"); + const identity = await captureRunIdentity(db, { companyId: input.companyId, agentId: input.actor.agentId, runId: input.actor.runId }); + const runIssueId = identity.run.nativeIssueId ?? identity.run.contextSnapshot?.issueId; + if (runIssueId !== input.issueId) throw forbidden("Repository preparation is scoped to the current task"); + userId = identity.run.responsibleUserId; + localTrusted = userId === "local-board"; + if (!userId) throw forbidden("Repository access requires a responsible user"); + } + const source = resolveTaskRepository(parsed.repository, await catalog(input.companyId, userId, localTrusted)); + const requestedRef = parsed.ref ?? "HEAD"; + const receipt = await db.transaction(async tx => { + const [issue] = await tx.select().from(issues).where(and(eq(issues.id, input.issueId), eq(issues.companyId, input.companyId), await issueReadSqlCondition(tx, input.actor))).for("no key update"); + if (!issue) throw notFound("Task not found"); + if (input.actor.type === "agent" && !["standard", "skill_test"].includes(issue.workMode)) throw forbidden("Repository preparation is unavailable in Ask or Plan mode"); + if (!issue.executionWorkspaceId) throw conflict("The task has no admitted workspace yet. Start it once before preparing a repository."); + if (!(await canActorReadExecutionWorkspace(db, input.actor, issue.executionWorkspaceId))) throw notFound("Execution workspace not found"); + // Serialize receipts across tasks without blocking unchanged-ID foreign-key reads. + const [workspace] = await tx.select().from(executionWorkspaces).where(and(eq(executionWorkspaces.id, issue.executionWorkspaceId), eq(executionWorkspaces.companyId, input.companyId))).for("no key update"); + if (!workspace || workspace.status !== "active") throw conflict("Execution workspace is unavailable"); + const existing = await tx.select().from(executionWorkspaceRepositories).where(and(eq(executionWorkspaceRepositories.executionWorkspaceId, workspace.id), or(eq(executionWorkspaceRepositories.repositoryIdentity, source.repositoryIdentity), sql`${executionWorkspaceRepositories.requestKeys} ? ${parsed.requestKey}`))); + for (const row of existing) assertRepositoryRetryMatches(row, source.repositoryIdentity, requestedRef); + if (existing[0]) { + const row = existing[0]; + if (!row.requestKeys.includes(parsed.requestKey)) { + const [updated] = await tx.update(executionWorkspaceRepositories).set({ requestKeys: [...row.requestKeys, parsed.requestKey] }).where(eq(executionWorkspaceRepositories.id, row.id)).returning(); + return updated; + } + return row; + } + const [row] = await tx.insert(executionWorkspaceRepositories).values({ companyId: input.companyId, executionWorkspaceId: workspace.id, requestedByIssueId: input.issueId, ...source, relativePath: taskRepositoryRelativePath(source.repositoryIdentity), requestedRef, requestKey: parsed.requestKey, requestKeys: [parsed.requestKey] }).returning(); + await tx.insert(activityLog).values({ companyId: input.companyId, actorType: input.actor.type === "agent" ? "agent" : "user", actorId: input.actor.agentId ?? input.actor.userId ?? "local-board", action: "workspace.repository.requested", entityType: "execution_workspace", entityId: workspace.id, runId: input.actor.runId ?? null, responsibleUserId: userId, details: { repositoryId: row.id, issueId: input.issueId, applies: "next_normal_admission" } }); + return row; + }); + // No host-only checkout is represented as ready in an already admitted remote session. + return { kind: "requires_next_admission" as const, applies: "next_normal_admission" as const, operationId: receipt.id, repository: receipt, reason: "immutable_admitted_workspace" }; + } + async function list(companyId: string, workspaceId: string) { + return db.select().from(executionWorkspaceRepositories).where(and(eq(executionWorkspaceRepositories.companyId, companyId), eq(executionWorkspaceRepositories.executionWorkspaceId, workspaceId))); + } + /** Logical rows may deliberately name one physical folder. Reuse bytes only + * after validating every published owner's retained privacy and source intent. */ + async function sharedPublicationPin(input: { row: RepositoryRow; root: string; actor: AuthorizationActor; ownership: { repositoryId?: string; pinnedCommit?: string } | null }) { + const candidates = await db.select({ repository: executionWorkspaceRepositories, workspace: executionWorkspaces }) + .from(executionWorkspaceRepositories).innerJoin(executionWorkspaces, eq(executionWorkspaces.id, executionWorkspaceRepositories.executionWorkspaceId)) + .where(and(eq(executionWorkspaceRepositories.companyId, input.row.companyId), eq(executionWorkspaces.companyId, input.row.companyId), + eq(executionWorkspaceRepositories.repositoryIdentity, input.row.repositoryIdentity), eq(executionWorkspaceRepositories.relativePath, input.row.relativePath))); + let pin = input.row.pinnedCommit; + for (const { repository, workspace } of candidates) { + if (repository.id === input.row.id || !workspace.cwd) continue; + const candidatePin = repository.pinnedCommit ?? (input.ownership?.repositoryId === repository.id ? input.ownership.pinnedCommit : null); + // An intent that has not published anything cannot claim another task's checkout. + if (!candidatePin || !/^[a-f0-9]{40,64}$/.test(candidatePin)) continue; + if (await fs.realpath(workspace.cwd).catch(() => null) !== input.root) continue; + assertRepositoryRetryMatches(repository, input.row.repositoryIdentity, input.row.requestedRef); + if (!(await canActorReadExecutionWorkspace(db, input.actor, workspace.id))) throw forbidden("The shared repository retains files from a workspace outside this actor's access"); + if (workspace.projectId && !(await accessService(db).decide({ actor: input.actor, action: "project:read", resource: { type: "project", companyId: input.row.companyId, projectId: workspace.projectId } })).allowed) throw forbidden("The shared repository's source project is outside this actor's access"); + if (pin && pin !== candidatePin) throw conflict("Shared repository publication receipts disagree; repair is required before reuse"); + pin = candidatePin; + } + return pin; + } + async function prepareForAdmission(input: { companyId: string; issueId: string; workspaceId: string; cwd: string; agentId: string; runId: string; responsibleUserId: string | null }) { + const rows = await list(input.companyId, input.workspaceId); + if (!rows.length) return []; + const available = await catalog(input.companyId, input.responsibleUserId, input.responsibleUserId === "local-board"); + const resolveAuth = createGitRemoteAuthProvider(db, input.companyId, { issueId: input.issueId, heartbeatRunId: input.runId, agentId: input.agentId, responsibleUserId: input.responsibleUserId }); + const actor: AuthorizationActor = { type: "agent", source: "agent_jwt", companyId: input.companyId, + agentId: input.agentId, runId: input.runId, + // local-board is a server identity, not a membership record. Preserve the + // agent's own authority rather than promoting it to a board actor. + onBehalfOfUserId: input.responsibleUserId === "local-board" ? null : input.responsibleUserId }; + const root = await fs.realpath(input.cwd); + const repositoryRoot = path.join(root, ".paperclip-repositories"); + await fs.mkdir(repositoryRoot, { recursive: true }); + if ((await fs.lstat(repositoryRoot)).isSymbolicLink() || await fs.realpath(repositoryRoot) !== repositoryRoot) throw conflict("Repository directory escapes the task workspace"); + await ensureManagedRepositoriesIgnored(root); + const prepared: Array<{ id: string; cwd: string; repoUrl: string; relativePath: string; pinnedCommit: string; branchName: string | null }> = []; + for (const row of rows) { + const source = resolveTaskRepository(row.catalogRepositoryId ? { kind: "catalog", id: row.catalogRepositoryId } : { kind: "url", url: row.repoUrl }, available); + if (source.repositoryIdentity !== row.repositoryIdentity || row.relativePath !== taskRepositoryRelativePath(row.repositoryIdentity)) throw conflict("Repository preparation receipt does not match its source"); + const cwd = path.join(root, row.relativePath); + await withDirectoryMergeLock(cwd, async () => { + const existing = await fs.lstat(cwd).catch(() => null); + if (existing && (!existing.isDirectory() || existing.isSymbolicLink() || await fs.realpath(cwd) !== cwd)) throw conflict("Repository checkout path is not a contained directory"); + if (existing) { + const gitDir = path.join(cwd, ".git"); + const metadata = await fs.lstat(gitDir).catch(() => null); + if (!metadata?.isDirectory() || metadata.isSymbolicLink() || await fs.realpath(gitDir) !== gitDir) throw conflict("Repository Git metadata escapes the managed checkout"); + } + // Uncataloged URLs may use anonymous public Git only. Never borrow a user's/company's credential for an arbitrary URL. + const anonymous = buildGitAuthInvocation({ token: "", source: "managed_connection", secretName: null }); + anonymous.env = { ...anonymous.env, GIT_CONFIG_GLOBAL: "/dev/null", GIT_CONFIG_SYSTEM: "/dev/null" }; + await db.update(executionWorkspaceRepositories).set({ state: "preparing", catalogRepositoryId: source.catalogRepositoryId, failureCode: null, updatedAt: new Date() }).where(eq(executionWorkspaceRepositories.id, row.id)); + try { + const ownershipPath = path.join(cwd, ".git", "paperclip-workspace-owner.json"); + const readOwnership = async () => { + const handle = await fs.open(ownershipPath, fsConstants.O_RDONLY | fsConstants.O_NOFOLLOW | fsConstants.O_NONBLOCK).catch(() => null); + if (!handle) return null; + try { + const stat = await handle.stat(); + if (!stat.isFile() || stat.size > 4096) return null; + const bytes = Buffer.alloc(4097); + const { bytesRead } = await handle.read(bytes, 0, bytes.length, 0); + return bytesRead <= 4096 ? JSON.parse(bytes.subarray(0, bytesRead).toString("utf8")) : null; + } catch { return null; } finally { await handle.close(); } + }; + const initialOwnership = await readOwnership(); + const authorizedPin = await sharedPublicationPin({ row, root, actor, ownership: initialOwnership }); + if (existing && !authorizedPin && initialOwnership?.repositoryId !== row.id) { + throw conflict("Repository directory has no matching preparation receipt; repair it before retrying"); + } + const result = await materializeManagedProjectWorkspace(cwd, { + repoUrl: row.repoUrl, + repoRef: authorizedPin ?? (row.requestedRef === "HEAD" ? null : row.requestedRef), + resolveGitAuth: source.catalogRepositoryId ? resolveAuth : async () => anonymous, + beforePublish: async cloneCwd => { + const pinnedCommit = (await execFile("git", ["-C", cloneCwd, "rev-parse", "HEAD"], { timeout: 30_000 })).stdout.trim(); + await fs.writeFile(path.join(cloneCwd, ".git", "paperclip-workspace-owner.json"), JSON.stringify({ version: 1, repositoryId: row.id, pinnedCommit }), { flag: "wx", mode: 0o600 }); + }, + }); + if (result.warning) throw conflict(result.warning); + const git = async (...args: string[]) => (await execFile("git", ["-C", cwd, ...args], { timeout: 30_000 })).stdout.trim(); + const origin = normalizeProjectRepositoryUrl(await git("remote", "get-url", "origin")); + if (origin.url.toLowerCase() !== row.repositoryIdentity) throw conflict("Repository checkout belongs to a different origin"); + const ownership = await readOwnership(); + if (!authorizedPin && (ownership?.repositoryId !== row.id || !/^[a-f0-9]{40,64}$/.test(ownership.pinnedCommit))) throw conflict("Repository publication has no valid preparation receipt"); + // The pre-publication commit survives a crash before the database update. + const pinnedCommit: string = authorizedPin ?? ownership.pinnedCommit; + const branchName = await git("symbolic-ref", "--quiet", "--short", "HEAD").catch(() => null); + await db.update(executionWorkspaceRepositories).set({ state: "ready", pinnedCommit, branchName, failureCode: null, updatedAt: new Date() }).where(eq(executionWorkspaceRepositories.id, row.id)); + prepared.push({ id: row.id, cwd, repoUrl: row.repoUrl, relativePath: row.relativePath, pinnedCommit, branchName }); + } catch (error) { + await db.update(executionWorkspaceRepositories).set({ state: "needs_repair", failureCode: "repository_preparation_failed", updatedAt: new Date() }).where(eq(executionWorkspaceRepositories.id, row.id)); + throw error; + } + }); + } + return prepared; + } + return { request, list, prepareForAdmission }; +} diff --git a/server/src/services/execution-workspaces.ts b/server/src/services/execution-workspaces.ts index c3bbb536196..6f215b982f7 100644 --- a/server/src/services/execution-workspaces.ts +++ b/server/src/services/execution-workspaces.ts @@ -1,3 +1,8 @@ +import { executionWorkspaceRepositoryService } from "./execution-workspace-repositories.js"; +import { accessService } from "./access.js"; +import { forbidden } from "../errors.js"; +import { taskWorkspaceSelectionSchema, type TaskWorkspaceSelection, type TaskWorkspaceIntent } from "@paperclipai/shared"; +import { executionWorkspaceReadSqlCondition, projectReadSqlCondition, issueReadSqlCondition, type AuthorizationActor } from "./authorization.js"; import type { SQL } from "drizzle-orm"; import { createWorkspaceGitInspectionCache } from "./workspace-git-inspection-cache.js"; import { execFile } from "node:child_process"; @@ -10,6 +15,7 @@ import type { Db } from "@paperclipai/db"; import { executionWorkspaces, heartbeatRuns, + nativeRunFinalizations, issueComments, issueWorkProducts, issues, @@ -64,6 +70,10 @@ import { selectConfiguredRuntimeServiceRows, } from "./workspace-runtime-read-model.js"; +export type TaskWorkspaceBindingPatch = Partial>; + type ExecutionWorkspaceRow = typeof executionWorkspaces.$inferSelect; type WorkspaceRuntimeServiceRow = typeof workspaceRuntimeServices.$inferSelect; type RuntimeServiceReadDb = Pick; @@ -1265,7 +1275,7 @@ async function loadEffectiveRuntimeServicesByExecutionWorkspace( } type WorkspaceOverviewPageRow = ExecutionWorkspaceRow & { - projectName: string; + projectName: string | null; projectWorkspaceMetadata: Record | null; }; @@ -1276,6 +1286,115 @@ type WorkspaceOverviewIssueRow = WorkspaceOverviewLinkedIssue & { const inspectGitForDisplay = createWorkspaceGitInspectionCache(inspectGitCloseReadiness); export function executionWorkspaceService(db: Db, opts: ExecutionWorkspaceServiceOptions = {}) { + /** Validate intent without allocating files or changing a task binding. */ + async function validateSelection(input: { companyId: string; actor: AuthorizationActor; selection: TaskWorkspaceSelection }, reader: Db | DbTransaction = db) { + const selection = taskWorkspaceSelectionSchema.parse(input.selection); + if (selection.kind === "existing") { + const [workspace] = await reader.select().from(executionWorkspaces).where(and( + eq(executionWorkspaces.id, selection.workspaceId), eq(executionWorkspaces.companyId, input.companyId), + await executionWorkspaceReadSqlCondition(reader, input.actor), + )); + if (!workspace || !["active", "idle"].includes(workspace.status)) throw notFound("Workspace is unavailable or inaccessible"); + return { executionWorkspaceId: workspace.id, projectWorkspaceId: workspace.projectWorkspaceId, + executionWorkspacePreference: "reuse_existing", executionWorkspaceSettings: { mode: workspace.mode } }; + } + if (selection.kind === "configured_source") { + const [source] = await reader.select({ id: projectWorkspaces.id }).from(projectWorkspaces) + .innerJoin(projects, eq(projects.id, projectWorkspaces.projectId)).where(and( + eq(projectWorkspaces.id, selection.projectWorkspaceId), eq(projectWorkspaces.companyId, input.companyId), + await projectReadSqlCondition(reader, input.actor), + )); + if (!source) throw notFound("Workspace source is unavailable or inaccessible"); + return { executionWorkspaceId: null, projectWorkspaceId: source.id, executionWorkspacePreference: null, + executionWorkspaceSettings: { mode: selection.mode === "shared" ? "shared_workspace" : "isolated_workspace" } }; + } + return { executionWorkspaceId: null, projectWorkspaceId: null, executionWorkspacePreference: null, + executionWorkspaceSettings: { mode: "shared_workspace" } }; + } + + async function inspectTaskWorkspace(companyId: string, issueId: string, actor: AuthorizationActor) { + const [task] = await db.select().from(issues).where(and(eq(issues.id, issueId), eq(issues.companyId, companyId), await issueReadSqlCondition(db, actor))); + if (!task) throw notFound("Task not found"); + const [workspace] = task.executionWorkspaceId ? await db.select().from(executionWorkspaces) + .where(and(eq(executionWorkspaces.id, task.executionWorkspaceId), eq(executionWorkspaces.companyId, companyId), await executionWorkspaceReadSqlCondition(db, actor))) : []; + if (task.executionWorkspaceId && !workspace) throw notFound("Workspace is unavailable or inaccessible"); + return { issueId, bindingRevision: task.workspaceBindingRevision, selection: task.workspaceSelection, + pendingSelection: task.workspacePendingSelection, workspace: workspace ? toExecutionWorkspace(workspace) : null }; + } + + async function selectTaskWorkspace(input: { companyId: string; issueId: string; actor: AuthorizationActor; + selection: TaskWorkspaceSelection; expectedBindingRevision: number; requestKey: string }) { + const decision = await accessService(db).decide({ actor: input.actor, action: "issue:mutate", resource: { type: "issue", companyId: input.companyId, issueId: input.issueId } }); + if (!decision.allowed) throw forbidden("Task workspace modification is not allowed"); + return db.transaction(async tx => { + const [task] = await tx.select().from(issues).where(and(eq(issues.id, input.issueId), eq(issues.companyId, input.companyId), + await issueReadSqlCondition(tx, input.actor))).for("update"); + if (!task) throw notFound("Task not found"); + if (task.workspaceSelection?.request?.key === input.requestKey) { + if (task.workspaceSelection.request.expectedBindingRevision !== input.expectedBindingRevision || + JSON.stringify(taskWorkspaceSelectionSchema.parse(task.workspaceSelection.selection)) !== JSON.stringify(taskWorkspaceSelectionSchema.parse(input.selection))) { + throw conflict("Workspace selection request key was already used for different intent"); + } + return { kind: "applied" as const, bindingRevision: task.workspaceBindingRevision }; + } + if (task.workspacePendingSelection?.requestKey === input.requestKey) { + if (task.workspacePendingSelection.expectedBindingRevision !== input.expectedBindingRevision || + JSON.stringify(taskWorkspaceSelectionSchema.parse(task.workspacePendingSelection.intent.selection)) !== JSON.stringify(taskWorkspaceSelectionSchema.parse(input.selection))) { + throw conflict("Workspace selection request key was already used for different intent"); + } + return { kind: "scheduled" as const, applies: "next_normal_admission" as const, bindingRevision: task.workspaceBindingRevision }; + } + if (task.workspaceBindingRevision !== input.expectedBindingRevision) throw conflict("Task workspace binding changed; inspect it before selecting again"); + await validateSelection(input, tx); + const intent: TaskWorkspaceIntent = { version: 1, request: { key: input.requestKey, expectedBindingRevision: input.expectedBindingRevision }, selection: input.selection, source: "explicit" }; + // Root changes always wait for a new admission. The current provider and + // any export-only recovery retain their immutable roots and input evidence. + await tx.update(issues).set({ workspacePendingSelection: { version: 1, requestKey: input.requestKey, + expectedBindingRevision: task.workspaceBindingRevision, intent }, updatedAt: new Date() }).where(eq(issues.id, task.id)); + return { kind: "scheduled" as const, applies: "next_normal_admission" as const, bindingRevision: task.workspaceBindingRevision }; + }); + } + + async function applyPendingTaskWorkspaceSelection(input: { companyId: string; issueId: string; runId: string; actor: AuthorizationActor }) { + return db.transaction(async tx => { + const [task] = await tx.select().from(issues).where(and(eq(issues.id, input.issueId), eq(issues.companyId, input.companyId))).for("update"); + if (!task?.workspacePendingSelection) return false; + const pending = task.workspacePendingSelection; + if (pending.expectedBindingRevision !== task.workspaceBindingRevision) throw conflict("Pending workspace selection is stale"); + const [holder] = await tx.select({ id: heartbeatRuns.id }).from(heartbeatRuns).where(and( + eq(heartbeatRuns.companyId, input.companyId), ne(heartbeatRuns.id, input.runId), + sql`coalesce(${heartbeatRuns.contextSnapshot}->>'issueId', ${heartbeatRuns.contextSnapshot}->>'taskId') = ${input.issueId}`, + eq(heartbeatRuns.status, "running"), + )).limit(1); + if (holder) throw conflict("Previous task run must finish persistence before changing its workspace"); + const [unsettled] = await tx.select({ runId: nativeRunFinalizations.runId }).from(nativeRunFinalizations).where(and( + eq(nativeRunFinalizations.companyId, input.companyId), eq(nativeRunFinalizations.issueId, input.issueId), + ne(nativeRunFinalizations.runId, input.runId), ne(nativeRunFinalizations.phase, "committed"), + )).limit(1); + if (unsettled) throw conflict("Previous task run still owns workspace recovery; finish recovery before changing its workspace"); + const patch = await validateSelection({ ...input, selection: pending.intent.selection }, tx); + await tx.update(issues).set({ ...patch, workspaceSelection: pending.intent, workspacePendingSelection: null, + workspaceBindingRevision: task.workspaceBindingRevision + 1, updatedAt: new Date() }).where(eq(issues.id, task.id)); + return true; + }); + } + + async function bindTaskWorkspace(companyId: string, issueId: string, workspaceId: string, patch: TaskWorkspaceBindingPatch = {}) { + return db.transaction(async tx => { + // Serialize binding and metadata writes without blocking unchanged-ID foreign-key reads. + const [task] = await tx.select().from(issues).where(and(eq(issues.id, issueId), eq(issues.companyId, companyId))).for("no key update"); + const [workspace] = await tx.select().from(executionWorkspaces).where(and(eq(executionWorkspaces.id, workspaceId), eq(executionWorkspaces.companyId, companyId))).for("no key update"); + if (!task || !workspace) throw notFound("Task workspace unavailable"); + // Retain confidentiality even after a task is moved to another root. + await tx.update(executionWorkspaces).set({ metadata: { ...workspace.metadata, + _issuePrivacySources: { ...(workspace.metadata?._issuePrivacySources as Record ?? {}), [task.id]: true } } }).where(eq(executionWorkspaces.id, workspaceId)); + const [bound] = await tx.update(issues).set({ ...patch, executionWorkspaceId: workspaceId, + workspaceBindingRevision: task.workspaceBindingRevision + (task.executionWorkspaceId === workspaceId ? 0 : 1), updatedAt: new Date() }) + .where(eq(issues.id, issueId)).returning(); + return bound; + }); + } + const inspectDisplay = opts.inspectGitCloseReadiness ? createWorkspaceGitInspectionCache(opts.inspectGitCloseReadiness) : inspectGitForDisplay; @@ -1723,7 +1842,7 @@ export function executionWorkspaceService(db: Db, opts: ExecutionWorkspaceServic db .select({ executionWorkspacePolicy: projects.executionWorkspacePolicy }) .from(projects) - .where(and(eq(projects.companyId, workspace.companyId), eq(projects.id, workspace.projectId))) + .where(and(eq(projects.companyId, workspace.companyId), workspace.projectId ? eq(projects.id, workspace.projectId) : sql`false`)) .then((rows) => parseProjectExecutionWorkspacePolicy(rows[0]?.executionWorkspacePolicy)), ]); const config = readExecutionWorkspaceConfig((workspace.metadata as Record | null) ?? null); @@ -1869,6 +1988,10 @@ export function executionWorkspaceService(db: Db, opts: ExecutionWorkspaceServic } return { + requestTaskRepository: executionWorkspaceRepositoryService(db).request, + listTaskRepositories: executionWorkspaceRepositoryService(db).list, + prepareTaskRepositoriesForAdmission: executionWorkspaceRepositoryService(db).prepareForAdmission, + validateSelection, inspectTaskWorkspace, selectTaskWorkspace, applyPendingTaskWorkspaceSelection, bindTaskWorkspace, listOverview: async ( companyId: string, filters: WorkspaceOverviewQuery, @@ -1876,13 +1999,13 @@ export function executionWorkspaceService(db: Db, opts: ExecutionWorkspaceServic ): Promise => { const conditions = buildOverviewConditions(companyId, filters); if (readCondition) conditions.push(readCondition); - const whereClause = and(...conditions); + const whereClause = and(...conditions, sql`(${executionWorkspaces.projectId} IS NULL OR ${projects.id} IS NOT NULL)`); const [totalRow, rows] = await Promise.all([ db .select({ count: sql`count(*)::int` }) .from(executionWorkspaces) - .innerJoin( + .leftJoin( projects, and( eq(projects.id, executionWorkspaces.projectId), @@ -1921,7 +2044,7 @@ export function executionWorkspaceService(db: Db, opts: ExecutionWorkspaceServic projectWorkspaceMetadata: projectWorkspaces.metadata, }) .from(executionWorkspaces) - .innerJoin( + .leftJoin( projects, and( eq(projects.id, executionWorkspaces.projectId), @@ -2042,7 +2165,7 @@ export function executionWorkspaceService(db: Db, opts: ExecutionWorkspaceServic workspaceId: row.id, workspaceName: row.name, projectId: row.projectId, - projectUrlKey: deriveProjectUrlKey(row.projectName, row.projectId), + projectUrlKey: row.projectId && row.projectName ? deriveProjectUrlKey(row.projectName, row.projectId) : null, projectName: row.projectName, mode: row.mode as WorkspaceOverviewItem["mode"], strategyType: row.strategyType as WorkspaceOverviewItem["strategyType"], @@ -2919,29 +3042,31 @@ export function executionWorkspaceService(db: Db, opts: ExecutionWorkspaceServic workspaceId: string; issue: { id: string; companyId: string; projectId: string | null }; actor: { agentId: string | null; actorType: string }; + authorizationActor: AuthorizationActor; }): Promise => { const { issue, actor } = input; - // Bind the workspace to the issue company and project. A null project on - // the issue must match a null project on the row (IS NOT DISTINCT FROM). - const projectIdCondition = - issue.projectId == null - ? sql`${executionWorkspaces.projectId} IS NULL` - : eq(executionWorkspaces.projectId, issue.projectId); - return db.transaction(async (tx): Promise => { await acquireExecutionWorkspaceLifecycleLock(tx, input.workspaceId); + // Organizational project assignment does not identify the source of files. + // Reopen only the task's persisted binding, with current source access. + const [boundTask] = await tx.select({ id: issues.id }).from(issues).where(and( + eq(issues.id, issue.id), eq(issues.companyId, issue.companyId), + eq(issues.executionWorkspaceId, input.workspaceId), + await issueReadSqlCondition(tx, input.authorizationActor), + )); + if (!boundTask) return { ok: false, code: "not_reopenable", message: "Execution workspace is not reopenable" }; const row = await tx .select() .from(executionWorkspaces) .where(and( eq(executionWorkspaces.id, input.workspaceId), eq(executionWorkspaces.companyId, issue.companyId), - projectIdCondition, + await executionWorkspaceReadSqlCondition(tx, input.authorizationActor), eq(executionWorkspaces.mode, "isolated_workspace"), )) .then((rows) => rows[0] ?? null); if (!row) { - // Wrong company, wrong project, wrong mode, or missing. Fail closed and + // Wrong company, inaccessible source, wrong mode, or missing. Fail closed and // disclose no workspace detail. return { ok: false, code: "not_reopenable", message: "Execution workspace is not reopenable" }; } @@ -2983,7 +3108,7 @@ export function executionWorkspaceService(db: Db, opts: ExecutionWorkspaceServic db .select({ executionWorkspacePolicy: projects.executionWorkspacePolicy }) .from(projects) - .where(and(eq(projects.companyId, row.companyId), eq(projects.id, row.projectId))) + .where(and(eq(projects.companyId, row.companyId), row.projectId ? eq(projects.id, row.projectId) : sql`false`)) .then((rows) => parseProjectExecutionWorkspacePolicy(rows[0]?.executionWorkspacePolicy)), ]); // Resolve the base checkout that the rebuild spawns git in. A diff --git a/server/src/services/heartbeat.ts b/server/src/services/heartbeat.ts index 615dd950638..0dec28b9d4f 100644 --- a/server/src/services/heartbeat.ts +++ b/server/src/services/heartbeat.ts @@ -10,6 +10,8 @@ import { formatIssueIdentifierLink, parseNativeSessionGoalControl, } from "./heartbeat/scheduling.js"; +import { canApplyTaskWorkspaceSelectionAtAdmission, taskWorkspaceRuntimeSelectionEnabled } from "./execution-workspace-policy.js"; +import { canActorReadExecutionWorkspace } from "./authorization.js"; import { agentExecutionsHaveStopped } from "./agent-execution-stop.js"; import { cancelHeartbeatNativeRun, @@ -381,6 +383,7 @@ import { inArray, isNull, notInArray, + ne, or, sql, } from "drizzle-orm"; @@ -598,6 +601,7 @@ import { } from "./plan-review-context.js"; import { executionWorkspaceService, + type TaskWorkspaceBindingPatch, } from "./execution-workspaces.js"; import { workspaceOperationService, @@ -2475,6 +2479,15 @@ export function heartbeatService( const taskKey = deriveTaskKeyWithHeartbeatFallback(context, null); const sessionCodec = getAdapterSessionCodec(agent.adapterType); const issueId = readNonEmptyString(context.issueId); + if (issueId && canApplyTaskWorkspaceSelectionAtAdmission({ admittedInput: parseObject(run.runnerProfileJson).nativeExecutionInput, restarting: Boolean(runOptions.nativeRestartRecovery), hasLeaseOwner: Boolean(runOptions.nativeLeaseOwner) })) { + const changed = await executionWorkspacesSvc.applyPendingTaskWorkspaceSelection({ companyId: agent.companyId, + issueId, runId: run.id, actor: { type: "agent", agentId: agent.id, companyId: agent.companyId, source: "agent_key" } }); + if (changed) { + delete context.resumeSessionParams; + delete context.resumeSessionDisplayId; + context.workspaceSelectionChanged = true; + } + } let issueContext = issueId ? await getIssueExecutionContext(agent.companyId, issueId) : null; @@ -2626,20 +2639,31 @@ export function heartbeatService( const defaultIsolatedWorkspacesEnabled = isolatedWorkspacesEnabled && experimentalInstanceSettings.enableIsolatedWorkspacesByDefault; + const [taskWorkspaceIntentRow] = issueId ? await db.select({ intent: issues.workspaceSelection }).from(issues) + .where(and(eq(issues.id, issueId), eq(issues.companyId, agent.companyId))) : []; + // Typed selections and durable bindings are authoritative even when the + // legacy isolated-workspace UI is disabled. Never downgrade isolation. + const runtimeWorkspaceSelectionEnabled = taskWorkspaceRuntimeSelectionEnabled({ legacyUiEnabled: isolatedWorkspacesEnabled, hasTypedSelection: Boolean(taskWorkspaceIntentRow?.intent), hasBinding: Boolean(issueContext?.executionWorkspaceId) }); const parsedIssueExecutionWorkspaceSettings = parseIssueExecutionWorkspaceSettings( issueContext?.executionWorkspaceSettings, ); - const issueExecutionWorkspaceSettings = isolatedWorkspacesEnabled + const issueExecutionWorkspaceSettings = runtimeWorkspaceSelectionEnabled ? parsedIssueExecutionWorkspaceSettings : null; const environmentExecutionWorkspaceSettings = selectEnvironmentExecutionWorkspaceSettings( parsedIssueExecutionWorkspaceSettings, - isolatedWorkspacesEnabled, + runtimeWorkspaceSelectionEnabled, ); const contextProjectId = readNonEmptyString(context.projectId); - const executionProjectId = issueContext?.projectId ?? contextProjectId; + const boundSourceWorkspace = issueContext?.executionWorkspaceId + ? await executionWorkspacesSvc.getById(issueContext.executionWorkspaceId) : null; + const [selectedWorkspaceSource] = !boundSourceWorkspace && issueContext?.projectWorkspaceId + ? await db.select({ projectId: projectWorkspaces.projectId }).from(projectWorkspaces) + .where(and(eq(projectWorkspaces.id, issueContext.projectWorkspaceId), eq(projectWorkspaces.companyId, agent.companyId))) : []; + const executionProjectId = boundSourceWorkspace ? boundSourceWorkspace.projectId + : taskWorkspaceIntentRow?.intent?.selection.kind === "task_directory" ? null : selectedWorkspaceSource?.projectId ?? issueContext?.projectId ?? contextProjectId; const projectContext = executionProjectId ? await db .select({ @@ -2893,7 +2917,7 @@ export function heartbeatService( delete context.paperclipContinuationSummary; } const taskSessionDecodedParams = normalizeSessionParams( - sessionCodec.deserialize(taskSession?.sessionParamsJson ?? null), + sessionCodec.deserialize(context.workspaceSelectionChanged ? null : taskSession?.sessionParamsJson ?? null), ); const explicitResumeSessionParams = normalizeResumeParamsForAdapter( agent.adapterType, @@ -3296,6 +3320,11 @@ export function heartbeatService( workspaceReuseRequest.existingExecutionWorkspaceAvailable ? existingExecutionWorkspace : null; + if (reusableExistingExecutionWorkspace && !nativeRecoveryExecutionWorkspaceId && !(await canActorReadExecutionWorkspace(db, + { type: "agent", agentId: agent.id, companyId: agent.companyId, source: "agent_jwt", runId: run.id, + onBehalfOfUserId: responsibleUserId === "local-board" ? null : responsibleUserId }, reusableExistingExecutionWorkspace.id))) { + throw new Error("Task workspace access is no longer available"); + } const requestedReusableExecutionWorkspaceConfig = reusableExistingExecutionWorkspace?.config ?? null; const localEnvironment = await environmentsSvc.ensureLocalEnvironment( @@ -3416,6 +3445,8 @@ export function heartbeatService( companyId: agent.companyId, agentId: agent.id, issueId, + admittedCwd: persistedNativeExecutionInput && persistedNativeExecutionInput.schema !== "paperclip.native-execution-input.v6" + ? persistedNativeExecutionInput.workspace.cwd : null, }); const nativeChatExpectedCwd = nativeChatWorkspaceScope ? nativeChatWorkspaceCwd( @@ -3455,6 +3486,19 @@ export function heartbeatService( }, ); } + if (!nativeRecoveryExecutionWorkspaceId && reusableExistingExecutionWorkspace && + effectiveExecutionWorkspaceMode === "shared_workspace" && + selectedEnvironmentForConfig?.driver !== "local" && selectedEnvironmentForConfig?.driver !== "ssh") { + const [remoteSharedHolder] = await db.select({ id: heartbeatRuns.id }).from(heartbeatRuns).where(and( + eq(heartbeatRuns.companyId, agent.companyId), ne(heartbeatRuns.id, run.id), eq(heartbeatRuns.status, "running"), + sql`exists (select 1 from ${issues} where ${issues.companyId} = ${agent.companyId} + and ${issues.executionWorkspaceId} = ${reusableExistingExecutionWorkspace.id} + and (${issues.executionRunId} = ${heartbeatRuns.id} or ${issues.id}::text = coalesce(${heartbeatRuns.contextSnapshot}->>'issueId', ${heartbeatRuns.contextSnapshot}->>'taskId')))`, + )).limit(1); + if (remoteSharedHolder) throw new ConfigurationIncompleteFailure("This environment cannot place concurrent agents in one physical shared folder. Use local/SSH sharing or isolated task workspaces.", { + configurationIncomplete: { reason: "shared_realization_unsupported", issueId }, + }); + } const sharedWorkspaceConcurrency = resolveSharedWorkspaceConcurrency({ projectPolicy: projectExecutionWorkspacePolicy, issueSettings: issueExecutionWorkspaceSettings, @@ -3473,11 +3517,17 @@ export function heartbeatService( projectWorkspaceId: issueRef.projectWorkspaceId, excludeIssueId: issueRef.id, excludeRunId: run.id, - honorIsolatedWorkspaceModes: isolatedWorkspacesEnabled, + honorIsolatedWorkspaceModes: runtimeWorkspaceSelectionEnabled, }); if (workspaceHolder) { const environmentDriver = selectedEnvironmentForConfig?.driver ?? null; + if (!nativeRecoveryExecutionWorkspaceId && sharedWorkspaceConcurrency === "allow" && + (executionForcedToKubernetes || (environmentDriver !== "local" && environmentDriver !== "ssh"))) { + throw new ConfigurationIncompleteFailure("This environment cannot place concurrent agents in one physical shared folder. Use local/SSH sharing or isolated task workspaces.", { + configurationIncomplete: { reason: "shared_realization_unsupported", issueId }, + }); + } const shouldSerialize = sharedWorkspaceConcurrency !== "allow" && (executionForcedToKubernetes || @@ -3540,6 +3590,12 @@ export function heartbeatService( ); } } + if (taskWorkspaceIntentRow?.intent && !nativeRecoveryExecutionWorkspaceId) { + await executionWorkspacesSvc.validateSelection({ companyId: agent.companyId, + actor: { type: "agent", agentId: agent.id, companyId: agent.companyId, runId: run.id, source: "agent_jwt", onBehalfOfUserId: responsibleUserId === "local-board" ? null : responsibleUserId }, + selection: taskWorkspaceIntentRow.intent.selection }); + } + const explicitTaskDirectory = taskWorkspaceIntentRow?.intent?.selection.kind === "task_directory"; const useIsolatedTaskDirectory = issueRef !== null && shouldUseIsolatedTaskDirectory({ trustPreset: trustPreset.kind, environmentDriver: selectedEnvironmentForConfig?.driver ?? null, @@ -3567,7 +3623,7 @@ export function heartbeatService( ...(requestedAiBinding?.mode === "router" ? parseObject(parseObject(context.aiRouterSelection).runtimeConfig) : {}), // The base below is already task-owned. Keep directory transport while // preserving isolated mode and the mandatory sandbox preflight. - ...(useIsolatedTaskDirectory ? { workspaceStrategy: { type: "project_primary" } } : {}), + ...((useIsolatedTaskDirectory || explicitTaskDirectory) ? { workspaceStrategy: { type: "project_primary" } } : {}), }; const configSnapshot = buildExecutionWorkspaceConfigSnapshot( mergedConfig, @@ -3845,7 +3901,7 @@ export function heartbeatService( acceptedPlanContinuationWake && !acceptedPlanWakeRoutingDecision && !agentIdentity, }); const resetTaskSession = - shouldResetTaskSessionForWake(context) || sessionConfigFreshness.reset; + context.workspaceSelectionChanged === true || shouldResetTaskSessionForWake(context) || sessionConfigFreshness.reset; const sessionResetReason = sessionConfigFreshness.reasons.join("; ") || null; const taskSessionForRun = resetTaskSession ? null : taskSession; @@ -3881,7 +3937,7 @@ export function heartbeatService( } = await resolveWorkspaceAfterLowTrustPreflight({ db, trustPreset, - isolatedWorkspacesEnabled, + isolatedWorkspacesEnabled: runtimeWorkspaceSelectionEnabled, effectiveExecutionWorkspaceMode, issue: issueRef ? { @@ -3916,7 +3972,6 @@ export function heartbeatService( }); if (reusableExistingExecutionWorkspace && ( reusableExistingExecutionWorkspace.companyId !== agent.companyId || - reusableExistingExecutionWorkspace.projectId !== issueRef.projectId || reusableExistingExecutionWorkspace.sourceIssueId !== issueRef.id || reusableExistingExecutionWorkspace.mode !== "isolated_workspace" || reusableExistingExecutionWorkspace.strategyType !== "project_primary" || @@ -3931,7 +3986,7 @@ export function heartbeatService( anchorWorkspace: { cwd, source: "task_session", - projectId: issueRef.projectId, + projectId: reusableExistingExecutionWorkspace?.projectId ?? null, workspaceId: null, repoUrl: null, repoRef: null, @@ -3983,7 +4038,14 @@ export function heartbeatService( issueId, runId: run.id, }) - : undefined, + : requestedShouldReuseExisting && reusableExistingExecutionWorkspace?.cwd ? { + cwd: reusableExistingExecutionWorkspace.cwd, source: "task_session" as const, + projectId: reusableExistingExecutionWorkspace.projectId, + workspaceId: reusableExistingExecutionWorkspace.projectWorkspaceId, + repoUrl: reusableExistingExecutionWorkspace.repoUrl, repoRef: reusableExistingExecutionWorkspace.baseRef, + localPathOnlyWorkspace: reusableExistingExecutionWorkspace.strategyType === "project_primary" && !reusableExistingExecutionWorkspace.repoUrl && !reusableExistingExecutionWorkspace.branchName, + workspaceHints: [], warnings: [], baseCwdFallback: false, materializationFailures: [], + } : undefined, // Thread the selected environment driver so run-workspace resolution can tell a local // target from a remote one, and a confined sandbox target from an unconfined remote // target. A remote run resolves referenced projects only for the confined sandbox @@ -4252,7 +4314,7 @@ export function heartbeatService( }, ); const resolvedProjectId = - executionWorkspace.projectId ?? + reusableExistingExecutionWorkspace ? reusableExistingExecutionWorkspace.projectId : executionWorkspace.projectId ?? issueRef?.projectId ?? executionProjectId ?? null; @@ -4290,8 +4352,8 @@ export function heartbeatService( issueRef?.executionWorkspacePreference === "reuse_existing" || requestedExecutionWorkspaceMode === "isolated_workspace" || requestedExecutionWorkspaceMode === "operator_branch" || - warmReusableExecutionWorkspace || nativeSharedWorkspace; - const nextIssuePatch: Record = {}; + warmReusableExecutionWorkspace || nativeSharedWorkspace || Boolean(issueId); + const nextIssuePatch: TaskWorkspaceBindingPatch = {}; if (issueExecutionWorkspaceIdForRun !== workspace.id) { nextIssuePatch.executionWorkspaceId = workspace.id; } @@ -4313,14 +4375,7 @@ export function heartbeatService( }; } if (Object.keys(nextIssuePatch).length > 0) { - await issuesSvc.update( - issueId, - { ...nextIssuePatch, companyGuard: agent.companyId }, - db, - undefined, - undefined, - { bindRuntimeSharedWorkspace: (warmReusableExecutionWorkspace || nativeSharedWorkspace) && workspace.mode === "shared_workspace" }, - ); + await executionWorkspacesSvc.bindTaskWorkspace(agent.companyId, issueId, workspace.id, nextIssuePatch); issueExecutionWorkspaceIdForRun = workspace.id; issueProjectWorkspaceIdForRun = resolvedProjectWorkspaceId ?? issueProjectWorkspaceIdForRun; @@ -4427,7 +4482,7 @@ export function heartbeatService( ), }, ) - : resolvedProjectId + : !isDotRun && !persistedNativeExecutionInput && (resolvedProjectId || issueRef) ? await executionWorkspacesSvc.create({ companyId: agent.companyId, projectId: resolvedProjectId, @@ -4549,7 +4604,9 @@ export function heartbeatService( } await bindIssueToPersistedExecutionWorkspace(persistedExecutionWorkspace); const projectRepositoryPaths: string[] = []; - if (executionWorkspace.projectId && resolvedWorkspace.source === "project_primary" && !resolvedWorkspace.baseCwdFallback) { + if (executionWorkspace.projectId + && (resolvedWorkspace.source === "project_primary" || Boolean(reusableExistingExecutionWorkspace?.projectWorkspaceId)) + && !resolvedWorkspace.baseCwdFallback && !persistedNativeExecutionInput) { const repositoryRows = await db.select().from(projectWorkspaces).where(and( eq(projectWorkspaces.companyId, agent.companyId), eq(projectWorkspaces.projectId, executionWorkspace.projectId), @@ -4563,10 +4620,21 @@ export function heartbeatService( const paths = new Map(repositories.map((repo) => [repo.workspaceId, repo.cwd])); projectRepositoryPaths.push(...repositories.map((repo) => path.relative(executionWorkspace.cwd, repo.cwd))); if (resolvedWorkspace.workspaceId) paths.set(resolvedWorkspace.workspaceId, executionWorkspace.cwd); - resolvedWorkspace.workspaceHints = resolvedWorkspace.workspaceHints.map((hint) => ({ - ...hint, cwd: paths.get(hint.workspaceId) ?? hint.cwd, + // Reused roots may have no transient hints. Rebuild from the currently + // authorized source rows and the paths actually prepared for this admission. + resolvedWorkspace.workspaceHints = repositoryRows.map((source) => ({ + workspaceId: source.id, + cwd: paths.get(source.id) ?? readNonEmptyString(source.cwd), + repoUrl: readNonEmptyString(source.repoUrl), + repoRef: readNonEmptyString(source.repoRef), })); } + if (persistedExecutionWorkspace && issueId && !persistedNativeExecutionInput) { + const taskRepositories = await executionWorkspacesSvc.prepareTaskRepositoriesForAdmission({ companyId: agent.companyId, + issueId, workspaceId: persistedExecutionWorkspace.id, cwd: executionWorkspace.cwd, + agentId: agent.id, runId: run.id, responsibleUserId }); + projectRepositoryPaths.push(...taskRepositories.map(repository => repository.relativePath)); + } if (persistedExecutionWorkspace) { context.executionWorkspaceId = persistedExecutionWorkspace.id; await db @@ -4613,7 +4681,7 @@ export function heartbeatService( await controllerLease.assertOwned(); nativeRunnerPreparationSpans.push({ name: "environment.acquire", - parentName: "task.run", + parentName: "task.provider_session", startedAtMs: environmentAcquireStartedAtMs, endedAtMs: Date.now(), attributes: { adapter: agent.adapterType }, @@ -4621,7 +4689,7 @@ export function heartbeatService( } catch (error) { nativeRunnerPreparationSpans.push({ name: "environment.acquire", - parentName: "task.run", + parentName: "task.provider_session", startedAtMs: environmentAcquireStartedAtMs, endedAtMs: Date.now(), outcome: "failed", @@ -4699,7 +4767,7 @@ export function heartbeatService( }); nativeRunnerPreparationSpans.push({ name: "environment.workspace.realize", - parentName: "task.run", + parentName: "task.provider_session", startedAtMs: environmentRealizeStartedAtMs, endedAtMs: Date.now(), attributes: { driver: selectedEnvironment.driver }, @@ -4707,7 +4775,7 @@ export function heartbeatService( } catch (error) { nativeRunnerPreparationSpans.push({ name: "environment.workspace.realize", - parentName: "task.run", + parentName: "task.provider_session", startedAtMs: environmentRealizeStartedAtMs, endedAtMs: Date.now(), outcome: "failed", @@ -6463,6 +6531,16 @@ export function heartbeatService( restartRecovery: runOptions.nativeRestartRecovery, sameRunRecovery: Boolean(runOptions.nativeLeaseOwner), resourceDisposition: providerResourceDispositionForRun, + onCheckpoint: async (metrics) => { + await appendRunEvent(run, { eventType: "lifecycle", stream: "system", level: "info", + message: "workspace_checkpoint", payload: { phase: "workspace_checkpoint", ...metrics } }); + }, + onPhase: async (name, work) => { + const started = Date.now(); + try { return await work(); } + finally { await appendRunEvent(run, { eventType: "lifecycle", stream: "system", level: "info", + message: name, payload: { phase: name, durationMs: Date.now() - started } }); } + }, }); } else { const legacyWarmLifecycle = @@ -6556,192 +6634,186 @@ export function heartbeatService( const recordWorkspaceFinalize = async ( status: "succeeded" | "failed", metadata?: Record, + restore?: () => Promise, ) => { if (adapterFinalizeOutcome) return; - let finalizeBranchMetadata: Record | null = null; - let finalizeBranchRepairMetadata: Record | null = - null; - if (status === "succeeded") { - const branchInspection = await inspectFinalizeWorkspaceBranch(); - if (branchInspection) { - let inspection = branchInspection.inspection; - const initialManagedGitWorktreeBranch = - formatManagedGitWorktreeBranchInspection(inspection); - if ( - !inspection.valid && - inspection.reasonCode === "branch_mismatch" && - inspection.repoRoot - ) { - let repairedExpectedBranchName = inspection.expectedBranchName; - try { - const coherence = await ensureGitWorktreeBranchCoherent({ - db, - repoRoot: inspection.repoRoot, - worktreePath: inspection.worktreePath, - expectedBranchName: inspection.expectedBranchName, - actualBranchName: inspection.actualBranchName, - sourceIssue: issueRef - ? { - id: issueRef.id, - identifier: issueRef.identifier, - title: issueRef.title, - workMode: issueRef.workMode, - } - : null, - executionWorkspaceId: branchInspection.workspaceRecord.id, - heartbeatRunId: run.id, - enableWorkspaceBranchReconcileForward: - resolvedInstanceSettings.experimental - .enableWorkspaceBranchReconcileForward, - enableWorkspaceDirtyQuarantineRepair: - resolvedInstanceSettings.experimental - .enableWorkspaceDirtyQuarantineRepair, - persistForwardReconcile: false, - reconcileOperationPhase: "workspace_finalize", - recorder: workspaceOperationRecorder, - }); + await workspaceOperationRecorder.recordOperation({ + phase: "workspace_finalize", cwd: executionWorkspace.cwd, + metadata: { adapterType: agent.adapterType, executionTargetKind: executionTarget?.kind ?? "local", + ...(restore ? { owningService: "native_workspace_finalizer" } : {}), ...metadata }, + run: async () => { + await restore?.(); + let finalizeBranchMetadata: Record | null = null; + let finalizeBranchRepairMetadata: Record | null = + null; + if (status === "succeeded") { + const branchInspection = await inspectFinalizeWorkspaceBranch(); + if (branchInspection) { + let inspection = branchInspection.inspection; + const initialManagedGitWorktreeBranch = + formatManagedGitWorktreeBranchInspection(inspection); if ( - coherence.branchName && - coherence.branchName !== - branchInspection.workspaceRecord.branchName + !inspection.valid && + inspection.reasonCode === "branch_mismatch" && + inspection.repoRoot ) { - repairedExpectedBranchName = coherence.branchName; - executionWorkspace.branchName = coherence.branchName; - executionWorkspace.warnings.push(...coherence.warnings); - } - } catch (repairErr) { - const workspaceValidationFailure = - isWorkspaceValidationFailure(repairErr) ? repairErr : null; - finalizeBranchMetadata = { - executionWorkspaceId: branchInspection.workspaceRecord.id, - ...initialManagedGitWorktreeBranch, - }; - finalizeBranchRepairMetadata = { - attempted: true, - succeeded: false, - initial: initialManagedGitWorktreeBranch, - reason: - repairErr instanceof Error - ? repairErr.message - : String(repairErr), - }; - await workspaceOperationRecorder.recordOperation({ - phase: "workspace_finalize", - cwd: executionWorkspace.cwd, - metadata: { - adapterType: agent.adapterType, - executionTargetKind: executionTarget?.kind ?? "local", - ...metadata, - managedGitWorktreeBranch: finalizeBranchMetadata, - managedGitWorktreeBranchRepair: - finalizeBranchRepairMetadata, - ...(workspaceValidationFailure?.resultJson - ? { - workspaceValidation: - workspaceValidationFailure.resultJson - .workspaceValidation ?? - workspaceValidationFailure.resultJson, - } - : {}), - }, - run: async () => ({ - status: "failed", - stderr: `Managed git worktree branch check failed: ${repairErr instanceof Error ? repairErr.message : String(repairErr)}\n`, - }), - }); - adapterFinalizeOutcome = "failed"; - throw repairErr; - } - - const repairedInspection = - await inspectManagedGitWorktreeBranch({ - worktreePath: inspection.worktreePath, - expectedBranchName: repairedExpectedBranchName, - repoRoot: inspection.repoRoot, - }); - finalizeBranchRepairMetadata = { - attempted: true, - succeeded: repairedInspection.valid, - initial: initialManagedGitWorktreeBranch, - repaired: - formatManagedGitWorktreeBranchInspection( - repairedInspection, - ), - }; - inspection = repairedInspection; - } - - const managedGitWorktreeBranch = - formatManagedGitWorktreeBranchInspection(inspection); - finalizeBranchMetadata = { - executionWorkspaceId: branchInspection.workspaceRecord.id, - ...managedGitWorktreeBranch, - }; - if (!inspection.valid) { - const workspaceValidationFingerprint = - fingerprintFinalizeWorkspaceBranchValidation({ - issueId: issueRef?.id ?? null, - executionWorkspaceId: branchInspection.workspaceRecord.id, - inspection: managedGitWorktreeBranch, - }); - await workspaceOperationRecorder.recordOperation({ - phase: "workspace_finalize", - cwd: executionWorkspace.cwd, - metadata: { - adapterType: agent.adapterType, - executionTargetKind: executionTarget?.kind ?? "local", - ...metadata, - managedGitWorktreeBranch: finalizeBranchMetadata, - ...(finalizeBranchRepairMetadata - ? { + let repairedExpectedBranchName = inspection.expectedBranchName; + try { + const coherence = await ensureGitWorktreeBranchCoherent({ + db, + repoRoot: inspection.repoRoot, + worktreePath: inspection.worktreePath, + expectedBranchName: inspection.expectedBranchName, + actualBranchName: inspection.actualBranchName, + sourceIssue: issueRef + ? { + id: issueRef.id, + identifier: issueRef.identifier, + title: issueRef.title, + workMode: issueRef.workMode, + } + : null, + executionWorkspaceId: branchInspection.workspaceRecord.id, + heartbeatRunId: run.id, + enableWorkspaceBranchReconcileForward: + resolvedInstanceSettings.experimental + .enableWorkspaceBranchReconcileForward, + enableWorkspaceDirtyQuarantineRepair: + resolvedInstanceSettings.experimental + .enableWorkspaceDirtyQuarantineRepair, + persistForwardReconcile: false, + reconcileOperationPhase: "workspace_finalize", + recorder: workspaceOperationRecorder, + }); + if ( + coherence.branchName && + coherence.branchName !== + branchInspection.workspaceRecord.branchName + ) { + repairedExpectedBranchName = coherence.branchName; + executionWorkspace.branchName = coherence.branchName; + executionWorkspace.warnings.push(...coherence.warnings); + } + } catch (repairErr) { + const workspaceValidationFailure = + isWorkspaceValidationFailure(repairErr) ? repairErr : null; + finalizeBranchMetadata = { + executionWorkspaceId: branchInspection.workspaceRecord.id, + ...initialManagedGitWorktreeBranch, + }; + finalizeBranchRepairMetadata = { + attempted: true, + succeeded: false, + initial: initialManagedGitWorktreeBranch, + reason: + repairErr instanceof Error + ? repairErr.message + : String(repairErr), + }; + await workspaceOperationRecorder.recordOperation({ + phase: "workspace_finalize", + cwd: executionWorkspace.cwd, + metadata: { + adapterType: agent.adapterType, + executionTargetKind: executionTarget?.kind ?? "local", + ...metadata, + managedGitWorktreeBranch: finalizeBranchMetadata, managedGitWorktreeBranchRepair: finalizeBranchRepairMetadata, - } - : {}), - }, - run: async () => ({ - status: "failed", - stderr: `Managed git worktree branch check failed: ${inspection.reason ?? "unknown branch mismatch"}\n`, - }), - }); - adapterFinalizeOutcome = "failed"; - throw new WorkspaceValidationFailure( - `Execution workspace ${branchInspection.workspaceRecord.id} expected git worktree branch "${inspection.expectedBranchName}" at "${inspection.worktreePath}", but ${inspection.reason ?? "the checked-out branch could not be verified"}. Record a sanctioned execution-workspace branch transition or restore the workspace branch before completing the run.`, - { - workspaceValidation: { - reason: "git_worktree_branch_incoherence", - fingerprint: workspaceValidationFingerprint, - adapterType: agent.adapterType, - issueId: issueRef?.id ?? null, - issueIdentifier: issueRef?.identifier ?? null, - persistedExecutionWorkspaceId: - branchInspection.workspaceRecord.id, - executionWorkspaceCwd: executionWorkspace.cwd, - managedGitWorktreeBranch: finalizeBranchMetadata, - }, - }, - ); - } - } - } - await workspaceOperationRecorder.recordOperation({ - phase: "workspace_finalize", - cwd: executionWorkspace.cwd, - metadata: { - adapterType: agent.adapterType, - executionTargetKind: executionTarget?.kind ?? "local", - ...metadata, - ...(finalizeBranchMetadata - ? { managedGitWorktreeBranch: finalizeBranchMetadata } - : {}), - ...(finalizeBranchRepairMetadata - ? { - managedGitWorktreeBranchRepair: - finalizeBranchRepairMetadata, + ...(workspaceValidationFailure?.resultJson + ? { + workspaceValidation: + workspaceValidationFailure.resultJson + .workspaceValidation ?? + workspaceValidationFailure.resultJson, + } + : {}), + }, + run: async () => ({ + status: "failed", + stderr: `Managed git worktree branch check failed: ${repairErr instanceof Error ? repairErr.message : String(repairErr)}\n`, + }), + }); + adapterFinalizeOutcome = "failed"; + throw repairErr; + } + + const repairedInspection = + await inspectManagedGitWorktreeBranch({ + worktreePath: inspection.worktreePath, + expectedBranchName: repairedExpectedBranchName, + repoRoot: inspection.repoRoot, + }); + finalizeBranchRepairMetadata = { + attempted: true, + succeeded: repairedInspection.valid, + initial: initialManagedGitWorktreeBranch, + repaired: + formatManagedGitWorktreeBranchInspection( + repairedInspection, + ), + }; + inspection = repairedInspection; } - : {}), + + const managedGitWorktreeBranch = + formatManagedGitWorktreeBranchInspection(inspection); + finalizeBranchMetadata = { + executionWorkspaceId: branchInspection.workspaceRecord.id, + ...managedGitWorktreeBranch, + }; + if (!inspection.valid) { + const workspaceValidationFingerprint = + fingerprintFinalizeWorkspaceBranchValidation({ + issueId: issueRef?.id ?? null, + executionWorkspaceId: branchInspection.workspaceRecord.id, + inspection: managedGitWorktreeBranch, + }); + await workspaceOperationRecorder.recordOperation({ + phase: "workspace_finalize", + cwd: executionWorkspace.cwd, + metadata: { + adapterType: agent.adapterType, + executionTargetKind: executionTarget?.kind ?? "local", + ...metadata, + managedGitWorktreeBranch: finalizeBranchMetadata, + ...(finalizeBranchRepairMetadata + ? { + managedGitWorktreeBranchRepair: + finalizeBranchRepairMetadata, + } + : {}), + }, + run: async () => ({ + status: "failed", + stderr: `Managed git worktree branch check failed: ${inspection.reason ?? "unknown branch mismatch"}\n`, + }), + }); + adapterFinalizeOutcome = "failed"; + throw new WorkspaceValidationFailure( + `Execution workspace ${branchInspection.workspaceRecord.id} expected git worktree branch "${inspection.expectedBranchName}" at "${inspection.worktreePath}", but ${inspection.reason ?? "the checked-out branch could not be verified"}. Record a sanctioned execution-workspace branch transition or restore the workspace branch before completing the run.`, + { + workspaceValidation: { + reason: "git_worktree_branch_incoherence", + fingerprint: workspaceValidationFingerprint, + adapterType: agent.adapterType, + issueId: issueRef?.id ?? null, + issueIdentifier: issueRef?.identifier ?? null, + persistedExecutionWorkspaceId: + branchInspection.workspaceRecord.id, + executionWorkspaceCwd: executionWorkspace.cwd, + managedGitWorktreeBranch: finalizeBranchMetadata, + }, + }, + ); + } + } + } + return { status, metadata: { + ...(finalizeBranchMetadata ? { managedGitWorktreeBranch: finalizeBranchMetadata } : {}), + ...(finalizeBranchRepairMetadata ? { managedGitWorktreeBranchRepair: finalizeBranchRepairMetadata } : {}), + } }; }, - run: async () => ({ status }), }); // Only mark the outcome after the row landed, so a transient write // failure on the succeeded path can still be recovered by recording @@ -7182,10 +7254,11 @@ export function heartbeatService( eq(workspaceOperations.status, "succeeded"), )).limit(1); if (exported.length) adapterFinalizeOutcome = "succeeded"; - else await restoreNativeWorkspaceBestEffort({ - db, runId: run.id, assertOwnership: ownership?.assertHeld, - restore: () => nativeWorkspaceSync!.restoreWorkspace(ownership?.assertHeld), - }); + else await recordWorkspaceFinalize(hasWorkspaceRestoreFailure(adapterResult.resultJson) ? "failed" : "succeeded", undefined, + async () => { await restoreNativeWorkspaceBestEffort({ + db, runId: run.id, assertOwnership: ownership?.assertHeld, + restore: () => nativeWorkspaceSync!.restoreWorkspace(ownership?.assertHeld), + }); }); } await ownership?.assertHeld(); await db @@ -7874,6 +7947,12 @@ export function heartbeatService( eq(heartbeatRuns.controllerBootId, legacyControllerBootId), inArray(heartbeatRuns.status, ["succeeded", "failed", "cancelled", "timed_out", "interrupted"]))); + latestRun = await getRun(run.id).catch(() => null); + if (latestRun && isHeartbeatRunTerminalStatus(latestRun.status) && !isNativeRunnerOwnershipHeld(latestRun)) { + await appendRunEvent(run, { eventType: "lifecycle", stream: "system", level: "info", message: "task.run.measured", + payload: { phase: "task.run.measured", durationMs: Date.now() - (latestRun.startedAt ?? latestRun.createdAt).getTime(), + outcome: latestRun.status, durableSettlement: true } }).catch(error => logger.warn({ runId: run.id, error }, "Could not record terminal run duration")); + } } finally { controllerLease.stop(); activeRunExecutions.delete(run.id); diff --git a/server/src/services/heartbeat/workspaces.ts b/server/src/services/heartbeat/workspaces.ts index 69b879f3c28..10047bd1440 100644 --- a/server/src/services/heartbeat/workspaces.ts +++ b/server/src/services/heartbeat/workspaces.ts @@ -1,20 +1,14 @@ +import { readGitConnectionFailure } from "../git-connection-failure.js"; +import { materializeManagedProjectWorkspace, ensureManagedRepositoriesIgnored } from "../managed-repository-checkout.js"; +export { materializeManagedProjectWorkspace } from "../managed-repository-checkout.js"; +import { sql } from "drizzle-orm"; +import { materializeIsolatedTaskDirectory } from "../isolated-task-directory.js"; import { readConnectionFailure, type GitConnectionFailure } from "@paperclipai/adapter-utils/connection-failure"; -import { classifyGitCloneFailure, GitConnectionFailureError, readGitConnectionFailure } from "../git-connection-failure.js"; import { managedAiSessionFingerprintConfig } from "../ai-connection-runtime.js"; import { PROJECT_REPOSITORIES_DIR, readGitWorkspaceSnapshot, - disposeGitWorkspaceSnapshot, } from "@paperclipai/adapter-utils/git-workspace-sync"; -import { - isWorkspaceGitScanError, - WorkspaceGitScanError, -} from "../workspace-git-operation-scheduler.js"; -import { - captureDirectorySnapshot, - disposeDirectorySnapshot, - mergeDirectoryWithBaseline, -} from "@paperclipai/adapter-utils/workspace-restore-merge"; import fs from "node:fs/promises"; import path from "node:path"; import { execFile as execFileCallback } from "node:child_process"; @@ -43,7 +37,6 @@ import { import { logger } from "../../middleware/logger.js"; import { createGitRemoteAuthProvider, - describeGitAuthFailure, scrubGitCredentialText, type GitRemoteAuthProvider, } from "../git-credentials.js"; @@ -61,7 +54,6 @@ import { inspectManagedGitWorktreeBranch, type ExecutionWorkspaceInput, type RealizedExecutionWorkspace, - sanitizeRuntimeServiceBaseEnv, } from "../workspace-runtime.js"; import { resolvePersistedGitWorkspaceSource } from "../persisted-workspace-source.js"; import { issueService } from "../issues.js"; @@ -97,7 +89,6 @@ const execFile = promisify(execFileCallback); const REPO_ONLY_CWD_SENTINEL = "/__paperclip_repo_only__"; -const MANAGED_WORKSPACE_GIT_CLONE_TIMEOUT_MS = 10 * 60 * 1000; export const WORKSPACE_VALIDATION_FAILURE_CODE = "workspace_validation_failed"; @@ -503,133 +494,6 @@ export async function ensureManagedProjectWorkspace(input: { return result; } -async function materializeManagedProjectWorkspace( - cwd: string, - input: { - repoUrl: string | null; - repoRef?: string | null; - localSource?: string | null; - resolveGitAuth?: GitRemoteAuthProvider | null; - }, -): Promise<{ cwd: string; warning: string | null }> { - await fs.mkdir(path.dirname(cwd), { recursive: true }); - const stats = await fs.stat(cwd).catch(() => null); - - if (!input.repoUrl) { - if (!stats) { - await fs.mkdir(cwd, { recursive: true }); - } - return { cwd, warning: null }; - } - - const hasAdoptableGitDir = () => - fs - .stat(path.resolve(cwd, ".git")) - .then((entry) => entry.isDirectory()) - .catch(() => false); - if (await hasAdoptableGitDir()) { - return { cwd, warning: null }; - } - - if (stats) { - const entries = await fs.readdir(cwd).catch(() => []); - if (entries.length > 0) { - return { - cwd, - warning: `Managed workspace path "${cwd}" already exists but is not a git checkout. Using it as-is.`, - }; - } - await fs.rm(cwd, { recursive: true, force: true }); - } - - // Clone into a temp sibling, then move into place atomically. The shared target directory - // is never created in a partial state and never removed on failure, so a concurrent - // materialization (another process, or a run racing this one) can neither adopt a broken - // checkout nor lose its own completed one. - const auth = input.resolveGitAuth && !input.localSource - ? await input.resolveGitAuth(input.repoUrl) - : null; - const cloneTmpDir = await fs.mkdtemp(`${cwd}.clone-`); - try { - try { - await execFile( - "git", - [...(auth?.configArgs ?? []), "clone", "--no-hardlinks", "--", input.localSource ?? input.repoUrl, cloneTmpDir], - { - env: { - // Spread order matters: the sanitizer strips PAPERCLIP_*, which would remove the - // credential-helper token env if it came first. GIT_TERMINAL_PROMPT=0 fails a - // credential-less private clone immediately instead of hanging on a prompt until - // the clone timeout. - ...sanitizeRuntimeServiceBaseEnv(process.env), - GIT_TERMINAL_PROMPT: "0", - ...(auth?.env ?? {}), - }, - timeout: MANAGED_WORKSPACE_GIT_CLONE_TIMEOUT_MS, - }, - ); - } catch (error) { - const connectionFailure = !input.localSource ? classifyGitCloneFailure(input.repoUrl, error) : null; - if (connectionFailure) throw new GitConnectionFailureError( - error instanceof Error ? error.message : "Git clone failed", connectionFailure, - ); - throw error; - } - if (input.localSource) { - const snapshot = await readGitWorkspaceSnapshot(input.localSource, false); - if (!snapshot) throw new Error("Configured repository folder is not a Git checkout"); - let baseline; - try { - baseline = await captureDirectorySnapshot(cloneTmpDir, { exclude: [".git", ".paperclip-runtime", PROJECT_REPOSITORIES_DIR], ignoredPaths: snapshot.ignoredPaths, diskBacked: true }); - await mergeDirectoryWithBaseline({ baseline, sourceDir: input.localSource, targetDir: cloneTmpDir }); - } finally { - if (baseline) await disposeDirectorySnapshot(baseline); - await disposeGitWorkspaceSnapshot(snapshot); - } - await execFile("git", ["-C", cloneTmpDir, "remote", "set-url", "origin", input.repoUrl], { timeout: 10_000 }); - } else if (input.repoRef) { - await execFile("git", ["-C", cloneTmpDir, "checkout", input.repoRef], { timeout: MANAGED_WORKSPACE_GIT_CLONE_TIMEOUT_MS }); - } - } catch (error) { - await fs - .rm(cloneTmpDir, { recursive: true, force: true }) - .catch(() => undefined); - const reason = error instanceof Error ? error.message : String(error); - const authNote = describeGitAuthFailure({ - error: reason, - used: auth ? { source: auth.source, secretName: auth.secretName } : null, - }); - const message = scrubGitCredentialText( - `Failed to prepare managed checkout for "${input.repoUrl}" at "${cwd}": ${reason}${authNote ? ` ${authNote}` : ""}`, - ); - // Preserve the closed failure code without copying subprocess output or - // credentials into the durable run. Setup recovery needs the actual cause. - if (isWorkspaceGitScanError(error)) throw new WorkspaceGitScanError(error.code, message); - const connectionFailure = readGitConnectionFailure(error); - if (connectionFailure) throw new GitConnectionFailureError(message, connectionFailure); - throw new Error(message); - } - - try { - await fs.rename(cloneTmpDir, cwd); - } catch (renameError) { - await fs - .rm(cloneTmpDir, { recursive: true, force: true }) - .catch(() => undefined); - // The target appearing between the emptiness check and the rename means another - // materialization won the race; adopt its checkout instead of failing the run. - if (await hasAdoptableGitDir()) { - return { cwd, warning: null }; - } - const reason = - renameError instanceof Error ? renameError.message : String(renameError); - throw new Error( - `Failed to move managed checkout into place at "${cwd}": ${reason}`, - ); - } - return { cwd, warning: null }; -} - /** Keep every distinct project repository inside the task's writable/synced root. */ export async function prepareProjectRepositoryWorkspaces(input: { cwd: string; @@ -650,13 +514,7 @@ export async function prepareProjectRepositoryWorkspaces(input: { if (await fs.realpath(root) !== path.join(await fs.realpath(input.cwd), PROJECT_REPOSITORIES_DIR)) { throw new Error("Project repositories directory escapes the task workspace"); } - const excludePath = await execFile("git", ["-C", input.cwd, "rev-parse", "--git-path", "info/exclude"], { timeout: 10_000 }) - .then((result) => path.resolve(input.cwd, result.stdout.trim())); - const exclude = await fs.readFile(excludePath, "utf8").catch(() => ""); - if (!exclude.split(/\r?\n/).includes(`/${PROJECT_REPOSITORIES_DIR}/`)) { - await fs.mkdir(path.dirname(excludePath), { recursive: true }); - await fs.appendFile(excludePath, `\n/${PROJECT_REPOSITORIES_DIR}/\n`); - } + await ensureManagedRepositoriesIgnored(input.cwd); const results = []; for (const workspace of selected) { const repoUrl = workspace.repoUrl!; @@ -675,7 +533,7 @@ export async function prepareProjectRepositoryWorkspaces(input: { // Retain detached checkout work outside the synchronized repository set. const active = new Set(results.map((repo) => path.basename(repo.cwd))); for (const entry of await fs.readdir(root)) { - if (active.has(entry) || entry.includes(".clone-")) continue; + if (active.has(entry) || entry.startsWith("task-repo-") || entry.includes(".clone-")) continue; const retained = path.join(input.cwd, ".paperclip-runtime", "detached-repositories", randomUUID()); await fs.mkdir(path.dirname(retained), { recursive: true }); await fs.rename(path.join(root, entry), retained); @@ -1282,7 +1140,7 @@ export async function assertGitSensitiveAdapterWorkspaceValid(input: { }); }; - if (issue.projectWorkspaceId && !issue.projectId) { + if (issue.projectWorkspaceId && !issue.projectId && !input.persistedExecutionWorkspace?.projectId) { fail( "missing_project_id", `Issue ${issue.identifier ?? issue.id} is linked to a project workspace but has no project id; refusing to launch ${input.adapterType} from fallback cwd.`, @@ -1371,6 +1229,7 @@ export async function assertGitSensitiveAdapterWorkspaceValid(input: { if ( workspaceExpectation && + !(input.resolvedWorkspace.localPathOnlyWorkspace === true && input.executionWorkspace.strategy === "project_primary" && !input.executionWorkspace.repoUrl && !input.executionWorkspace.branchName) && effectiveCwd && !(await hasGitMetadata(effectiveCwd)) ) { @@ -3390,15 +3249,15 @@ export function createHeartbeatWorkspaceResolver(db: Db) { ? await db.select({ projectId: issues.projectId, projectWorkspaceId: issues.projectWorkspaceId }).from(issues) .where(and(eq(issues.id, input.issueId), eq(issues.companyId, agent.companyId))).then(rows => rows[0] ?? null) : null; - const sourceProjectId = input.issueId && !input.immutableNativeBinding ? currentIssueSource?.projectId ?? null : input.projectId; + const sourceProjectId = workspace.projectId; const explicitProjectWorkspaceId = currentIssueSource?.projectWorkspaceId ?? input.explicitProjectWorkspaceId; const projectWorkspaceRows = await db.select().from(projectWorkspaces).where(and( eq(projectWorkspaces.companyId, agent.companyId), - eq(projectWorkspaces.projectId, workspace.projectId), + workspace.projectId ? eq(projectWorkspaces.projectId, workspace.projectId) : sql`false`, )).orderBy(asc(projectWorkspaces.createdAt), asc(projectWorkspaces.id)); const boundProjectWorkspace = projectWorkspaceRows.find(row => row.id === workspace.projectWorkspaceId) ?? null; const configuredSource = prioritizeProjectWorkspaceCandidatesForRun(projectWorkspaceRows, explicitProjectWorkspaceId)[0]; - const managedBase = workspace.repoUrl ? resolveManagedProjectWorkspaceDir({ + const managedBase = workspace.repoUrl && workspace.projectId ? resolveManagedProjectWorkspaceDir({ companyId: agent.companyId, projectId: workspace.projectId, repoName: deriveRepoNameFromRepoUrl(workspace.repoUrl), @@ -3416,10 +3275,10 @@ export function createHeartbeatWorkspaceResolver(db: Db) { boundProjectWorkspace, candidateBaseCwds, managedSourceRoot: resolvePaperclipInstanceRoot(), - materializeOriginalRepository: workspace.repoUrl ? async () => { + materializeOriginalRepository: workspace.repoUrl && workspace.projectId ? async () => { const original = await ensureManagedProjectWorkspace({ companyId: agent.companyId, - projectId: workspace.projectId, + projectId: workspace.projectId!, repoUrl: workspace.repoUrl, resolveGitAuth: createGitRemoteAuthProvider(db, agent.companyId, { issueId: input.issueId, heartbeatRunId: input.runId, agentId: agent.id, @@ -3476,6 +3335,7 @@ export function createHeartbeatWorkspaceResolver(db: Db) { .select({ projectId: issues.projectId, projectWorkspaceId: issues.projectWorkspaceId, + workspaceSelection: issues.workspaceSelection, }) .from(issues) .where( @@ -3486,9 +3346,12 @@ export function createHeartbeatWorkspaceResolver(db: Db) { const issueProjectId = issueProjectRef?.projectId ?? null; const preferredProjectWorkspaceId = issueProjectRef?.projectWorkspaceId ?? contextProjectWorkspaceId ?? null; - const resolvedProjectId = issueProjectId ?? contextProjectId; + const [selectedSource] = preferredProjectWorkspaceId ? await db.select({ projectId: projectWorkspaces.projectId }).from(projectWorkspaces) + .where(and(eq(projectWorkspaces.id, preferredProjectWorkspaceId), eq(projectWorkspaces.companyId, agent.companyId))) : []; + const resolvedProjectId = selectedSource?.projectId ?? issueProjectId ?? contextProjectId; + const forceTaskDirectory = issueProjectRef?.workspaceSelection?.selection.kind === "task_directory"; const useProjectWorkspace = opts?.useProjectWorkspace !== false; - const workspaceProjectId = useProjectWorkspace ? resolvedProjectId : null; + const workspaceProjectId = useProjectWorkspace && !forceTaskDirectory ? resolvedProjectId : null; const unorderedProjectWorkspaceRows = workspaceProjectId ? await db @@ -3592,52 +3455,12 @@ export function createHeartbeatWorkspaceResolver(db: Db) { missingProjectCwds.push(projectCwd); } - const fallbackCwd = resolveDefaultAgentWorkspaceDir(agent.id); - await fs.mkdir(fallbackCwd, { recursive: true }); - const warnings = buildAnchorFallbackWorkspaceNotes({ - fallbackCwd, - preferredWorkspaceWarning, - materializationFailures, - missingProjectCwds, - hasConfiguredProjectCwd, - }); - return { - cwd: fallbackCwd, - source: "project_primary" as const, - projectId: resolvedProjectId, - workspaceId: projectWorkspaceRows[0]?.id ?? null, - repoUrl: projectWorkspaceRows[0]?.repoUrl ?? null, - repoRef: projectWorkspaceRows[0]?.repoRef ?? null, - workspaceHints, - warnings, - baseCwdFallback: true, - materializationFailures, - }; - } - - if (workspaceProjectId) { - const managedWorkspace = await ensureManagedProjectWorkspace({ - companyId: agent.companyId, - projectId: workspaceProjectId, - repoUrl: null, - }); - return { - cwd: managedWorkspace.cwd, - source: "project_primary" as const, - projectId: resolvedProjectId, - workspaceId: null, - repoUrl: null, - repoRef: null, - workspaceHints, - warnings: managedWorkspace.warning ? [managedWorkspace.warning] : [], - baseCwdFallback: false, - materializationFailures: [], - }; + throw new Error(`Configured project workspace is unavailable: ${preferredWorkspaceWarning ?? materializationFailures[0]?.error ?? missingProjectCwds[0] ?? "source could not be prepared"}`); } const sessionCwd = readNonEmptyString(previousSessionParams?.cwd); const sessionCwdLooksUnsafe = isUnsafeSessionWorkspaceCwd(sessionCwd); - if (sessionCwd && !sessionCwdLooksUnsafe) { + if (!forceTaskDirectory && sessionCwd && !sessionCwdLooksUnsafe) { const sessionCwdExists = await fs .stat(sessionCwd) .then((stats) => stats.isDirectory()) @@ -3658,7 +3481,10 @@ export function createHeartbeatWorkspaceResolver(db: Db) { } } - const cwd = resolveDefaultAgentWorkspaceDir(agent.id); + const configuredCwd = !forceTaskDirectory ? readNonEmptyString(parseObject(agent.adapterConfig).cwd) : null; + const cwd = configuredCwd ? path.resolve(configuredCwd) + : issueId ? await materializeIsolatedTaskDirectory({ companyId: agent.companyId, issueId }) + : resolveDefaultAgentWorkspaceDir(agent.id); await fs.mkdir(cwd, { recursive: true }); const warnings: string[] = []; if (sessionCwd && sessionCwdLooksUnsafe) { @@ -3680,8 +3506,8 @@ export function createHeartbeatWorkspaceResolver(db: Db) { } return { cwd, - source: "agent_home" as const, - projectId: resolvedProjectId, + source: issueId || configuredCwd ? "task_session" as const : "agent_home" as const, + projectId: null, workspaceId: null, repoUrl: null, repoRef: null, diff --git a/server/src/services/issues.ts b/server/src/services/issues.ts index 400593dace3..4bb86cb2a00 100644 --- a/server/src/services/issues.ts +++ b/server/src/services/issues.ts @@ -1,3 +1,6 @@ +import { type AuthorizationActor, executionWorkspaceReadSqlCondition, projectReadSqlCondition } from "./authorization.js"; +import { type TaskWorkspaceSelection, type TaskWorkspaceIntent } from "@paperclipai/shared"; +import { executionWorkspaceService } from "./execution-workspaces.js"; import { recordChatHandoff, recordChatCompletion, existingChatCompletionReply, acknowledgeChatCompletionReply } from "./chat-completion-delivery.js"; import { mirrorSlackBoardComment, slackBoardReplyBindings } from "./slack-board-messages.js"; import { assertAgentRunWriteAllowed } from "../agent-run-cancellation.js"; @@ -1937,7 +1940,10 @@ async function assertExecutionTaskParent(db: Db, companyId: string, parentId?: s } type DbTransaction = Parameters[0]>[0]; -type IssueCreateInput = Omit & { +type IssueCreateInput = Omit & { + workspaceSelection?: TaskWorkspaceSelection; + workspaceSelectionSource?: "explicit" | "channel"; + workspaceSelectionActor?: AuthorizationActor; title?: string; initialPlan?: string | null; labelIds?: string[]; @@ -4999,6 +5005,9 @@ const issueListSelect = { executionWorkspaceId: issues.executionWorkspaceId, executionWorkspacePreference: issues.executionWorkspacePreference, executionWorkspaceSettings: sql`null`, + workspaceSelection: sql`null`, + workspacePendingSelection: sql`null`, + workspaceBindingRevision: issues.workspaceBindingRevision, sourceTrust: issues.sourceTrust, unblockDescriptor: issues.unblockDescriptor, blockedTransitionAt: issues.blockedTransitionAt, @@ -7301,11 +7310,6 @@ export function issueService(db: Db) { if (!workspace) throw notFound("Execution workspace not found"); if (workspace.companyId !== companyId) throw unprocessable("Execution workspace must belong to same company"); - if (projectId && workspace.projectId !== projectId) { - throw unprocessable( - "Execution workspace must belong to the selected project", - ); - } return workspace; } @@ -9913,6 +9917,7 @@ export function issueService(db: Db) { dbOrTx: Db | DbTransaction = db, ) => { const { + workspaceSelection, workspaceSelectionSource = "explicit", workspaceSelectionActor, initialPlan, labelIds: inputLabelIds, blockedByIssueIds, @@ -10080,12 +10085,14 @@ export function issueService(db: Db) { let executionWorkspaceSettings = (issueData.executionWorkspaceSettings as Record | null | undefined) ?? null; + let inheritedWorkspaceSelection = false; const workspaceInheritanceIssueId = skipExecutionWorkspaceInheritance ? null : (inheritExecutionWorkspaceFromIssueId ?? issueData.parentId ?? null); const hasExplicitExecutionWorkspaceOverride = + (workspaceSelection !== undefined && workspaceSelectionSource === "explicit") || issueData.executionWorkspaceId !== undefined || issueData.executionWorkspacePreference !== undefined || issueData.executionWorkspaceSettings !== undefined; @@ -10113,6 +10120,7 @@ export function issueService(db: Db) { workspaceSource.projectWorkspaceId ) { projectWorkspaceId = workspaceSource.projectWorkspaceId; + inheritedWorkspaceSelection = true; } if ( inheritsSourceProject && @@ -10135,6 +10143,7 @@ export function issueService(db: Db) { .then((rows) => rows[0] ?? null); if (sourceWorkspace) { executionWorkspaceId = sourceWorkspace.id; + inheritedWorkspaceSelection = true; executionWorkspacePreference = "reuse_existing"; executionWorkspaceSettings = { ...((workspaceSource.executionWorkspaceSettings as @@ -10146,23 +10155,19 @@ export function issueService(db: Db) { } } } - if (issueData.projectId == null && projectWorkspaceId) { - const workspace = await assertValidProjectWorkspace( - companyId, - null, - projectWorkspaceId, - tx, - ); - issueData.projectId = workspace.projectId; - } - if (issueData.projectId == null && executionWorkspaceId) { - const workspace = await assertValidExecutionWorkspace( - companyId, - null, - executionWorkspaceId, - tx, - ); - issueData.projectId = workspace.projectId; + const workspaceActor: AuthorizationActor = workspaceSelectionActor ?? (issueData.createdByUserId + ? { type: "board", userId: issueData.createdByUserId, companyIds: [companyId] } + : { type: "agent", agentId: issueData.createdByAgentId ?? issueData.assigneeAgentId, companyId }); + let workspaceIntent: TaskWorkspaceIntent | null = null; + if (workspaceSelection && (workspaceSelectionSource === "explicit" || !inheritedWorkspaceSelection)) { + const projection = await executionWorkspaceService(db).validateSelection({ companyId, + actor: workspaceActor, + selection: workspaceSelection }, tx); + projectWorkspaceId = projection.projectWorkspaceId; + executionWorkspaceId = projection.executionWorkspaceId; + executionWorkspacePreference = projection.executionWorkspacePreference; + executionWorkspaceSettings = projection.executionWorkspaceSettings; + workspaceIntent = { version: 1, selection: workspaceSelection, source: workspaceSelectionSource }; } const projectGoalId = await getProjectDefaultGoalId( tx, @@ -10210,7 +10215,7 @@ export function issueService(db: Db) { ), ) as Record | null; } - if (!projectWorkspaceId && issueData.projectId) { + if (!projectWorkspaceId && issueData.projectId && workspaceIntent?.selection.kind !== "task_directory") { const project = await tx .select({ executionWorkspacePolicy: projects.executionWorkspacePolicy, @@ -10245,10 +10250,21 @@ export function issueService(db: Db) { .then((rows) => rows[0]?.id ?? null); } } + if (projectWorkspaceId && workspaceSelectionActor) { + const [readableSource] = await tx.select({ id: projectWorkspaces.id }).from(projectWorkspaces) + .innerJoin(projects, eq(projects.id, projectWorkspaces.projectId)) + .where(and(eq(projectWorkspaces.id, projectWorkspaceId), eq(projectWorkspaces.companyId, companyId), await projectReadSqlCondition(tx, workspaceActor))); + if (!readableSource) throw notFound("Workspace source is unavailable or inaccessible"); + } + if (executionWorkspaceId && workspaceSelectionActor) { + const [readableWorkspace] = await tx.select({ id: executionWorkspaces.id }).from(executionWorkspaces) + .where(and(eq(executionWorkspaces.id, executionWorkspaceId), eq(executionWorkspaces.companyId, companyId), await executionWorkspaceReadSqlCondition(tx, workspaceActor))); + if (!readableWorkspace) throw notFound("Workspace is unavailable or inaccessible"); + } if (projectWorkspaceId) { await assertValidProjectWorkspace( companyId, - issueData.projectId, + null, projectWorkspaceId, tx, ); @@ -10256,7 +10272,7 @@ export function issueService(db: Db) { if (executionWorkspaceId) { await assertValidExecutionWorkspace( companyId, - issueData.projectId, + null, executionWorkspaceId, tx, ); @@ -10367,6 +10383,7 @@ export function issueService(db: Db) { const values = { ...issueData, + workspaceSelection: workspaceIntent, visibility, privacyRootIssueId, privacyParentIssueId, @@ -11083,46 +11100,8 @@ export function issueService(db: Db) { ? { ...nextExecutionWorkspaceSettings } : null; } - let validatedProjectWorkspace: { projectId: string } | null = null; - let validatedExecutionWorkspace: { projectId: string } | null = null; - if (!nextProjectId && nextProjectWorkspaceId) { - const workspace = await assertValidProjectWorkspace( - existing.companyId, - null, - nextProjectWorkspaceId, - ); - validatedProjectWorkspace = workspace; - nextProjectId = workspace.projectId; - patch.projectId = workspace.projectId; - } - if (!nextProjectId && nextExecutionWorkspaceId) { - const workspace = await assertValidExecutionWorkspace( - existing.companyId, - null, - nextExecutionWorkspaceId, - ); - validatedExecutionWorkspace = workspace; - nextProjectId = workspace.projectId; - patch.projectId = workspace.projectId; - } - if (nextProjectWorkspaceId) { - if (!validatedProjectWorkspace) { - await assertValidProjectWorkspace( - existing.companyId, - nextProjectId, - nextProjectWorkspaceId, - ); - } - } - if (nextExecutionWorkspaceId) { - if (!validatedExecutionWorkspace) { - await assertValidExecutionWorkspace( - existing.companyId, - nextProjectId, - nextExecutionWorkspaceId, - ); - } - } + if (nextProjectWorkspaceId) await assertValidProjectWorkspace(existing.companyId, null, nextProjectWorkspaceId); + if (nextExecutionWorkspaceId) await assertValidExecutionWorkspace(existing.companyId, null, nextExecutionWorkspaceId); if ( isolatedWorkspacesEnabled && issueData.executionWorkspaceSettings !== undefined diff --git a/server/src/services/managed-repository-checkout.ts b/server/src/services/managed-repository-checkout.ts new file mode 100644 index 00000000000..49919bfde0d --- /dev/null +++ b/server/src/services/managed-repository-checkout.ts @@ -0,0 +1,160 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import { execFile as execFileCallback } from "node:child_process"; +import { promisify } from "node:util"; +import { PROJECT_REPOSITORIES_DIR, readGitWorkspaceSnapshot, disposeGitWorkspaceSnapshot } from "@paperclipai/adapter-utils/git-workspace-sync"; +import { captureDirectorySnapshot, disposeDirectorySnapshot, mergeDirectoryWithBaseline } from "@paperclipai/adapter-utils/workspace-restore-merge"; +import { sanitizeRuntimeServiceBaseEnv } from "./runtime-service-env.js"; +import { describeGitAuthFailure, scrubGitCredentialText, type GitRemoteAuthProvider } from "./git-credentials.js"; +import { classifyGitCloneFailure, GitConnectionFailureError, readGitConnectionFailure } from "./git-connection-failure.js"; +import { isWorkspaceGitScanError, WorkspaceGitScanError } from "./workspace-git-operation-scheduler.js"; + +const execFile = promisify(execFileCallback); +const MANAGED_WORKSPACE_GIT_CLONE_TIMEOUT_MS = 10 * 60 * 1000; + +/** Atomically publish a complete managed checkout; never delete a competing checkout. */ +export async function materializeManagedProjectWorkspace( + cwd: string, + input: { + repoUrl: string | null; + repoRef?: string | null; + localSource?: string | null; + resolveGitAuth?: GitRemoteAuthProvider | null; + /** Internal preparation receipt is committed inside the clone before atomic publication. */ + beforePublish?: (cloneCwd: string) => Promise; + }, +): Promise<{ cwd: string; warning: string | null }> { + await fs.mkdir(path.dirname(cwd), { recursive: true }); + const stats = await fs.stat(cwd).catch(() => null); + + if (!input.repoUrl) { + if (!stats) { + await fs.mkdir(cwd, { recursive: true }); + } + return { cwd, warning: null }; + } + + const hasAdoptableGitDir = () => + fs + .stat(path.resolve(cwd, ".git")) + .then((entry) => entry.isDirectory()) + .catch(() => false); + if (await hasAdoptableGitDir()) { + return { cwd, warning: null }; + } + + if (stats) { + const entries = await fs.readdir(cwd).catch(() => []); + if (entries.length > 0) { + return { + cwd, + warning: `Managed workspace path "${cwd}" already exists but is not a git checkout. Using it as-is.`, + }; + } + await fs.rm(cwd, { recursive: true, force: true }); + } + + // Clone into a temp sibling, then move into place atomically. The shared target directory + // is never created in a partial state and never removed on failure, so a concurrent + // materialization (another process, or a run racing this one) can neither adopt a broken + // checkout nor lose its own completed one. + const auth = input.resolveGitAuth && !input.localSource + ? await input.resolveGitAuth(input.repoUrl) + : null; + const cloneTmpDir = await fs.mkdtemp(`${cwd}.clone-`); + try { + try { + await execFile( + "git", + [...(auth?.configArgs ?? []), "clone", "--no-hardlinks", "--", input.localSource ?? input.repoUrl, cloneTmpDir], + { + env: { + // Spread order matters: the sanitizer strips PAPERCLIP_*, which would remove the + // credential-helper token env if it came first. GIT_TERMINAL_PROMPT=0 fails a + // credential-less private clone immediately instead of hanging on a prompt until + // the clone timeout. + ...sanitizeRuntimeServiceBaseEnv(process.env), + GIT_TERMINAL_PROMPT: "0", + ...(auth?.env ?? {}), + }, + timeout: MANAGED_WORKSPACE_GIT_CLONE_TIMEOUT_MS, + }, + ); + } catch (error) { + const connectionFailure = !input.localSource ? classifyGitCloneFailure(input.repoUrl, error) : null; + if (connectionFailure) throw new GitConnectionFailureError( + error instanceof Error ? error.message : "Git clone failed", connectionFailure, + ); + throw error; + } + if (input.localSource) { + const snapshot = await readGitWorkspaceSnapshot(input.localSource, false); + if (!snapshot) throw new Error("Configured repository folder is not a Git checkout"); + let baseline; + try { + baseline = await captureDirectorySnapshot(cloneTmpDir, { exclude: [".git", ".paperclip-runtime", PROJECT_REPOSITORIES_DIR], ignoredPaths: snapshot.ignoredPaths, diskBacked: true }); + await mergeDirectoryWithBaseline({ baseline, sourceDir: input.localSource, targetDir: cloneTmpDir }); + } finally { + if (baseline) await disposeDirectorySnapshot(baseline); + await disposeGitWorkspaceSnapshot(snapshot); + } + await execFile("git", ["-C", cloneTmpDir, "remote", "set-url", "origin", input.repoUrl], { timeout: 10_000 }); + } else if (input.repoRef) { + await execFile("git", ["-C", cloneTmpDir, "checkout", input.repoRef], { timeout: MANAGED_WORKSPACE_GIT_CLONE_TIMEOUT_MS }); + } + await input.beforePublish?.(cloneTmpDir); + } catch (error) { + await fs + .rm(cloneTmpDir, { recursive: true, force: true }) + .catch(() => undefined); + const reason = error instanceof Error ? error.message : String(error); + const authNote = describeGitAuthFailure({ + error: reason, + used: auth ? { source: auth.source, secretName: auth.secretName } : null, + }); + const message = scrubGitCredentialText( + `Failed to prepare managed checkout for "${input.repoUrl}" at "${cwd}": ${reason}${authNote ? ` ${authNote}` : ""}`, + ); + // Preserve the closed failure code without copying subprocess output or + // credentials into the durable run. Setup recovery needs the actual cause. + if (isWorkspaceGitScanError(error)) throw new WorkspaceGitScanError(error.code, message); + const connectionFailure = readGitConnectionFailure(error); + if (connectionFailure) throw new GitConnectionFailureError(message, connectionFailure); + throw new Error(message); + } + + try { + await fs.rename(cloneTmpDir, cwd); + } catch (renameError) { + await fs + .rm(cloneTmpDir, { recursive: true, force: true }) + .catch(() => undefined); + // The target appearing between the emptiness check and the rename means another + // materialization won the race; adopt its checkout instead of failing the run. + if (await hasAdoptableGitDir()) { + return { cwd, warning: null }; + } + const reason = + renameError instanceof Error ? renameError.message : String(renameError); + throw new Error( + `Failed to move managed checkout into place at "${cwd}": ${reason}`, + ); + } + return { cwd, warning: null }; +} + + +/** The container is independently captured, never an accidental Git submodule. + * A plain task root must not discover and mutate an ancestor repository. */ +export async function ensureManagedRepositoriesIgnored(cwd: string): Promise { + const gitEntry = await fs.lstat(path.join(cwd, ".git")).catch(() => null); + if (!gitEntry) return; + if (gitEntry.isSymbolicLink()) throw new Error("Workspace Git metadata must not be a symlink"); + const excludePath = await execFile("git", ["-C", cwd, "rev-parse", "--git-path", "info/exclude"], { timeout: 10_000 }) + .then((result) => path.resolve(cwd, result.stdout.trim())); + const exclude = await fs.readFile(excludePath, "utf8").catch(() => ""); + if (!exclude.split(/\r?\n/).includes(`/${PROJECT_REPOSITORIES_DIR}/`)) { + await fs.mkdir(path.dirname(excludePath), { recursive: true }); + await fs.appendFile(excludePath, `\n/${PROJECT_REPOSITORIES_DIR}/\n`); + } +} diff --git a/server/src/services/native-runtime/native-chat-workspace.ts b/server/src/services/native-runtime/native-chat-workspace.ts index 26898069e08..da315bf8263 100644 --- a/server/src/services/native-runtime/native-chat-workspace.ts +++ b/server/src/services/native-runtime/native-chat-workspace.ts @@ -1,7 +1,7 @@ import { lstat, mkdir, realpath } from "node:fs/promises"; import path from "node:path"; import { and, eq, sql } from "drizzle-orm"; -import { chatConversations, issues, type Db } from "@paperclipai/db"; +import { chatConversations, issues, executionWorkspaces, projectWorkspaces, type Db } from "@paperclipai/db"; import { resolvePaperclipInstanceRoot } from "../../home-paths.js"; export type NativeChatWorkspaceScope = { @@ -30,6 +30,8 @@ export async function findNativeChatWorkspaceScope( agentId: string; issueId: string | null; instanceRoot?: string; + /** Immutable admitted input only; never a wake or prompt path. */ + admittedCwd?: string | null; }, ): Promise { if ( @@ -43,6 +45,19 @@ export async function findNativeChatWorkspaceScope( id: issues.id, projectId: issues.projectId, originKind: issues.originKind, + selection: issues.workspaceSelection, + configuredProjectId: projectWorkspaces.projectId, + hasConfiguredDefault: sql`exists ( + select 1 from ${projectWorkspaces} + where ${projectWorkspaces.projectId} = ${issues.projectId} + and ${projectWorkspaces.companyId} = ${input.companyId} + and (nullif(btrim(${projectWorkspaces.cwd}), '') is not null + or nullif(btrim(${projectWorkspaces.repoUrl}), '') is not null) + )`, + boundWorkspaceId: executionWorkspaces.id, + boundProjectId: executionWorkspaces.projectId, + boundSourceIssueId: executionWorkspaces.sourceIssueId, + boundCwd: executionWorkspaces.cwd, hasConversation: sql`exists ( select 1 from ${chatConversations} where ${chatConversations.issueId} = ${issues.id} @@ -50,6 +65,8 @@ export async function findNativeChatWorkspaceScope( )`, }) .from(issues) + .leftJoin(projectWorkspaces, and(eq(projectWorkspaces.id, issues.projectWorkspaceId), eq(projectWorkspaces.companyId, input.companyId))) + .leftJoin(executionWorkspaces, and(eq(executionWorkspaces.id, issues.executionWorkspaceId), eq(executionWorkspaces.companyId, input.companyId))) .where( and(eq(issues.id, input.issueId), eq(issues.companyId, input.companyId)), ) @@ -64,23 +81,18 @@ export async function findNativeChatWorkspaceScope( companyId: input.companyId, agentId: input.agentId, issueId: issue.id, - projectId: issue.projectId, + projectId: issue.boundWorkspaceId ? issue.boundProjectId : issue.selection?.selection.kind === "task_directory" ? null : issue.configuredProjectId ?? (issue.hasConfiguredDefault ? issue.projectId : null), instanceRoot, // This must be a sibling of agent homes, not a child readable by an old // provider thread whose immutable permission root is the entire agent home. - taskRoot: path.join( - instanceRoot, - "chat-workspaces", - segment(input.companyId), - segment(input.agentId), - segment(issue.id), - ), + taskRoot: issue.boundSourceIssueId === issue.id && issue.boundCwd + ? issue.boundCwd : input.admittedCwd ?? path.join(instanceRoot, "isolated-workspaces", segment(input.companyId), segment(issue.id)), }; } export type NativeChatProjectWorkspace = { companyId: string; - projectId: string; + projectId: string | null; sourceIssueId: string | null; mode: string; strategyType: string; @@ -95,7 +107,10 @@ export function nativeChatWorkspaceCwd( workspace: NativeChatProjectWorkspace | null, reuseExisting: boolean, ): string | null { - if (!scope.projectId) return workspace === null ? scope.taskRoot : null; + if (!scope.projectId) return workspace === null || ( + workspace.companyId === scope.companyId && workspace.sourceIssueId === scope.issueId && + workspace.projectId === null && workspace.cwd === scope.taskRoot && ["active", "idle"].includes(workspace.status) + ) ? scope.taskRoot : null; if ( !reuseExisting || !workspace || @@ -120,12 +135,13 @@ export async function materializeNativeChatTaskRoot( if (scope.projectId) throw new Error("native_chat_workspace_project_requires_isolation"); let cursor = scope.instanceRoot; - for (const part of [ - "chat-workspaces", - segment(scope.companyId), - segment(scope.agentId), - segment(scope.issueId), - ]) { + const relative = path.relative(scope.instanceRoot, scope.taskRoot); + const allowedTask = path.join("isolated-workspaces", segment(scope.companyId), segment(scope.issueId)); + const legacyParts = relative.split(path.sep); + const allowedLegacy = legacyParts.length === 4 && legacyParts[0] === "chat-workspaces" && + legacyParts[1] === scope.companyId && /^[a-zA-Z0-9_-]+$/.test(legacyParts[2]!) && legacyParts[3] === scope.issueId; + if (relative !== allowedTask && !allowedLegacy) throw new Error("native_chat_workspace_path_not_isolated"); + for (const part of relative.split(path.sep)) { cursor = path.join(cursor, part); await mkdir(cursor, { mode: 0o700 }).catch( (error: NodeJS.ErrnoException) => { diff --git a/server/src/services/native-runtime/native-run-trace.test.ts b/server/src/services/native-runtime/native-run-trace.test.ts index b5c8bd372c0..a5554167c36 100644 --- a/server/src/services/native-runtime/native-run-trace.test.ts +++ b/server/src/services/native-runtime/native-run-trace.test.ts @@ -80,7 +80,7 @@ describe("native runner performance trace", () => { }); expect(ingress).toEqual({ name: "question_response.to_run_created", - parentName: "task.run", + parentName: "task.provider_session", startedAtMs: answeredAtMs, endedAtMs: answeredAtMs + 10, }); @@ -120,7 +120,7 @@ describe("native runner performance trace", () => { clock.mockRestore(); } expect( - events.find((event) => event.payload?.span === "task.run.measured") + events.find((event) => event.payload?.span === "task.provider_session.measured") ?.payload, ).toMatchObject({ durationMs: 19_000 }); expect(original.createdAt).toBe(new Date(1_000).toISOString()); @@ -144,7 +144,7 @@ describe("native runner performance trace", () => { }), ).toEqual({ name: "comment.to_run_created", - parentName: "task.run", + parentName: "task.provider_session", startedAtMs: 1_000, endedAtMs: 3_000, }); @@ -210,7 +210,7 @@ describe("native runner performance trace", () => { }, }); const prepare = trace.start("task.prepare", { - parentName: "task.run", + parentName: "task.provider_session", startedAtMs: starts.preparationStartedAtMs, }); await trace.end(prepare, { endedAtMs: attemptStartedAtMs + 50 }); @@ -263,7 +263,7 @@ describe("native runner performance trace", () => { schema: "paperclip.run-performance-span.v1", traceSchemaVersion: NATIVE_RUN_TRACE_SCHEMA_VERSION, span: "runner.transport.selected", - parentSpan: "task.run", + parentSpan: "task.provider_session", startOffsetMs: 125, durationMs: 0, outcome: "ok", @@ -304,7 +304,7 @@ describe("native runner performance trace", () => { const { traceContext, spans } = createRecordingTraceContext(); const trace = createNativeRunTrace({ runId: "skills-run", startedAtMs: 100, traceContext, onEvent: async () => { throw new Error("run log unavailable"); } }); - const preparation = trace.start("task.prepare", { parentName: "task.run", startedAtMs: 100 }); + const preparation = trace.start("task.prepare", { parentName: "task.provider_session", startedAtMs: 100 }); await expect(trace.record({ name: "skills.prepare", parentName: "task.prepare", startedAtMs: 120, endedAtMs: 170 })).resolves.toBeUndefined(); await trace.end(preparation, { endedAtMs: 200 }); expect(spans.find((span) => span.name === "skills.prepare")).toMatchObject({ name: "skills.prepare", parentName: "task.prepare", endedAtMs: 170 }); @@ -350,7 +350,7 @@ describe("native runner performance trace", () => { }); const prepare = trace.start("task.prepare", { - parentName: "task.run", + parentName: "task.provider_session", startedAtMs: 1_010, }); const environment = trace.start("environment.startup", { @@ -367,7 +367,7 @@ describe("native runner performance trace", () => { await trace.end(prepare, { endedAtMs: 1_040 }); const execute = trace.start("native.session.execute", { - parentName: "task.run", + parentName: "task.provider_session", startedAtMs: 1_050, }); const startup = trace.start("runner.session.startup", { @@ -417,11 +417,11 @@ describe("native runner performance trace", () => { const parentOf = (name: string) => spans.find((span) => span.name === name)?.parentName; - expect(parentOf("task.run")).toBeNull(); - expect(parentOf("task.prepare")).toBe("task.run"); + expect(parentOf("task.provider_session")).toBeNull(); + expect(parentOf("task.prepare")).toBe("task.provider_session"); expect(parentOf("environment.startup")).toBe("task.prepare"); expect(parentOf("environment.acquire")).toBe("environment.startup"); - expect(parentOf("native.session.execute")).toBe("task.run"); + expect(parentOf("native.session.execute")).toBe("task.provider_session"); expect(parentOf("runner.session.startup")).toBe("native.session.execute"); expect(parentOf("runner.artifact.prepare")).toBe("runner.session.startup"); expect(parentOf("runner.artifact.discover")).toBe( @@ -437,10 +437,10 @@ describe("native runner performance trace", () => { expect(parentOf("provider.dynamic")).toBe("agent.turn"); expect(parentOf("provider.turn.queue")).toBe("agent.turn"); - expect(spans.filter((span) => span.name === "task.run")).toHaveLength(1); - expect(spans.some((span) => span.name === "task.run.measured")).toBe(false); + expect(spans.filter((span) => span.name === "task.provider_session")).toHaveLength(1); + expect(spans.some((span) => span.name === "task.provider_session.measured")).toBe(false); expect( - events.some((event) => event.payload?.span === "task.run.measured"), + events.some((event) => event.payload?.span === "task.provider_session.measured"), ).toBe(true); }); }); diff --git a/server/src/services/native-runtime/native-run-trace.ts b/server/src/services/native-runtime/native-run-trace.ts index b130f26cdaa..e3f4f8291b0 100644 --- a/server/src/services/native-runtime/native-run-trace.ts +++ b/server/src/services/native-runtime/native-run-trace.ts @@ -9,7 +9,7 @@ import { } from "../../instrumentation.js"; export const NATIVE_RUN_SPAN_EVENT_TYPE = "run.performance.span"; -export const NATIVE_RUN_TRACE_SCHEMA_VERSION = 2; +export const NATIVE_RUN_TRACE_SCHEMA_VERSION = 3; export type NativeRunSpanOutcome = "ok" | "failed"; @@ -41,7 +41,7 @@ export function buildNativeWakeIngressSpan(input: { if (answeredAt !== null && Number.isFinite(answeredAt) && answeredAt >= 0) { return { name: "question_response.to_run_created", - parentName: "task.run", + parentName: "task.provider_session", startedAtMs: answeredAt, endedAtMs: Math.max(answeredAt, input.runCreatedAtMs), }; @@ -60,7 +60,7 @@ export function buildNativeWakeIngressSpan(input: { ? null : { name: "comment.to_run_created", - parentName: "task.run", + parentName: "task.provider_session", startedAtMs: createdAt, endedAtMs: Math.max(createdAt, input.runCreatedAtMs), }; @@ -77,13 +77,13 @@ export function buildNativeHeartbeatPreparationSpans(input: { return [ { name: "heartbeat.queue", - parentName: "task.run", + parentName: "task.provider_session", startedAtMs: input.runCreatedAtMs, endedAtMs: Math.max(input.runCreatedAtMs, input.runStartedAtMs), }, { name: "heartbeat.prepare_before_environment", - parentName: "task.run", + parentName: "task.provider_session", // A same-run resume retains startedAt for wall-time accounting; it is // not the beginning of this dispatch attempt's preparation. startedAtMs: input.attemptStartedAtMs, @@ -94,7 +94,7 @@ export function buildNativeHeartbeatPreparationSpans(input: { }, { name: "heartbeat.prepare_after_environment", - parentName: "task.run", + parentName: "task.provider_session", startedAtMs: Math.min( input.nativeDispatchAtMs, input.environmentRealizeEndedAtMs, @@ -210,7 +210,7 @@ export function createNativeRunTrace(input: { const runIdHash = hashedId(input.runId); let rootSpan: SpanHandle; try { - rootSpan = tracing.tracer.startSpan("task.run", { + rootSpan = tracing.tracer.startSpan("task.provider_session", { startTime: traceStartedAtMs, attributes: { "paperclip.task.run.run_id": runIdHash, @@ -249,19 +249,19 @@ export function createNativeRunTrace(input: { ? { name: active.name, context: active.context } : runtimeParentScope && !runtimeParentScope.ended ? { name: runtimeParentScope.name, context: runtimeParentScope.context } - : { name: "task.run", context: rootContext }; + : { name: "task.provider_session", context: rootContext }; }; const parentFor = ( parentName?: string, ): { name: string; context: unknown } => { - if (parentName && parentName !== "task.run") { + if (parentName && parentName !== "task.provider_session") { const named = namedScopes.get(parentName); if (named) return { name: named.name, context: named.context }; return currentParent(); } - if (parentName === "task.run") - return { name: "task.run", context: rootContext }; + if (parentName === "task.provider_session") + return { name: "task.provider_session", context: rootContext }; return currentParent(); }; @@ -298,7 +298,7 @@ export function createNativeRunTrace(input: { schema: "paperclip.run-performance-span.v1", traceSchemaVersion: NATIVE_RUN_TRACE_SCHEMA_VERSION, span: span.name, - parentSpan: span.parentName ?? "task.run", + parentSpan: span.parentName ?? "task.provider_session", startOffsetMs: startedAtMs - traceStartedAtMs, durationMs, outcome, @@ -508,8 +508,8 @@ export function createNativeRunTrace(input: { // Preserve the historical run-log record without emitting a duplicate // full-width child span into the OpenTelemetry waterfall. await emitCompletedSpanEvent({ - name: "task.run.measured", - parentName: "task.run", + name: "task.provider_session.measured", + parentName: "task.provider_session", startedAtMs: traceStartedAtMs, endedAtMs, outcome, @@ -541,7 +541,7 @@ export async function recordFailedSkillPreparation(input: { }): Promise { try { const trace = createNativeRunTrace(input); - const preparation = trace.start("task.prepare", { parentName: "task.run", startedAtMs: input.startedAtMs }); + const preparation = trace.start("task.prepare", { parentName: "task.provider_session", startedAtMs: input.startedAtMs }); const endedAtMs = Date.now(); await trace.record({ name: "skills.prepare", parentName: "task.prepare", startedAtMs: input.startedAtMs, endedAtMs, outcome: "failed" }); await trace.end(preparation, { endedAtMs, outcome: "failed" }); diff --git a/server/src/services/native-runtime/native-session-executor.test.ts b/server/src/services/native-runtime/native-session-executor.test.ts index 6ae27897a98..8a37a96d76f 100644 --- a/server/src/services/native-runtime/native-session-executor.test.ts +++ b/server/src/services/native-runtime/native-session-executor.test.ts @@ -5842,7 +5842,7 @@ describe("native resumed preparation timing", () => { const payloadFor = (span: string) => events.find((event) => event.payload?.span === span)?.payload; expect(payloadFor("question_response.to_run_created")).toMatchObject({ - parentSpan: "task.run", + parentSpan: "task.provider_session", durationMs: 50, startOffsetMs: 0, }); @@ -5850,7 +5850,7 @@ describe("native resumed preparation timing", () => { durationMs: 30, startOffsetMs: 70, }); - expect(payloadFor("task.run.measured")).toMatchObject({ durationMs: 100 }); + expect(payloadFor("task.provider_session.measured")).toMatchObject({ durationMs: 100 }); }); it("uses attempt-local preparation in the executor without truncating run elapsed time", async () => { @@ -5904,7 +5904,7 @@ describe("native resumed preparation timing", () => { durationMs: 1_000, startOffsetMs: 0, }); - expect(payloadFor("task.run.measured")).toMatchObject({ + expect(payloadFor("task.provider_session.measured")).toMatchObject({ durationMs: 984_050, }); }); diff --git a/server/src/services/native-runtime/native-session-executor.ts b/server/src/services/native-runtime/native-session-executor.ts index 6b5275fc9d5..81fca0cc84d 100644 --- a/server/src/services/native-runtime/native-session-executor.ts +++ b/server/src/services/native-runtime/native-session-executor.ts @@ -7691,7 +7691,7 @@ async function executePaperclipNativeSessionWithinScope( }); const toolTrace = createNativeToolTrace(trace); const taskPrepareScope = trace.start("task.prepare", { - parentName: "task.run", + parentName: "task.provider_session", startedAtMs: preparationStarts.preparationStartedAtMs, }); const environmentSpans = preparationSpans.filter( @@ -7719,7 +7719,7 @@ async function executePaperclipNativeSessionWithinScope( await trace.record({ ...span, parentName: rootMilestone - ? "task.run" + ? "task.provider_session" : environmentSpans.includes(span) ? "environment.startup" : "task.prepare", @@ -8280,7 +8280,7 @@ async function executePaperclipNativeSessionWithinScope( outcome, }); taskSettleScope = trace.start("task.settle", { - parentName: "task.run", + parentName: "task.provider_session", startedAtMs: milestoneAtMs, }); trace.activate(taskSettleScope); @@ -8950,7 +8950,7 @@ async function executePaperclipNativeSessionWithinScope( }); return result; }, - { parentName: "task.run" }, + { parentName: "task.provider_session" }, ); // A detached consumer can resolve successfully after its stream closes. // Only the replacement controller may settle the run or certify accounting. @@ -9054,7 +9054,7 @@ async function executePaperclipNativeSessionWithinScope( ).slice(-4_096); if (!taskSettleScope) { taskSettleScope = trace.start("task.settle", { - parentName: "task.run", + parentName: "task.provider_session", startedAtMs: failedAtMs, }); } diff --git a/server/src/services/native-runtime/native-workspace-sync.ts b/server/src/services/native-runtime/native-workspace-sync.ts index b6e1aeaf9ca..820f26aa9ee 100644 --- a/server/src/services/native-runtime/native-workspace-sync.ts +++ b/server/src/services/native-runtime/native-workspace-sync.ts @@ -1,3 +1,5 @@ +import type { WorkspaceCheckpointMetrics } from "@paperclipai/adapter-utils/workspace-checkpoint"; +import type { RuntimeSpanRunner } from "@paperclipai/adapter-utils/acpx-engine/startup-timing"; import fs from "node:fs/promises"; import { createReadStream } from "node:fs"; import path from "node:path"; @@ -31,8 +33,9 @@ import { parseObject } from "../../adapters/utils.js"; import type { NativeRestartRecoveryClaim } from "./native-restart-recovery.js"; const LEGACY_DESCRIPTOR_SCHEMA = "paperclip.native-workspace-sync/v1"; -const DESCRIPTOR_SCHEMA = "paperclip.native-workspace-sync/v2"; -type DescriptorSchema = typeof DESCRIPTOR_SCHEMA | typeof LEGACY_DESCRIPTOR_SCHEMA; +const PREVIOUS_DESCRIPTOR_SCHEMA = "paperclip.native-workspace-sync/v2"; +const DESCRIPTOR_SCHEMA = "paperclip.native-workspace-sync/v3"; +type DescriptorSchema = typeof DESCRIPTOR_SCHEMA | typeof PREVIOUS_DESCRIPTOR_SCHEMA | typeof LEGACY_DESCRIPTOR_SCHEMA; const STAMP_SCHEMA = "paperclip.native-workspace-stamp/v1"; const STATE_ROOT_NAME = "native-workspace-sync"; const DESCRIPTOR_NAME = "descriptor"; @@ -61,6 +64,7 @@ interface NativeWorkspaceSyncDescriptor { baselineSha256: string; baseline: SerializedDirectorySnapshot; gitSnapshot: GitWorkspaceSnapshot | null; + repositories?: NonNullable; seed: { workspaceArchiveSha256: string; gitArchiveSha256: string | null; @@ -190,6 +194,7 @@ async function verifiedDurableSeed(input: { runId: string; seed: NonNullable; gitSnapshot: GitWorkspaceSnapshot | null; + repositories?: NonNullable; }): Promise { try { const paths = durableSeedPaths(input.runId); @@ -203,7 +208,7 @@ async function verifiedDurableSeed(input: { ) { throw new Error("workspace_sync_out_unrecoverable"); } - if (input.gitSnapshot) { + if (input.gitSnapshot || input.repositories?.length) { const gitStat = await fs.lstat(paths.gitArchivePath); if ( gitStat.isSymbolicLink() || @@ -219,7 +224,7 @@ async function verifiedDurableSeed(input: { return { workspaceArchivePath: paths.workspaceArchivePath, workspaceArchiveSha256: input.seed.workspaceArchiveSha256, - gitArchivePath: input.gitSnapshot ? paths.gitArchivePath : null, + gitArchivePath: input.gitSnapshot || input.repositories?.length ? paths.gitArchivePath : null, gitArchiveSha256: input.seed.gitArchiveSha256, }; } catch (error) { @@ -281,7 +286,7 @@ export function readNativeWorkspaceSyncReference( ): NativeWorkspaceSyncReference | null { const candidate = parseObject(value); if ( - (candidate.schema !== DESCRIPTOR_SCHEMA && candidate.schema !== LEGACY_DESCRIPTOR_SCHEMA) || + (candidate.schema !== DESCRIPTOR_SCHEMA && candidate.schema !== PREVIOUS_DESCRIPTOR_SCHEMA && candidate.schema !== LEGACY_DESCRIPTOR_SCHEMA) || (candidate.state !== "prepared" && candidate.state !== "finalized") || typeof candidate.descriptorSha256 !== "string" || !/^[0-9a-f]{64}$/.test(candidate.descriptorSha256) || @@ -360,6 +365,7 @@ async function persistSnapshotManifests(runId: string, snapshot: NonNullable
();
   for (const manifest of manifests()) {
@@ -385,6 +391,7 @@ async function persistSnapshotManifests(runId: string, snapshot: NonNullable
 validManifestShape(entries) || (Array.isArray(entries) && entries.every((entry) =>
       typeof entry === "string" && entry.length > 0 && !entry.includes("\0") && !path.posix.isAbsolute(entry) && !path.win32.isAbsolute(entry)
       && !entry.split(/[\\/]/).some((segment) => segment === ".." || segment === "."))))) return undefined;
@@ -414,6 +421,7 @@ function parseGitSnapshot(
   return {
     headCommit: candidate.headCommit,
     branchName: candidate.branchName as string | null,
+    ...(candidate.originUrl !== undefined ? { originUrl: candidate.originUrl as string | null } : {}),
     overlayPaths: candidate.overlayPaths as WorkspacePaths,
     deletedPaths: candidate.deletedPaths as WorkspacePaths,
     ignoredPaths: candidate.ignoredPaths as WorkspacePaths,
@@ -464,7 +472,7 @@ async function readDescriptor(input: {
   const binding = parseObject(candidate.binding);
   const rawBaseline = parseObject(candidate.baseline);
   let baseline: DirectorySnapshot | null;
-  if (candidate.schema === DESCRIPTOR_SCHEMA && rawBaseline.version === 2
+  if ((candidate.schema === DESCRIPTOR_SCHEMA || candidate.schema === PREVIOUS_DESCRIPTOR_SCHEMA) && rawBaseline.version === 2
     && Array.isArray(rawBaseline.exclude) && rawBaseline.exclude.every((entry) => typeof entry === "string")
     && validManifestShape(rawBaseline.entries) && rawBaseline.entries.category === "baseline"
     && (rawBaseline.ignoredPaths === undefined || validManifestShape(rawBaseline.ignoredPaths))) {
@@ -475,6 +483,12 @@ async function readDescriptor(input: {
     baseline = parseDirectorySnapshot(candidate.baseline);
   }
   const gitSnapshot = parseGitSnapshot(candidate.gitSnapshot);
+  const repositoryContainer = candidate.repositories === undefined ? null : parseGitSnapshot({
+    headCommit: "", branchName: null, overlayPaths: [], deletedPaths: [], ignoredPaths: [], repositories: candidate.repositories,
+  });
+  if (candidate.repositories !== undefined && (!repositoryContainer || candidate.schema !== DESCRIPTOR_SCHEMA || gitSnapshot)) throw new Error("native_workspace_sync_repositories_invalid");
+  const repositories = repositoryContainer?.repositories;
+  for (const repo of repositories ?? []) for (const manifest of gitManifests(repo.snapshot)) await verifyManifest(input.runId, manifest);
   for (const manifest of gitManifests(gitSnapshot ?? null)) await verifyManifest(input.runId, manifest);
   const rawSeed =
     candidate.seed === null || candidate.seed === undefined
@@ -509,7 +523,7 @@ async function readDescriptor(input: {
         ? null
         : undefined;
   if (
-    (candidate.schema !== DESCRIPTOR_SCHEMA && candidate.schema !== LEGACY_DESCRIPTOR_SCHEMA) ||
+    (candidate.schema !== DESCRIPTOR_SCHEMA && candidate.schema !== PREVIOUS_DESCRIPTOR_SCHEMA && candidate.schema !== LEGACY_DESCRIPTOR_SCHEMA) ||
     (candidate.state !== "prepared" && candidate.state !== "finalized") ||
     !baseline ||
     gitSnapshot === undefined ||
@@ -560,6 +574,7 @@ async function readDescriptor(input: {
       baselineSha256: candidate.baselineSha256,
       baseline: candidate.baseline as SerializedDirectorySnapshot,
       gitSnapshot,
+      ...(repositories?.length ? { repositories } : {}),
       seed,
       createdAt: candidate.createdAt,
       finalizedAt:
@@ -681,6 +696,7 @@ async function remoteStampMatches(input: {
   target: Extract;
   expected: Record;
   gitSnapshot?: GitWorkspaceSnapshot | null;
+  repositories?: NonNullable;
 }): Promise {
   if (!input.target.runner) return false;
   const stampPath = path.posix.join(
@@ -699,11 +715,15 @@ async function remoteStampMatches(input: {
     gitChecks.push(snapshot.branchName === null
       ? `(${git} symbolic-ref --quiet HEAD >/dev/null 2>&1; test $? -eq 1)`
       : `test "$(${git} symbolic-ref --quiet --short HEAD)" = ${shellQuote(snapshot.branchName)}`);
+    if (snapshot.originUrl !== undefined) gitChecks.push(snapshot.originUrl === null
+      ? `! ${git} config --get remote.origin.url >/dev/null 2>&1`
+      : `test "$(${git} config --get remote.origin.url)" = ${shellQuote(snapshot.originUrl)}`);
     for (const repository of snapshot.repositories ?? []) {
       checkGit(path.posix.join(remoteDir, repository.path), repository.snapshot);
     }
   };
   if (input.gitSnapshot) checkGit(input.target.remoteCwd, input.gitSnapshot);
+  for (const repo of input.repositories ?? []) checkGit(path.posix.join(input.target.remoteCwd, repo.path), repo.snapshot);
   const result = await input.target.runner.execute({
     command: input.target.shellCommand ?? "sh",
     args: [
@@ -767,7 +787,11 @@ async function prepareRuntime(input: {
   mode: WorkspaceInboundMode;
   baseline?: DirectorySnapshot;
   gitSnapshot?: GitWorkspaceSnapshot | null;
+  repositories?: NonNullable;
   durableSeed?: WorkspaceDurableSeedPaths;
+  seedCacheDirectory?: string;
+  onPhase?: RuntimeSpanRunner;
+  onCheckpoint?: (metrics: WorkspaceCheckpointMetrics) => Promise;
 }): Promise {
   return prepareAdapterExecutionTargetRuntime({
     runId: input.runId,
@@ -779,6 +803,11 @@ async function prepareRuntime(input: {
     workspaceDurableSeed: input.durableSeed,
     workspaceBaseline: input.baseline,
     workspaceGitSnapshot: input.gitSnapshot,
+    workspaceRepositories: input.repositories ?? (input.baseline ? [] : undefined),
+    workspaceCheckpoint: true,
+    onWorkspaceCheckpoint: input.onCheckpoint,
+    workspaceSeedCacheDirectory: input.seedCacheDirectory,
+    runtimeSpan: input.onPhase,
   });
 }
 
@@ -789,8 +818,11 @@ async function finalizePreparedRuntime(input: {
   runtime: PreparedAdapterExecutionTargetRuntime;
   descriptor: NativeWorkspaceSyncDescriptor;
   assertOwnership?: () => Promise;
+  onPhase?: RuntimeSpanRunner;
+  onCheckpoint?: (metrics: WorkspaceCheckpointMetrics) => Promise;
 }): Promise {
-  await input.runtime.restoreWorkspace();
+  if (input.onPhase) await input.onPhase("workspace.restore", () => input.runtime.restoreWorkspace());
+  else await input.runtime.restoreWorkspace();
   await input.assertOwnership?.();
   const finalSnapshot =
     await import("@paperclipai/adapter-utils/workspace-restore-merge").then(
@@ -836,6 +868,8 @@ export async function prepareNativeWorkspaceSync(input: {
   restartRecovery?: NativeRestartRecoveryClaim;
   sameRunRecovery?: boolean;
   resourceDisposition?: NativeWorkspaceResourceDisposition;
+  onPhase?: RuntimeSpanRunner;
+  onCheckpoint?: (metrics: WorkspaceCheckpointMetrics) => Promise;
 }): Promise {
   if (input.target?.kind !== "remote" || input.target.transport !== "sandbox") {
     return null;
@@ -896,6 +930,7 @@ export async function prepareNativeWorkspaceSync(input: {
               runId: input.runId,
               seed: existing.descriptor.seed,
               gitSnapshot: existing.descriptor.gitSnapshot,
+              repositories: existing.descriptor.repositories,
             })
           : (() => {
               throw new Error("workspace_sync_out_unrecoverable");
@@ -904,10 +939,13 @@ export async function prepareNativeWorkspaceSync(input: {
     runtime = await prepareRuntime({
       runId: input.runId,
       target,
+      onPhase: input.onPhase,
+      onCheckpoint: input.onCheckpoint,
       workspaceLocalDir: input.workspaceLocalDir,
       mode,
       baseline: existing.baseline,
       gitSnapshot: existing.descriptor.gitSnapshot,
+      repositories: existing.descriptor.repositories,
       durableSeed,
     });
     descriptor = {
@@ -934,8 +972,11 @@ export async function prepareNativeWorkspaceSync(input: {
     runtime = await prepareRuntime({
       runId: input.runId,
       target,
+      onPhase: input.onPhase,
+      onCheckpoint: input.onCheckpoint,
       workspaceLocalDir: input.workspaceLocalDir,
       mode,
+      seedCacheDirectory: path.join(resolvePaperclipInstanceRoot(), STATE_ROOT_NAME, "generations", requireSafeSegment(input.companyId, "company_id"), requireSafeSegment(input.workspaceId, "workspace_id")),
       durableSeed: {
         workspaceArchivePath: seedPaths.workspaceArchivePath,
         gitArchivePath: seedPaths.gitArchivePath,
@@ -951,7 +992,7 @@ export async function prepareNativeWorkspaceSync(input: {
       );
       const verifiedWarmAdoption =
         priorStamp.hostSha256 === currentHostSha256 &&
-        (await remoteStampMatches({ target, expected: priorStamp, gitSnapshot: currentSnapshot.gitSnapshot }));
+        (await remoteStampMatches({ target, expected: priorStamp, gitSnapshot: currentSnapshot.gitSnapshot, repositories: currentSnapshot.repositories }));
       if (verifiedWarmAdoption) {
         mode = "adopt_remote";
       } else {
@@ -963,6 +1004,9 @@ export async function prepareNativeWorkspaceSync(input: {
           mode,
           baseline: currentSnapshot.baseline,
           gitSnapshot: currentSnapshot.gitSnapshot,
+          repositories: currentSnapshot.repositories,
+          onPhase: input.onPhase,
+          onCheckpoint: input.onCheckpoint,
           durableSeed: {
             workspaceArchivePath: seedPaths.workspaceArchivePath,
             gitArchivePath: seedPaths.gitArchivePath,
@@ -977,7 +1021,7 @@ export async function prepareNativeWorkspaceSync(input: {
     const workspaceArchiveSha256 = await sha256File(
       seedPaths.workspaceArchivePath,
     );
-    const gitArchiveSha256 = snapshot.gitSnapshot
+    const gitArchiveSha256 = snapshot.gitSnapshot || snapshot.repositories?.length
       ? await sha256File(seedPaths.gitArchivePath)
       : null;
     descriptor = {
@@ -995,6 +1039,7 @@ export async function prepareNativeWorkspaceSync(input: {
       baselineSha256: directorySnapshotSha256(snapshot.baseline),
       baseline: serializeDirectorySnapshot(snapshot.baseline),
       gitSnapshot: snapshot.gitSnapshot,
+      ...(snapshot.repositories?.length ? { repositories: snapshot.repositories } : {}),
       seed: { workspaceArchiveSha256, gitArchiveSha256 },
       createdAt: now,
       finalizedAt: null,
@@ -1021,6 +1066,8 @@ export async function prepareNativeWorkspaceSync(input: {
           runtime: preparedRuntime,
           descriptor,
           assertOwnership,
+          onPhase: input.onPhase,
+          onCheckpoint: input.onCheckpoint,
         })
           .then((finalizedReference) => {
             reference = finalizedReference;
@@ -1046,6 +1093,8 @@ export async function resumeNativeWorkspaceSync(input: {
   runId: string;
   target: AdapterExecutionTarget;
   assertOwnership?: () => Promise;
+  onPhase?: RuntimeSpanRunner;
+  onCheckpoint?: (metrics: WorkspaceCheckpointMetrics) => Promise;
 }): Promise {
   if (input.target.kind !== "remote" || input.target.transport !== "sandbox") {
     throw new Error("workspace_sync_out_unrecoverable");
@@ -1094,10 +1143,13 @@ export async function resumeNativeWorkspaceSync(input: {
   const runtime = await prepareRuntime({
     runId: input.runId,
     target: input.target,
+    onPhase: input.onPhase,
+    onCheckpoint: input.onCheckpoint,
     workspaceLocalDir: existing.descriptor.binding.localCwd,
     mode: "adopt_remote",
     baseline: existing.baseline,
     gitSnapshot: existing.descriptor.gitSnapshot,
+    repositories: existing.descriptor.repositories,
   });
   await finalizePreparedRuntime({
     db: input.db,
@@ -1106,6 +1158,8 @@ export async function resumeNativeWorkspaceSync(input: {
     runtime,
     descriptor: existing.descriptor,
     assertOwnership: input.assertOwnership,
+    onPhase: input.onPhase,
+    onCheckpoint: input.onCheckpoint,
   });
   return true;
 }
diff --git a/server/src/services/native-runtime/paperclip-runner-tool-authority.ts b/server/src/services/native-runtime/paperclip-runner-tool-authority.ts
index de950a6b7ab..5d9a812a0c4 100644
--- a/server/src/services/native-runtime/paperclip-runner-tool-authority.ts
+++ b/server/src/services/native-runtime/paperclip-runner-tool-authority.ts
@@ -1,3 +1,4 @@
+import { callTaskWorkspaceTool } from "../task-workspace-tools.js";
 import { TaskAttachmentReadCache, TASK_ATTACHMENT_DEFINITIONS, TASK_ATTACHMENT_SCHEMAS, listTaskAttachments, readTaskAttachment } from "./runner-task-attachments.js";
 import { RUNNER_BRIDGE_SCHEMAS, runnerBridgeDefinitions, readAssignedSkill, executeWorkspaceTool, readWorkspaceUploadFile, settleRunnerBridgeRead, assertRunnerWorkspacePathAllowed } from "./runner-bridge-tools.js";
 import type { NativeRuntimeContextSnapshot } from "../../vendor/paperclip-runner/index.js";
@@ -110,7 +111,7 @@ const IMPLEMENTED_OPERATIONS = new Set([
   "search_api", "call_api", "hire_agent",
   "get_task_context", "get_task_history", "search_tasks", "report_progress", "set_task_title", "set_task_monitor",
   "request_human_input",
-  "create_skill", "update_skill", "create_task", "reassign_task", "set_dependencies", "create_project", "list_project_repositories", "list_projects", "register_deliverable",
+  "get_workspace", "list_workspaces", "select_workspace", "prepare_repository", "create_skill", "update_skill", "create_task", "reassign_task", "set_dependencies", "create_project", "list_project_repositories", "list_projects", "register_deliverable",
   "list_documents", "read_document", "list_document_revisions", "write_document",
   "list_agents", "get_agent", "list_approvals", "get_approval", "get_approval_context",
 ]);
@@ -507,6 +508,15 @@ export class PaperclipRunnerToolAuthority {
         if (!apiUrl || !token) throw new Error("Skill tool authentication is unavailable");
         return callUpdateSkillTool({ arguments: input, apiUrl, token, companyId: this.binding.companyId });
       }
+      case "get_workspace":
+      case "list_workspaces":
+      case "select_workspace":
+      case "prepare_repository": {
+        const apiUrl = this.binding.apiUrl ?? process.env.PAPERCLIP_API_URL;
+        const token = createLocalAgentJwt(this.binding.agentId, this.binding.companyId, context.actor.adapterType, this.binding.runId, context.run.responsibleUserId);
+        if (!apiUrl || !token) throw new Error("Workspace tool authentication is unavailable");
+        return callTaskWorkspaceTool({ name: call.tool, arguments: input, apiUrl, token, companyId: this.binding.companyId, issueId: this.binding.issueId, workspaceRoot: this.binding.workspaceRoot });
+      }
       case "create_project":
       case "list_project_repositories":
       case "list_projects": {
diff --git a/server/src/services/projects.ts b/server/src/services/projects.ts
index b5cd4d2445a..7e823a61d73 100644
--- a/server/src/services/projects.ts
+++ b/server/src/services/projects.ts
@@ -2,6 +2,7 @@ import { and, asc, desc, eq, gt, inArray, isNull, or, sql } from "drizzle-orm";
 import type { Db } from "@paperclipai/db";
 import {
   projects,
+  executionWorkspaces,
   projectAccessMembers,
   projectGoals,
   goals,
@@ -30,7 +31,7 @@ import {
   type PluginManagedProjectDeclaration,
   type PluginManagedProjectResolution,
 } from "@paperclipai/shared";
-import { unprocessable } from "../errors.js";
+import { conflict, unprocessable } from "../errors.js";
 import { listCurrentRuntimeServicesForProjectWorkspaces } from "./workspace-runtime-read-model.js";
 import { parseProjectExecutionWorkspacePolicy } from "./execution-workspace-policy.js";
 import { mergeProjectWorkspaceRuntimeConfig, readProjectWorkspaceRuntimeConfig } from "./project-workspace-runtime-config.js";
@@ -1054,16 +1055,13 @@ export function projectService(db: Db, options: { captureWorkspaceUpdates?: bool
       return cleared;
     },
 
-    remove: (id: string) =>
-      db
-        .delete(projects)
-        .where(eq(projects.id, id))
-        .returning()
-        .then((rows) => {
-          const row = rows[0] ?? null;
-          if (!row) return null;
-          return { ...row, urlKey: deriveProjectUrlKey(row.name, row.id) };
-        }),
+    remove: async (id: string) => {
+      const [retained] = await db.select({ id: executionWorkspaces.id }).from(executionWorkspaces)
+        .where(eq(executionWorkspaces.projectId, id)).limit(1);
+      if (retained) throw conflict("This project still supplies access policy for retained task workspaces. Archive the project or explicitly clean up its workspaces before deleting it.");
+      const [row] = await db.delete(projects).where(eq(projects.id, id)).returning();
+      return row ? { ...row, urlKey: deriveProjectUrlKey(row.name, row.id) } : null;
+    },
 
     listWorkspaces: async (projectId: string): Promise => {
       const rows = await db
diff --git a/server/src/services/runtime-service-env.ts b/server/src/services/runtime-service-env.ts
new file mode 100644
index 00000000000..ae196738b40
--- /dev/null
+++ b/server/src/services/runtime-service-env.ts
@@ -0,0 +1,18 @@
+export function sanitizeRuntimeServiceBaseEnv(baseEnv: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
+  const env: NodeJS.ProcessEnv = { ...baseEnv };
+  for (const key of Object.keys(env)) {
+    if (key.startsWith("PAPERCLIP_")) {
+      delete env[key];
+    }
+  }
+  // These origin settings belong to the parent instance. Letting them leak into a
+  // managed worktree runtime can send auth cookies and OAuth callbacks to the wrong
+  // Paperclip instance. Runtime/service overrides are merged back after sanitizing.
+  delete env.BETTER_AUTH_URL;
+  delete env.BETTER_AUTH_BASE_URL;
+  delete env.DATABASE_URL;
+  delete env.npm_config_tailscale_auth;
+  delete env.npm_config_authenticated_private;
+  return env;
+}
+
diff --git a/server/src/services/task-workspace-tools.ts b/server/src/services/task-workspace-tools.ts
new file mode 100644
index 00000000000..06b6658fd0c
--- /dev/null
+++ b/server/src/services/task-workspace-tools.ts
@@ -0,0 +1,38 @@
+import { prepareWorkspaceRepositorySchema, selectTaskWorkspaceSchema } from "@paperclipai/shared";
+import { badRequest } from "../errors.js";
+
+function repositoryView(repo: Record) {
+  return { id: repo.id, repoUrl: repo.repoUrl, relativePath: repo.relativePath, requestedRef: repo.requestedRef,
+    state: repo.state, pinnedCommit: repo.pinnedCommit, branchName: repo.branchName };
+}
+
+/** Transport only: authorization, receipts, and workspace decisions remain in ordinary APIs. */
+export async function callTaskWorkspaceTool(input: { name: string; arguments: Record; apiUrl: string; token: string; companyId: string; issueId: string; workspaceRoot?: string }) {
+  let route = `/issues/${input.issueId}/workspace`;
+  let method = "GET";
+  let body: unknown;
+  switch (input.name) {
+    case "get_workspace": break;
+    case "list_workspaces": route = `/companies/${input.companyId}/execution-workspaces?summary=true&reuseEligible=true`; break;
+    case "select_workspace": method = "PUT"; body = selectTaskWorkspaceSchema.parse(input.arguments); break;
+    case "prepare_repository": method = "POST"; route += "/repositories"; body = prepareWorkspaceRepositorySchema.parse(input.arguments); break;
+    default: throw badRequest("Unknown workspace tool");
+  }
+  const response = await fetch(`${input.apiUrl.replace(/\/+$/, "").replace(/\/api$/, "")}/api${route}`, { method, headers: { Authorization: `Bearer ${input.token}`, "Content-Type": "application/json" }, ...(body ? { body: JSON.stringify(body) } : {}), signal: AbortSignal.timeout(60_000) });
+  const result = await response.json();
+  if (!response.ok) throw new Error(typeof result.error === "string" ? result.error : `Workspace tool failed (${response.status})`);
+  if (input.name === "prepare_repository") {
+    const { operationId, ...rest } = result;
+    // PRP reserves operationId for the semantic operation name.
+    return { ...rest, ...(result.repository ? { repository: repositoryView(result.repository) } : {}), preparationId: operationId };
+  }
+  if (input.name === "get_workspace") {
+    const workspace = result.workspace;
+    return { issueId: result.issueId, bindingRevision: result.bindingRevision, selection: result.selection,
+      pendingSelection: result.pendingSelection, cwd: input.workspaceRoot ?? null,
+      workspace: workspace ? { id: workspace.id, projectId: workspace.projectId, mode: workspace.mode, status: workspace.status } : null,
+      repositories: (result.repositories ?? []).map(repositoryView),
+      capabilities: result.capabilities, fileLocations: result.fileLocations };
+  }
+  return result;
+}
diff --git a/server/src/services/workspace-file-resources.ts b/server/src/services/workspace-file-resources.ts
index 9b57cfe3458..1ee19ee5917 100644
--- a/server/src/services/workspace-file-resources.ts
+++ b/server/src/services/workspace-file-resources.ts
@@ -1094,7 +1094,7 @@ export function workspaceFileResourceService(db: Db, actor?: AuthorizationActor)
     if (explicitTarget) return [explicitTarget];
 
     const candidates: WorkspaceCandidate[] = [];
-    if ((selector === "auto" || selector === "execution") && issue.projectId) {
+    if (selector === "auto" || selector === "execution") {
       const executionIds = [issue.executionWorkspaceId].filter((id): id is string => Boolean(id));
       let executionRows: ExecutionWorkspaceRow[] = [];
       if (executionIds.length > 0) {
@@ -1110,7 +1110,6 @@ export function workspaceFileResourceService(db: Db, actor?: AuthorizationActor)
         const activeRows = await db.select().from(executionWorkspaces).where(
           and(
             eq(executionWorkspaces.companyId, issue.companyId),
-            eq(executionWorkspaces.projectId, issue.projectId),
             inArray(executionWorkspaces.sourceIssueId, sourceIssueIds),
             eq(executionWorkspaces.status, "active"),
             isNull(executionWorkspaces.closedAt),
@@ -1126,24 +1125,23 @@ export function workspaceFileResourceService(db: Db, actor?: AuthorizationActor)
       }
     }
 
-    if ((selector === "auto" || selector === "project") && issue.projectId) {
+    if ((selector === "auto" || selector === "project") && (issue.projectId || issue.projectWorkspaceId)) {
       if (issue.projectWorkspaceId) {
         const rows = await db.select().from(projectWorkspaces).where(
           and(
             eq(projectWorkspaces.companyId, issue.companyId),
-            eq(projectWorkspaces.projectId, issue.projectId),
             eq(projectWorkspaces.id, issue.projectWorkspaceId),
           ),
         ).limit(1);
         if (rows[0]) candidates.push(candidateFromProjectWorkspace(rows[0]));
       }
-      const primaryRows = await db.select().from(projectWorkspaces).where(
+      const primaryRows = issue.projectId ? await db.select().from(projectWorkspaces).where(
         and(
           eq(projectWorkspaces.companyId, issue.companyId),
           eq(projectWorkspaces.projectId, issue.projectId),
           eq(projectWorkspaces.isPrimary, true),
         ),
-      ).limit(1);
+      ).limit(1) : [];
       if (primaryRows[0] && !candidates.some((candidate) => candidate.workspaceId === primaryRows[0]!.id)) {
         candidates.push(candidateFromProjectWorkspace(primaryRows[0]));
       }
diff --git a/server/src/services/workspace-runtime.ts b/server/src/services/workspace-runtime.ts
index 5c161536697..f8351fb7b14 100644
--- a/server/src/services/workspace-runtime.ts
+++ b/server/src/services/workspace-runtime.ts
@@ -1,3 +1,5 @@
+import { sanitizeRuntimeServiceBaseEnv } from "./runtime-service-env.js";
+export { sanitizeRuntimeServiceBaseEnv } from "./runtime-service-env.js";
 import { execFile, spawn, type ChildProcess } from "node:child_process";
 import { existsSync, lstatSync, readdirSync, readFileSync, realpathSync } from "node:fs";
 import fs from "node:fs/promises";
@@ -681,23 +683,6 @@ export async function ensureServerWorkspaceLinksCurrent(
   );
 }
 
-export function sanitizeRuntimeServiceBaseEnv(baseEnv: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
-  const env: NodeJS.ProcessEnv = { ...baseEnv };
-  for (const key of Object.keys(env)) {
-    if (key.startsWith("PAPERCLIP_")) {
-      delete env[key];
-    }
-  }
-  // These origin settings belong to the parent instance. Letting them leak into a
-  // managed worktree runtime can send auth cookies and OAuth callbacks to the wrong
-  // Paperclip instance. Runtime/service overrides are merged back after sanitizing.
-  delete env.BETTER_AUTH_URL;
-  delete env.BETTER_AUTH_BASE_URL;
-  delete env.DATABASE_URL;
-  delete env.npm_config_tailscale_auth;
-  delete env.npm_config_authenticated_private;
-  return env;
-}
 
 function stableRuntimeServiceId(input: {
   adapterType: string;
@@ -3781,8 +3766,8 @@ export async function ensurePersistedExecutionWorkspaceAvailable(input: {
   const realized: RealizedExecutionWorkspace = {
     baseCwd: input.base.baseCwd,
     source: input.workspace.mode === "shared_workspace" ? "project_primary" : "task_session",
-    projectId: input.workspace.projectId ?? input.base.projectId,
-    workspaceId: input.workspace.projectWorkspaceId ?? input.base.workspaceId,
+    projectId: input.workspace.projectId === undefined ? input.base.projectId : input.workspace.projectId,
+    workspaceId: input.workspace.projectWorkspaceId === undefined ? input.base.workspaceId : input.workspace.projectWorkspaceId,
     repoUrl: input.workspace.repoUrl ?? input.base.repoUrl,
     repoRef: input.workspace.baseRef ?? input.base.repoRef,
     additionalWorkspaces: input.base.additionalWorkspaces ?? [],
diff --git a/skills/paperclip/SKILL.md b/skills/paperclip/SKILL.md
index f2ec350c7da..600a94c3452 100644
--- a/skills/paperclip/SKILL.md
+++ b/skills/paperclip/SKILL.md
@@ -544,6 +544,12 @@ Routines are recurring tasks. Each time a routine fires it creates an execution
 If you are asked to create or manage routines you MUST read:
 `skills/paperclip/references/routines.md`
 
+## Task Files and Workspace Choices
+
+Use the task's provided working directory for task files. `AGENT_HOME` holds personal instructions and memory; provider home is runtime-managed. A task does not need a project or Git repository. Do not clone unrelated repositories or place task output in home to compensate for a missing project.
+
+Use `get_workspace` to inspect the current folder, pending selection, and capabilities. Use `list_workspaces` for authorized existing folders and `list_project_repositories` for repository choices. `select_workspace` and `prepare_repository` express intent; the harness validates and prepares it. Respect `next_normal_admission` responses: keep the current root, do not claim the requested checkout exists, and do not retry in a loop or interrupt the run. Project assignment alone does not relocate files. See `references/issue-workspaces.md` for the API equivalents.
+
 ## Issue Workspace Runtime Controls
 
 When an issue needs browser/manual QA or a preview server, inspect its current execution workspace and use Paperclip's workspace runtime controls instead of starting unmanaged background servers yourself.
diff --git a/skills/paperclip/references/issue-workspaces.md b/skills/paperclip/references/issue-workspaces.md
index 41f5e62c9b7..6442910537d 100644
--- a/skills/paperclip/references/issue-workspaces.md
+++ b/skills/paperclip/references/issue-workspaces.md
@@ -1,6 +1,6 @@
 # Issue Workspace Runtime Controls
 
-Use this reference when an issue has an isolated execution workspace and you need to inspect or run that workspace's services, especially for QA/browser verification.
+Use this reference to choose task file placement or inspect and run workspace services. A workspace may be a plain task directory without a project or Git repository.
 
 ## Discover the Workspace
 
@@ -20,6 +20,20 @@ Read `currentExecutionWorkspace`:
 
 If `currentExecutionWorkspace` is `null`, the issue does not currently have a realized execution workspace. For child/follow-up work, create the child with `parentId` or use `inheritExecutionWorkspaceFromIssueId` so Paperclip preserves workspace continuity.
 
+## Choose Files and Repositories
+
+`GET /api/issues/:issueId/workspace` returns the current binding, revision, pending selection, repository inventory, and available preparation capabilities. The Runner equivalents are `get_workspace` and `list_workspaces`.
+
+`PUT /api/issues/:issueId/workspace` accepts a stable `requestKey`, the inspected `expectedBindingRevision`, and one typed `selection`:
+
+- `{ "kind": "task_directory" }`: the task's separate directory.
+- `{ "kind": "existing", "workspaceId": "" }`: intentionally reuse a workspace.
+- `{ "kind": "configured_source", "projectWorkspaceId": "", "mode": "shared" }`: use its configured folder; `managed_isolated` requests an isolated checkout.
+
+`POST /api/issues/:issueId/workspace/repositories` accepts a stable `requestKey`, optional `ref`, and `repository: { kind: "catalog", id: "" }`. Discover IDs with `list_project_repositories`. Existing public HTTPS GitHub URLs can use `{ kind: "url", url: "https://github.com/owner/repo" }`; URLs do not grant private credentials or create a remote repository.
+
+Root selections and repository preparation return `next_normal_admission`. The active process retains its root. Do not claim a queued checkout is ready or repeatedly request it. A different ref cannot silently reset an existing checkout. New noncoding work can proceed immediately in task files without any repository. Explicitly shared folders remain shared; do not remove them when finishing one task.
+
 ## Control Services
 
 Prefer Paperclip-managed runtime service controls over manual `pnpm dev &` or ad-hoc background processes. These endpoints keep service state, URLs, logs, and ownership visible to other agents and the board.
diff --git a/ui/src/api/chatEndpoints.ts b/ui/src/api/chatEndpoints.ts
index 6190811c122..b5b3eb5f258 100644
--- a/ui/src/api/chatEndpoints.ts
+++ b/ui/src/api/chatEndpoints.ts
@@ -35,6 +35,7 @@ export type ChatEndpointSetupAction =
   "configure" | "verify" | "pause" | "resume" | "reconnect" | "remove";
 
 export interface ChatEndpointResource {
+  executionDefaults?: import("@paperclipai/shared").ChatExecutionDefaults | null;
   metadata?: Record;
   id: string;
   type: string;
@@ -100,6 +101,7 @@ export interface ChatIdentityLinkPreview {
 }
 
 export interface ChatEndpoint {
+  executionDefaults?: import("@paperclipai/shared").ChatExecutionDefaults | null;
   communicationInstructions?: string;
   publicationMode?: "automatic" | "explicit";
   externalExecutionPolicy?: "restricted" | "agent";
@@ -242,7 +244,7 @@ export const chatEndpointsApi = {
     ),
   updateResources: (
     endpointId: string,
-    resources: Array<{ id: string; enabled: boolean }>,
+    resources: Array<{ id: string; enabled?: boolean; executionDefaults?: import("@paperclipai/shared").ChatExecutionDefaults | null }>,
   ) =>
     api.put(`/chat-endpoints/${endpointId}/resources`, {
       resources,
diff --git a/ui/src/components/ProjectWorkspaceSummaryCard.tsx b/ui/src/components/ProjectWorkspaceSummaryCard.tsx
index c126b390be5..09570bd29df 100644
--- a/ui/src/components/ProjectWorkspaceSummaryCard.tsx
+++ b/ui/src/components/ProjectWorkspaceSummaryCard.tsx
@@ -21,7 +21,7 @@ function truncatePath(path: string) {
 }
 
 interface ProjectWorkspaceSummaryCardProps {
-  projectRef: string;
+  projectRef: string | null;
   summary: ProjectWorkspaceSummary;
   runtimeActionKey: string | null;
   runtimeActionPending: boolean;
@@ -50,7 +50,7 @@ export function ProjectWorkspaceSummaryCard({
   const visibleIssues = summary.issues.slice(0, 4);
   const hiddenIssueCount = Math.max(summary.linkedIssueCount - visibleIssues.length, 0);
   const workspaceHref =
-    summary.kind === "project_workspace"
+    summary.kind === "project_workspace" && projectRef
       ? projectWorkspaceUrl({ id: projectRef, urlKey: projectRef }, summary.workspaceId)
       : `/execution-workspaces/${summary.workspaceId}`;
   const hasRunningServices = summary.runningServiceCount > 0;
diff --git a/ui/src/components/ProjectWorkspacesContent.tsx b/ui/src/components/ProjectWorkspacesContent.tsx
index 7ffd3cba00a..2414ca6dca8 100644
--- a/ui/src/components/ProjectWorkspacesContent.tsx
+++ b/ui/src/components/ProjectWorkspacesContent.tsx
@@ -15,8 +15,8 @@ export function ProjectWorkspacesContent({
   summaries,
 }: {
   companyId: string;
-  projectId: string;
-  projectRef: string;
+  projectId: string | null;
+  projectRef: string | null;
   summaries: ProjectWorkspaceSummary[];
 }) {
   const queryClient = useQueryClient();
@@ -35,6 +35,7 @@ export function ProjectWorkspacesContent({
     }) => {
       setRuntimeActionKey(`${input.key}:${input.action}`);
       if (input.kind === "project_workspace") {
+        if (!projectId) throw new Error("Project workspace has no project");
         return await projectsApi.controlWorkspaceRuntimeServices(projectId, input.workspaceId, input.action, companyId);
       }
       return await executionWorkspacesApi.controlRuntimeServices(input.workspaceId, input.action);
@@ -43,11 +44,11 @@ export function ProjectWorkspacesContent({
       setRuntimeActionKey(null);
       queryClient.invalidateQueries({ queryKey: queryKeys.executionWorkspaces.overview(companyId) });
       queryClient.invalidateQueries({ queryKey: queryKeys.executionWorkspaces.list(companyId) });
-      queryClient.invalidateQueries({ queryKey: queryKeys.executionWorkspaces.list(companyId, { projectId }) });
-      queryClient.invalidateQueries({ queryKey: queryKeys.projects.detail(projectId) });
+      if (projectId) queryClient.invalidateQueries({ queryKey: queryKeys.executionWorkspaces.list(companyId, { projectId }) });
+      if (projectId) queryClient.invalidateQueries({ queryKey: queryKeys.projects.detail(projectId) });
       queryClient.invalidateQueries({ queryKey: queryKeys.projects.all(companyId) });
       queryClient.invalidateQueries({ queryKey: queryKeys.issues.list(companyId) });
-      queryClient.invalidateQueries({ queryKey: queryKeys.issues.listByProject(companyId, projectId) });
+      if (projectId) queryClient.invalidateQueries({ queryKey: queryKeys.issues.listByProject(companyId, projectId) });
     },
   });
 
@@ -107,11 +108,11 @@ export function ProjectWorkspacesContent({
           onClosed={() => {
             queryClient.invalidateQueries({ queryKey: queryKeys.executionWorkspaces.overview(companyId) });
             queryClient.invalidateQueries({ queryKey: queryKeys.executionWorkspaces.list(companyId) });
-            queryClient.invalidateQueries({ queryKey: queryKeys.executionWorkspaces.list(companyId, { projectId }) });
-            queryClient.invalidateQueries({ queryKey: queryKeys.projects.detail(projectId) });
+            if (projectId) queryClient.invalidateQueries({ queryKey: queryKeys.executionWorkspaces.list(companyId, { projectId }) });
+            if (projectId) queryClient.invalidateQueries({ queryKey: queryKeys.projects.detail(projectId) });
             queryClient.invalidateQueries({ queryKey: queryKeys.projects.all(companyId) });
             queryClient.invalidateQueries({ queryKey: queryKeys.issues.list(companyId) });
-            queryClient.invalidateQueries({ queryKey: queryKeys.issues.listByProject(companyId, projectId) });
+            if (projectId) queryClient.invalidateQueries({ queryKey: queryKeys.issues.listByProject(companyId, projectId) });
             setClosingWorkspace(null);
           }}
         />
diff --git a/ui/src/components/issue-properties/IssueProperties.tsx b/ui/src/components/issue-properties/IssueProperties.tsx
index 2b00c76abae..07072cad374 100644
--- a/ui/src/components/issue-properties/IssueProperties.tsx
+++ b/ui/src/components/issue-properties/IssueProperties.tsx
@@ -634,7 +634,7 @@ export function IssueProperties({
     onSuccess: (result, request) => {
       queryClient.setQueryData(queryKeys.executionWorkspaces.detail(result.workspace.id), result.workspace);
       void queryClient.invalidateQueries({ queryKey: queryKeys.issues.detail(issue.id) });
-      void queryClient.invalidateQueries({ queryKey: queryKeys.projects.detail(result.workspace.projectId) });
+      if (result.workspace.projectId) void queryClient.invalidateQueries({ queryKey: queryKeys.projects.detail(result.workspace.projectId) });
       void queryClient.invalidateQueries({ queryKey: queryKeys.executionWorkspaces.overview(result.workspace.companyId) });
       void queryClient.invalidateQueries({ queryKey: queryKeys.executionWorkspaces.workspaceOperations(result.workspace.id) });
       if (companyId) {
diff --git a/ui/src/pages/ExecutionWorkspaceDetail.tsx b/ui/src/pages/ExecutionWorkspaceDetail.tsx
index 69d4864800c..e1d65ba2b24 100644
--- a/ui/src/pages/ExecutionWorkspaceDetail.tsx
+++ b/ui/src/pages/ExecutionWorkspaceDetail.tsx
@@ -803,8 +803,8 @@ export function ExecutionWorkspaceDetail() {
   const workspace = workspaceQuery.data ?? null;
 
   const projectQuery = useQuery({
-    queryKey: workspace ? [...queryKeys.projects.detail(workspace.projectId), workspace.companyId] : ["projects", "detail", "__pending__"],
-    queryFn: () => projectsApi.get(workspace!.projectId, workspace!.companyId),
+    queryKey: workspace?.projectId ? [...queryKeys.projects.detail(workspace.projectId), workspace.companyId] : ["projects", "detail", "__pending__"],
+    queryFn: () => projectsApi.get(workspace!.projectId!, workspace!.companyId),
     enabled: Boolean(workspace?.projectId),
   });
   const project = projectQuery.data ?? null;
@@ -958,7 +958,7 @@ export function ExecutionWorkspaceDetail() {
       queryClient.setQueryData(queryKeys.executionWorkspaces.detail(result.workspace.id), result.workspace);
       queryClient.invalidateQueries({ queryKey: queryKeys.executionWorkspaces.overview(result.workspace.companyId) });
       queryClient.invalidateQueries({ queryKey: queryKeys.executionWorkspaces.workspaceOperations(result.workspace.id) });
-      queryClient.invalidateQueries({ queryKey: queryKeys.projects.detail(result.workspace.projectId) });
+      if (result.workspace.projectId) queryClient.invalidateQueries({ queryKey: queryKeys.projects.detail(result.workspace.projectId) });
       setRuntimeActionErrorMessage(null);
       setRuntimeActionMessage(
         request.action === "run"
@@ -1426,7 +1426,7 @@ export function ExecutionWorkspaceDetail() {
               
               
               
-                {project ? {project.name} : }
+                {project ? {project.name} : }
               
               
                 {project && linkedProjectWorkspace ? (
diff --git a/ui/src/pages/Workspaces.test.tsx b/ui/src/pages/Workspaces.test.tsx
index 9ee2b3fad05..2522896c3ba 100644
--- a/ui/src/pages/Workspaces.test.tsx
+++ b/ui/src/pages/Workspaces.test.tsx
@@ -136,6 +136,18 @@ describe("Workspaces", () => {
     vi.clearAllMocks();
   });
 
+  it("links projectless task folders to workspace details without a project route", async () => {
+    mockExecutionWorkspacesApi.listOverview.mockResolvedValue(overviewResponse({ items: [{ ...overviewItem(), projectId: null, projectName: null, projectUrlKey: null }] }));
+    const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
+    root = createRoot(container);
+    await act(() => root!.render());
+    await flushQueries();
+    await vi.waitFor(() => expect(container.textContent).toContain("Task workspaces"));
+    expect(container.querySelector('a[href="/execution-workspaces/workspace-1"]')).not.toBeNull();
+    expect(container.querySelector('a[href^="/projects/"]')).toBeNull();
+    client.clear();
+  });
+
   it("uses the bounded overview endpoint and renders grouped workspace cards with linked task summaries", async () => {
     mockExecutionWorkspacesApi.listOverview
       .mockResolvedValueOnce(overviewResponse({
diff --git a/ui/src/pages/Workspaces.tsx b/ui/src/pages/Workspaces.tsx
index 269dcc025c9..a2feba67c49 100644
--- a/ui/src/pages/Workspaces.tsx
+++ b/ui/src/pages/Workspaces.tsx
@@ -15,9 +15,9 @@ import { queryKeys } from "../lib/queryKeys";
 import { projectRouteRef } from "../lib/utils";
 
 type ProjectWorkspaceGroup = {
-  projectId: string;
+  projectId: string | null;
   projectName: string;
-  projectRef: string;
+  projectRef: string | null;
   summaries: ProjectWorkspaceSummary[];
   lastUpdatedAt: Date;
   runningServiceCount: number;
@@ -46,7 +46,7 @@ function overviewItemToSummary(item: WorkspaceOverviewItem): ProjectWorkspaceSum
 }
 
 function buildProjectWorkspaceGroups(items: WorkspaceOverviewItem[]): ProjectWorkspaceGroup[] {
-  const groups = new Map();
+  const groups = new Map();
   for (const item of items) {
     const existing = groups.get(item.projectId);
     const summary = overviewItemToSummary(item);
@@ -60,8 +60,8 @@ function buildProjectWorkspaceGroups(items: WorkspaceOverviewItem[]): ProjectWor
     }
     groups.set(item.projectId, {
       projectId: item.projectId,
-      projectName: item.projectName,
-      projectRef: projectRouteRef({ id: item.projectId, name: item.projectName, urlKey: item.projectUrlKey }),
+      projectName: item.projectName ?? "Task workspaces",
+      projectRef: item.projectId ? projectRouteRef({ id: item.projectId, name: item.projectName ?? "Project", urlKey: item.projectUrlKey }) : null,
       summaries: [summary],
       lastUpdatedAt: summary.lastUpdatedAt,
       runningServiceCount: summary.runningServiceCount,
@@ -133,15 +133,15 @@ export function Workspaces() {
       ) : (
         
{groups.map((group) => ( -
+
- {group.projectName} - + :

{group.projectName}

}
{group.summaries.length} workspace{group.summaries.length === 1 ? "" : "s"} diff --git a/ui/src/pages/apps/chat/ChatEndpointDetail.tsx b/ui/src/pages/apps/chat/ChatEndpointDetail.tsx index 2170cfc9f68..c29d04e129c 100644 --- a/ui/src/pages/apps/chat/ChatEndpointDetail.tsx +++ b/ui/src/pages/apps/chat/ChatEndpointDetail.tsx @@ -4,6 +4,7 @@ import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, Di import { SlackToolsSettings, SlackSearchAccess } from "./SlackToolSettings"; import { defaultSlackAppName } from "./slack-app-name"; import { ChatCommunicationInstructions } from "./ChatCommunicationInstructions"; +import { ChatExecutionDefaults } from "./ChatExecutionDefaults"; import { SlackAvatarSettings } from "./SlackAvatarStep"; import { agentsApi } from "@/api/agents"; import { agentAvatarUrl } from "@/lib/agent-avatar-url"; @@ -305,7 +306,7 @@ export function ChatEndpointDetail() {
); if (endpoint.provider === "agentmail" && activeTab === "settings") - return ; + return
; const setupIncomplete = endpoint.setup?.step !== "complete" && !(endpoint.provider === "slack" && endpoint.setup?.step === "test") && @@ -363,6 +364,7 @@ export function ChatEndpointDetail() {
} {activeTab === "settings" && endpoint.provider !== "github" && } + {activeTab === "settings" && } {activeTab === "reviews" && endpoint.provider === "github" && } {activeTab === "access" && endpoint.provider === "agentmail" && } {activeTab === "access" && endpoint.provider !== "github" && endpoint.provider !== "agentmail" && ( @@ -395,6 +397,19 @@ export function ChatEndpointDetail() { ); } +function EndpointExecutionDefaults({ endpoint }: { endpoint: ChatEndpoint }) { + const queryClient = useQueryClient(); + return
{ + const next = await chatEndpointsApi.update(endpoint.id, { executionDefaults }); + queryClient.setQueryData(queryKeys.chatEndpoints.detail(endpoint.id), next); + }} + />
; +} + function EmailAccess({ endpoint }: { endpoint: ChatEndpoint }) { const connection = useQuery({ queryKey: queryKeys.tools.connection(endpoint.connectionId ?? ""), @@ -509,7 +524,8 @@ function Settings({ ) : (
{destinationResources.map((resource) => ( -
+
+

{resource.label} @@ -534,6 +550,19 @@ function Settings({ } />

+
+ Task defaults +
{ + const next = await chatEndpointsApi.updateResources(endpointId, [{ id: resource.id, executionDefaults }]); + queryClient.setQueryData(queryKeys.chatEndpoints.resources(endpointId), next); + }} + />
+
+
))} {endpoint.provider === "slack" && (

diff --git a/ui/src/pages/apps/chat/ChatExecutionDefaults.test.tsx b/ui/src/pages/apps/chat/ChatExecutionDefaults.test.tsx new file mode 100644 index 00000000000..5847ba7221e --- /dev/null +++ b/ui/src/pages/apps/chat/ChatExecutionDefaults.test.tsx @@ -0,0 +1,40 @@ +// @vitest-environment jsdom +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { afterEach, beforeEach, expect, it, vi } from "vitest"; +import { ChatExecutionDefaults } from "./ChatExecutionDefaults"; +const mocks = vi.hoisted(() => ({ projects: vi.fn(), workspaces: vi.fn(), save: vi.fn() })); +vi.mock("@/api/projects", () => ({ projectsApi: { list: mocks.projects } })); +vi.mock("@/api/execution-workspaces", () => ({ executionWorkspacesApi: { listSummaries: mocks.workspaces } })); +let root: Root, container: HTMLDivElement, client: QueryClient; +beforeEach(() => { + vi.clearAllMocks(); mocks.projects.mockResolvedValue([]); mocks.workspaces.mockResolvedValue([]); mocks.save.mockResolvedValue(undefined); + container = document.createElement("div"); document.body.append(container); root = createRoot(container); + client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); +}); +afterEach(async () => { await act(async () => root.unmount()); client.clear(); container.remove(); }); +async function render() { + await act(async () => root.render()); + await vi.waitFor(() => expect(container.querySelectorAll("select")[1].disabled).toBe(false)); +} +async function choose(value: string) { + await act(async () => { const select = container.querySelectorAll("select")[1]; select.value = value; select.dispatchEvent(new Event("change", { bubbles: true })); }); +} +async function save() { + await act(async () => container.querySelector("form")!.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true }))); +} +it("lets a destination clear inherited workspace independently of project", async () => { + await render(); await choose("clear"); await save(); + expect(mocks.save).toHaveBeenCalledWith({ projectId: null, workspace: null }); + expect(container.textContent).toContain("Saved for new tasks."); +}); +it("keeps edits visible after a save failure and permits retry", async () => { + mocks.save.mockRejectedValueOnce(new Error("Workspace access was revoked")); + await render(); await choose("task"); await save(); + expect(container.querySelector('[role="alert"]')?.textContent).toContain("Workspace access was revoked"); + expect(container.querySelectorAll("select")[1].value).toBe("task"); + await save(); + expect(mocks.save).toHaveBeenLastCalledWith({ projectId: null, workspace: { kind: "task_directory" } }); + expect(container.textContent).toContain("Saved for new tasks."); +}); diff --git a/ui/src/pages/apps/chat/ChatExecutionDefaults.tsx b/ui/src/pages/apps/chat/ChatExecutionDefaults.tsx new file mode 100644 index 00000000000..bf5cdc6bcc3 --- /dev/null +++ b/ui/src/pages/apps/chat/ChatExecutionDefaults.tsx @@ -0,0 +1,107 @@ +import { useId, useState } from "react"; +import { useQuery } from "@tanstack/react-query"; +import type { ChatExecutionDefaults as Defaults, TaskWorkspaceSelection } from "@paperclipai/shared"; +import { projectsApi } from "@/api/projects"; +import { executionWorkspacesApi } from "@/api/execution-workspaces"; +import { Button } from "@/components/ui/button"; +import { NativeSelect } from "@/components/ui/select"; +import { queryKeys } from "@/lib/queryKeys"; + +function selectionKey(value: TaskWorkspaceSelection | null | undefined): string { + if (value === undefined) return "inherit"; + if (value === null) return "clear"; + if (value.kind === "task_directory") return "task"; + if (value.kind === "existing") return `existing:${value.workspaceId}`; + return `source:${value.projectWorkspaceId}:${value.mode}`; +} + +/** The same optional editor is used by the connection and individual destinations. */ +export function ChatExecutionDefaults({ companyId, value, resource = false, onSave }: { + companyId: string; + value: Defaults | null | undefined; + resource?: boolean; + onSave: (defaults: Defaults | null) => Promise; +}) { + const id = useId(); + const [draft, setDraft] = useState(null); + const [pending, setPending] = useState(false); + const [error, setError] = useState(null); + const [saved, setSaved] = useState(false); + const projects = useQuery({ queryKey: queryKeys.projects.list(companyId), queryFn: () => projectsApi.list(companyId) }); + const workspaces = useQuery({ + queryKey: ["channel-workspace-choices", companyId], + queryFn: () => executionWorkspacesApi.listSummaries(companyId, { status: "active" }), + }); + const current = draft ?? value ?? {}; + const projectValue = current.projectId === undefined ? "inherit" : current.projectId ?? "none"; + const workspaceValue = selectionKey(current.workspace); + const choices = new Map(); + choices.set("task", { kind: "task_directory" }); + for (const project of projects.data ?? []) { + for (const source of project.workspaces ?? []) { + for (const mode of ["managed_isolated", "shared"] as const) { + choices.set(`source:${source.id}:${mode}`, { kind: "configured_source", projectWorkspaceId: source.id, mode }); + } + } + } + for (const workspace of workspaces.data ?? []) { + choices.set(`existing:${workspace.id}`, { kind: "existing", workspaceId: workspace.id }); + } + const dirty = JSON.stringify(current) !== JSON.stringify(value ?? {}); + const change = (next: Defaults) => { setDraft(next); setSaved(false); setError(null); }; + return

{ + event.preventDefault(); + if (pending || !dirty) return; + setPending(true); setError(null); setSaved(false); + try { await onSave(Object.keys(current).length ? current : null); setDraft(null); setSaved(true); } + catch (cause) { setError(cause instanceof Error ? cause.message : "Couldn’t save task defaults. Try again."); } + finally { setPending(false); } + }}> +
+

Task defaults

+

Applies to new conversations. Existing tasks keep their files and project.

+
+ {(projects.isError || workspaces.isError) &&

Couldn’t load available projects or workspaces.

} +
+ + { + const { projectId: _previous, ...rest } = current; + change(event.target.value === "inherit" ? rest : { ...rest, projectId: event.target.value === "none" ? null : event.target.value }); + }}> + + + {current.projectId && !projects.data?.some((project) => project.id === current.projectId) && } + {(projects.data ?? []).map((project) => )} + +
+
+ + { + const key = event.target.value; + const { workspace: _previous, ...rest } = current; + if (key === "inherit") change(rest); + else if (key === "clear") change({ ...rest, workspace: null }); + else { const workspace = choices.get(key); if (workspace) change({ ...rest, workspace }); } + }}> + + {resource && } + {!resource && workspaceValue === "clear" && } + + {current.workspace && !choices.has(workspaceValue) && } + {(projects.data ?? []).filter((project) => project.workspaces?.length).map((project) => + {project.workspaces.flatMap((source) => [ + , + , + ])} + )} + {(workspaces.data?.length ?? 0) > 0 && {workspaces.data!.map((workspace) => )}} + +

Project and workspace access is checked for each task. A shared folder must also be allowed by the agent’s execution policy.

+
+ {error &&

{error}

} +
+
{dirty ? : saved ? Saved for new tasks. : null}
+ +
+
; +}