mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-21 00:05:49 +02:00
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 <noreply@paperclip.ing>
This commit is contained in:
1 parent
3389bea708
commit
ec2210e4f8
120 files changed
+4984
-921
No files matched your search
@@ -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).
|
||||
@@ -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).
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
+12
-8
@@ -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).
|
||||
|
||||
|
||||
@@ -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/<stable-key>` 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.
|
||||
@@ -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<void>;
|
||||
workspaceSeedCacheDirectory?: string;
|
||||
workspaceBaseline?: DirectorySnapshot;
|
||||
workspaceGitSnapshot?: GitWorkspaceSnapshot | null;
|
||||
workspaceRepositories?: NonNullable<GitWorkspaceSnapshot["repositories"]>;
|
||||
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,
|
||||
|
||||
@@ -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<GitWorkspaceSnapshot["repositories"]>;
|
||||
} | null;
|
||||
cleanupWorkspaceSnapshot?(): Promise<void>;
|
||||
restoreWorkspace(onProgress?: RuntimeProgressSink): Promise<void>;
|
||||
@@ -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<void>;
|
||||
workspaceSeedCacheDirectory?: string;
|
||||
workspaceBaseline?: DirectorySnapshot;
|
||||
workspaceGitSnapshot?: GitWorkspaceSnapshot | null;
|
||||
workspaceRepositories?: NonNullable<GitWorkspaceSnapshot["repositories"]>;
|
||||
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,
|
||||
|
||||
@@ -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<NonNullable<GitWorkspaceSnapshot["repositories"]>> {
|
||||
const repositories: NonNullable<GitWorkspaceSnapshot["repositories"]> = [];
|
||||
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<GitWorkspaceSnapshot | null> {
|
||||
const repositories: NonNullable<GitWorkspaceSnapshot["repositories"]> = [];
|
||||
// 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<GitCommandResult>).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<st
|
||||
}
|
||||
}
|
||||
|
||||
async function copyCloneTree(source: string, target: string): Promise<void> {
|
||||
export async function copyCloneTree(source: string, target: string): Promise<void> {
|
||||
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<T>(
|
||||
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,
|
||||
|
||||
@@ -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<GitWorkspaceSnapshot["repositories"]>;
|
||||
} | null;
|
||||
cleanupWorkspaceSnapshot(): Promise<void>;
|
||||
restoreWorkspace(onProgress?: RuntimeProgressSink): Promise<void>;
|
||||
@@ -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<void>;
|
||||
workspaceSeedCacheDirectory?: string;
|
||||
/** Durable snapshots supplied when reconstructing an interrupted restore. */
|
||||
workspaceBaseline?: DirectorySnapshot;
|
||||
workspaceGitSnapshot?: GitWorkspaceSnapshot | null;
|
||||
workspaceRepositories?: NonNullable<GitWorkspaceSnapshot["repositories"]>;
|
||||
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<ReturnType<typeof createWorkspaceManifest>> | 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/<adapterKey>`). 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! }));
|
||||
|
||||
@@ -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);
|
||||
@@ -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<void> {
|
||||
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<ReturnType<typeof fs.lstat>>) => [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<unknown>).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<WorkspaceCheckpointMetrics> {
|
||||
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;
|
||||
}
|
||||
@@ -662,7 +662,7 @@ export async function mergeDirectoryWithBaseline(input: {
|
||||
afterApply?: () => Promise<void>;
|
||||
/** 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<void> {
|
||||
const options = { exclude: input.baseline.exclude, ignoredPaths: input.baseline.ignoredPaths, diskBacked: true };
|
||||
const source = input.snapshots?.source ?? await captureDirectorySnapshot(input.sourceDir, options);
|
||||
|
||||
@@ -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<GitWorkspaceSnapshot["repositories"]> = []): 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<string> {
|
||||
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<WorkspaceDurableSeedPaths | null> {
|
||||
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<void> {
|
||||
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 }); }
|
||||
}
|
||||
@@ -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;
|
||||
+935
-4
File diff suppressed because it is too large.
Load diff
+7
@@ -2255,6 +2255,13 @@
|
||||
"when": 1791578748368,
|
||||
"tag": "0323_petite_shockwave",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 324,
|
||||
"version": "7",
|
||||
"when": 1791584830800,
|
||||
"tag": "0324_needy_wong",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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<ChatExecutionDefaults>(),
|
||||
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<ChatExecutionDefaults>(),
|
||||
metadata: jsonb("metadata")
|
||||
.$type<Record<string, unknown>>()
|
||||
.notNull()
|
||||
|
||||
@@ -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<string[]>().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),
|
||||
}));
|
||||
@@ -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(),
|
||||
|
||||
@@ -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";
|
||||
@@ -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<TaskWorkspaceIntent>(),
|
||||
workspacePendingSelection: jsonb("workspace_pending_selection").$type<TaskWorkspacePendingSelection>(),
|
||||
executionWorkspacePreference: text("execution_workspace_preference"),
|
||||
executionWorkspaceSettings: jsonb("execution_workspace_settings").$type<Record<string, unknown>>(),
|
||||
sourceTrust: jsonb("source_trust").$type<SourceTrustMetadata | null>(),
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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.
|
||||
@@ -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","requiredClaimLine truncated
|
||||
[{"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","requiredClaimLine truncated
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -175,7 +175,7 @@
|
||||
},
|
||||
{
|
||||
"path": "fixtures/evals/native-execution-seeded.json",
|
||||
"sha256": "248c366cd272e1cc13506886bee4ee15fd63813280583b5859011446035ebbbb",
|
||||
"sha256": "872f556fd39a4b7e50a902d09c8b42f8fc729030d03642983fa16872f623e1ef",
|
||||
"expectation": "accept",
|
||||
"compatibilityCase": "canonical"
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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"',
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -34,7 +34,7 @@ const NAMESPACE: Readonly<Record<CapabilitySemanticOperationId, string>> = 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." },
|
||||
]);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -39,7 +39,7 @@ const NAMESPACE: Readonly<Record<PaperclipSemanticActionId, string>> =
|
||||
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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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";
|
||||
@@ -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;
|
||||
|
||||
@@ -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";
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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<ExecutionWorkspaceMode, "inherit" | "reuse_existing" | "agent_default"> | "adapter_managed" | "cloud_sandbox";
|
||||
|
||||
@@ -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(),
|
||||
)
|
||||
|
||||
@@ -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";
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
@@ -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<typeof prepareWorkspaceRepositorySchema>;
|
||||
@@ -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);
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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<ReturnType<typeof startRuntimeServicesForWorkspaceControl>> = [];
|
||||
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("") || "<none>"}. 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
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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: {} });
|
||||
|
||||
@@ -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<string, unknown>)?.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)
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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<string, unknown> = {};
|
||||
// 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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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<ReturnType<typeof startRunnerApiTestServer>>;
|
||||
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<string, unknown>) => 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();
|
||||
});
|
||||
});
|
||||
@@ -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<ReturnType<typeof startEmbeddedPostgresTestDatabase>>;
|
||||
let db: ReturnType<typeof createDb>;
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -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<ReturnType<typeof startEmbeddedPostgresTestDatabase>>;
|
||||
let db: ReturnType<typeof createDb>;
|
||||
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<typeof setTimeout> | 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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 }));
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -3710,6 +3710,7 @@ export function issueRoutes(
|
||||
input: Record<string, unknown>,
|
||||
) {
|
||||
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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<string, unknown> { return value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : {}; }
|
||||
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),
|
||||
|
||||
@@ -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<Parameters<Db["transaction"]>[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<void> {
|
||||
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 } : {}),
|
||||
};
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<RepositoryRow, "repositoryIdentity" | "requestedRef">, 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 };
|
||||
}
|
||||
@@ -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<Pick<typeof issues.$inferInsert,
|
||||
"executionWorkspaceId" | "projectWorkspaceId" | "executionWorkspacePreference" | "executionWorkspaceSettings"
|
||||
>>;
|
||||
|
||||
type ExecutionWorkspaceRow = typeof executionWorkspaces.$inferSelect;
|
||||
type WorkspaceRuntimeServiceRow = typeof workspaceRuntimeServices.$inferSelect;
|
||||
type RuntimeServiceReadDb = Pick<Db, "select">;
|
||||
@@ -1265,7 +1275,7 @@ async function loadEffectiveRuntimeServicesByExecutionWorkspace(
|
||||
}
|
||||
|
||||
type WorkspaceOverviewPageRow = ExecutionWorkspaceRow & {
|
||||
projectName: string;
|
||||
projectName: string | null;
|
||||
projectWorkspaceMetadata: Record<string, unknown> | 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<string, unknown> ?? {}), [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<string, unknown> | 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<WorkspaceOverviewResponse> => {
|
||||
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<number>`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<ReopenClosedIsolatedExecutionWorkspaceResult> => {
|
||||
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<ReopenClosedIsolatedExecutionWorkspaceResult> => {
|
||||
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
|
||||
|
||||
+291
-212
@@ -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<string, unknown> = {};
|
||||
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<string, unknown>,
|
||||
restore?: () => Promise<void>,
|
||||
) => {
|
||||
if (adapterFinalizeOutcome) return;
|
||||
let finalizeBranchMetadata: Record<string, unknown> | null = null;
|
||||
let finalizeBranchRepairMetadata: Record<string, unknown> | 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<string, unknown> | null = null;
|
||||
let finalizeBranchRepairMetadata: Record<string, unknown> | 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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<Parameters<Db["transaction"]>[0]>[0];
|
||||
type IssueCreateInput = Omit<typeof issues.$inferInsert, "companyId" | "title" | "titleNeedsGeneration"> & {
|
||||
type IssueCreateInput = Omit<typeof issues.$inferInsert, "companyId" | "title" | "titleNeedsGeneration" | "workspaceSelection"> & {
|
||||
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>`null`,
|
||||
workspaceSelection: sql<null>`null`,
|
||||
workspacePendingSelection: sql<null>`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<string, unknown> | 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<string, unknown> | 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
|
||||
|
||||
@@ -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<void>;
|
||||
},
|
||||
): 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<void> {
|
||||
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`);
|
||||
}
|
||||
}
|
||||
@@ -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<NativeChatWorkspaceScope | null> {
|
||||
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<boolean>`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<boolean>`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) => {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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<void> {
|
||||
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" });
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
|
||||
Loaded 100 of 120 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user