diff --git a/Dockerfile b/Dockerfile index eddc097b0a..1db10902f6 100644 --- a/Dockerfile +++ b/Dockerfile @@ -144,6 +144,21 @@ RUN pnpm --filter @paperclipai/server build RUN test -f server/dist/index.js || (echo "ERROR: server build output missing" && exit 1) RUN rm -rf packages/paperclip-runner/runner/target +# Remote OpenCode and ACPX runs require a controller-owned provider pack to +# verify the sandbox installation or stage matching assets. Ship it in the +# standard image so downstream Cloud compositions inherit the same artifacts. +# Grok's native executable stays an external sandbox prerequisite; this pack +# contains only its launcher. +FROM build AS runner-provider-pack +# Unstamped local builds remain usable, but cannot qualify a remote pack. +# Never invent a source revision to make an unqualified pack look verified. +RUN mkdir -p /provider-pack \ + && if [ -n "${PAPERCLIP_BUILD_COMMIT}" ]; then \ + PAPERCLIP_RUNNER_SOURCE_REVISION="${PAPERCLIP_BUILD_COMMIT}" node packages/paperclip-runner/scripts/build-provider-pack.mjs /provider-pack; \ + else \ + echo "Skipping remote provider pack: supply a full PAPERCLIP_BUILD_COMMIT to enable remote OpenCode/ACPX execution"; \ + fi + FROM base AS production ARG USER_UID=1000 ARG USER_GID=1000 @@ -169,6 +184,17 @@ RUN chmod +x /usr/local/bin/docker-entrypoint.sh COPY --chown=node:node --from=build /app /app +COPY --from=runner-provider-pack /provider-pack /opt/paperclip-runner/provider-pack +# Managed deployments can remap node's UID at startup. This immutable pack +# contains public code and integrity metadata, never credentials; it must remain +# readable afterward. +# Keep it root-owned and verify access as an unrelated unprivileged UID. +RUN chmod -R a+rX /opt/paperclip-runner/provider-pack \ + && if [ -f /opt/paperclip-runner/provider-pack/provider-pack.json ]; then \ + gosu 65534:65534 node -e 'const fs = require("node:fs"); const path = require("node:path"); const root = "/opt/paperclip-runner/provider-pack"; const manifest = JSON.parse(fs.readFileSync(path.join(root, "provider-pack.json"), "utf8")); for (const artifact of Object.values(manifest.payload.artifacts)) fs.readFileSync(path.join(root, artifact.path)); fs.accessSync(path.join(root, manifest.payload.artifacts.nodeCommand.path), fs.constants.X_OK);'; \ + fi +ENV PAPERCLIP_RUNNER_REMOTE_PROVIDER_PACK_PATH=/opt/paperclip-runner/provider-pack + # Declare per-build metadata after the stable RUN layers. Docker includes # in-scope ARG values in a RUN's environment even when its command does not # mention them; declaring these earlier invalidates the weekly tool cache. @@ -213,8 +239,8 @@ CMD ["node", "--import", "./server/node_modules/tsx/dist/loader.mjs", "server/di # dist/ to exist in the image — the default image ships only their source, # so auto-install logs "bundle not present" and skips. The plugins are # built in this separate target so the default (self-hosted) image stays -# lean; CI pins the default build to `--target production`, which is -# byte-identical to before this stage existed. +# lean; CI pins the default build to `--target production`. Both targets +# inherit the build-owned remote provider pack from the standard image. # # The sandbox providers are intentionally excluded from the pnpm workspace # (see pnpm-workspace.yaml), so each installs standalone exactly as its @@ -293,29 +319,7 @@ RUN set -eu; \ test -n "$specifiers" || { echo "ERROR: CLOUD_BUNDLED_SERVER_DEPS names no package" >&2; exit 1; }; \ pnpm add --ignore-workspace --no-lockfile $specifiers -# ACPX remote runs require a controller-owned provider pack to verify the -# sandbox installation or stage matching assets. Grok's native executable stays -# an external sandbox prerequisite; this pack contains only its launcher. -FROM build AS cloud-provider-pack -# Unstamped local builds remain usable, but cannot qualify a remote pack. -# Never invent a source revision to make an unqualified pack look verified. -RUN mkdir -p /provider-pack \ - && if [ -n "${PAPERCLIP_BUILD_COMMIT}" ]; then \ - PAPERCLIP_RUNNER_SOURCE_REVISION="${PAPERCLIP_BUILD_COMMIT}" node packages/paperclip-runner/scripts/build-provider-pack.mjs /provider-pack; \ - else \ - echo "Skipping remote provider pack: supply a full PAPERCLIP_BUILD_COMMIT to enable remote ACPX execution"; \ - fi - FROM production AS cloud -COPY --from=cloud-provider-pack /provider-pack /opt/paperclip-runner/provider-pack -# Cloud remaps node's UID at startup. This immutable pack contains public code -# and integrity metadata, never credentials; it must remain readable afterward. -# Keep it root-owned and verify access as an unrelated unprivileged UID. -RUN chmod -R a+rX /opt/paperclip-runner/provider-pack \ - && if [ -f /opt/paperclip-runner/provider-pack/provider-pack.json ]; then \ - gosu 65534:65534 node -e 'const fs = require("node:fs"); const path = require("node:path"); const root = "/opt/paperclip-runner/provider-pack"; const manifest = JSON.parse(fs.readFileSync(path.join(root, "provider-pack.json"), "utf8")); for (const artifact of Object.values(manifest.payload.artifacts)) fs.readFileSync(path.join(root, artifact.path)); fs.accessSync(path.join(root, manifest.payload.artifacts.nodeCommand.path), fs.constants.X_OK);'; \ - fi -ENV PAPERCLIP_RUNNER_REMOTE_PROVIDER_PACK_PATH=/opt/paperclip-runner/provider-pack COPY --chown=node:node --from=cloud-plugins /app/packages/plugins/sandbox-providers /app/packages/plugins/sandbox-providers # Land the isolated install inside the server's own `node_modules`, the # directory Node's module resolution walks up to from `/app/server` for diff --git a/doc/DEVELOPING.md b/doc/DEVELOPING.md index 5d37d441c8..c88890e98a 100644 --- a/doc/DEVELOPING.md +++ b/doc/DEVELOPING.md @@ -6,6 +6,9 @@ This project can run fully in local dev without setting up PostgreSQL manually. For mode definitions and intended CLI behavior, see `doc/DEPLOYMENT-MODES.md`. +For sandbox file synchronization, lock ownership, and the required upgrade +procedure from directory locks, see [Workspace restore locks](workspace-restore-locks.md). + Current implementation status: - canonical model: `local_trusted` and `authenticated` (with `private/public` exposure) @@ -100,6 +103,20 @@ pnpm build-storybook These run the `@paperclipai/ui` Storybook on port `6006` and build the static output to `ui/storybook-static/`. +Use **Components → Agent setup prompt** to review the shared setup handoff: +hover/focus logo motion, one-click copying with a prompt preview, animated +confirmation, and manual-copy recovery. Opening the preview copies immediately; +clicking its trigger again copies without closing. Close and Escape only dismiss; +the inner copy button remains available for retries and later copies. Stories include Slack and API copy, a compact quick-access placement, +light and mobile views, and a clipboard paste check. `AgentSetupPrompt` accepts +the complete `prompt`, `title`, `description`, trigger `label`, and popover +placement. Use it for prompts handed to an external agent: Slack and GitHub +connections, MCP configuration help, routine webhook setup, external-agent +invitations, and task continuation. The **App placements** story collects the +production controls; **Inside MCP help** exercises the popover inside a dialog. +Prompt generation can pass `initialCopyStatus` to preserve its automatic copy +result, and `onCopied` to clear an earlier generation-time clipboard error. + Use **Design explorations → Agent chat sidebar** to review the production secondary agent navigation. **Components** covers selection, search, loading, empty results, and larger teams. **Pages** puts it beside the production agent chat inside the @@ -823,8 +840,9 @@ The default `worktree init` still seeds eagerly. A lean worktree (created withou - `pnpm paperclipai worktree ensure-seeded` performs the deferred seed **exactly once**. It is lock-guarded and idempotent: only a complete `verified` manifest short-circuits it, so it is safe to call repeatedly and from concurrent processes. Managed workspaces derive the source from the control-plane-provided base project workspace when it carries its own `.paperclip/config.json`, and otherwise from the control plane's own registered instance config; either way the workspace's manifest never selects it. Manual worktrees must pass `--from-config`. - `paperclipai run` calls `ensureWorktreeSeeded` automatically before doctor/boot. Managed runs transparently seed a lean worktree from their registered base workspace; an unmanaged lean worktree must first run `worktree ensure-seeded --from-config `. -- Managed Paperclip git worktrees default to the repository's `scripts/provision-worktree.sh` when the strategy omits `provisionCommand`, so the isolated config and pending manifest cannot be silently skipped. Runtime startup also runs `scripts/provision-worktree-runtime.sh` automatically when no explicit runtime provision command is configured and the manifest is not verified. Explicitly configured provision commands still take precedence. -- If the built-in provisioner reports an unavailable seed source config, decide whether the task needs a seeded development instance or only an isolated checkout. A seeded instance needs a canonical config from the registered base workspace or control-plane instance; environment-only server configuration does not provide that file. For a checkout-only worktree, explicitly set the `git_worktree` strategy's `provisionCommand` to `"true"`. This skips setup and does not establish runtime or seed readiness. Repair rejected symlinks or non-regular source files instead of treating those validation failures as a missing prerequisite. +- Managed Paperclip git worktrees default to the repository's `scripts/provision-worktree.sh` when the strategy omits `provisionCommand`. When a registered source config exists, setup creates the isolated config and pending manifest. Runtime startup also runs `scripts/provision-worktree-runtime.sh` automatically when no explicit runtime provision command is configured and the manifest is not verified. Explicitly configured provision commands still take precedence. +- An environment-configured server may have no local seed config. If neither the base checkout nor the default control-plane instance has one, a fresh worktree prepares its dependencies without creating a development instance. This also applies when `PAPERCLIP_CONFIG` names the default `$PAPERCLIP_HOME/instances/$PAPERCLIP_INSTANCE_ID/config.json` path, as the Docker image does. Setup creates no config, environment file, or seed manifest and does not claim runtime or seed readiness. A later request for a seeded development runtime still needs a canonical registered source config. Once that source exists, provisioning the checkout again creates the development instance normally. +- A missing custom `PAPERCLIP_CONFIG`, rejected symlink, or non-regular source file still fails setup. An existing worktree with a config, environment file, or seed state also fails if its source disappears; setup never downgrades that instance to a plain checkout. Repair the source before retrying. - The built-in deferred seed is recorded as its own terminal `workspace_seed` operation. A zero exit code is not enough for success: the operation succeeds only when `.paperclip/seed-manifest.json` contains complete verified evidence; failed, missing, or malformed manifests produce a failed operation with the seed phase in metadata. - Worktrees created before lazy seeding shipped may have neither marker. Paperclip adopts them only after their configured database proves a compatible migration journal and the core Paperclip schema; otherwise managed startup creates a pending manifest and performs the normal verified seed. Manual markerless worktrees must provide `--from-config` so the source remains explicit. diff --git a/doc/DOCKER.md b/doc/DOCKER.md index 897a8f3cd1..7d9bb1ea95 100644 --- a/doc/DOCKER.md +++ b/doc/DOCKER.md @@ -40,6 +40,19 @@ and ARM64. Canonical master pushes also publish for its immutable multi-platform digest. Downstream services can compose their own images from this public base without rebuilding Core. +Stamped standard images include the build-owned remote provider pack at +`/opt/paperclip-runner/provider-pack` and set +`PAPERCLIP_RUNNER_REMOTE_PROVIDER_PACK_PATH` to that directory. Downstream +compositions and the explicit `cloud` target inherit both. The pack is required +on the controller for remote OpenCode and ACPX runs, even when the sandbox has +preinstalled providers; setting the environment variable alone cannot repair an +image that omits the pack. Rebuild and redeploy from a standard image containing +the pack if runs fail with `runner_remote_provider_artifact_incompatible`. + +Local Docker builds must supply a full `PAPERCLIP_BUILD_COMMIT` to include a +qualified pack. Unstamped builds remain usable for local adapters and skip pack +generation. The pack stays root-owned and readable by remapped runtime UIDs. + The legacy recurring public `-cloud` publisher is retired. Master pushes, release tags, and manual `Docker` dispatches no longer build that variant. Existing `-cloud` tags and digests remain in the registry for rollback; their diff --git a/doc/SPEC-implementation.md b/doc/SPEC-implementation.md index 580aa360aa..20f2d91f4c 100644 --- a/doc/SPEC-implementation.md +++ b/doc/SPEC-implementation.md @@ -1197,6 +1197,14 @@ Dashboard payload must include: - month-to-date spend and budget utilization - pending approvals count +The dashboard agent cards show each linked task at most once. The server selects +distinct task cards from bounded active and recent run samples before it applies +the dashboard card limit. When multiple runs belong to one task, an active run +takes precedence over completed runs. +Runs without a linked task remain separate cards. The dashboard keeps the +count of additional distinct cards in its link to the live runs page, which +can show every run. + ## 10.10 Error Semantics - `400` validation error @@ -1661,6 +1669,8 @@ for persistence, migration, rendering, and integration contracts. `GET /api/companies/:companyId/chats/:agentRef` reads an existing conversation or null. `POST` atomically resolves its issue when adding a chat or on first send/upload. `GET /api/companies/:companyId/chats` lists only the current board user’s conversations in that company, subject to ordinary issue read access. The Chat navigation opens a searchable secondary sidebar with agent avatars and a picker for starting or reopening the same per-agent conversation. Existing issue comment, attachment, document, interaction, and run APIs apply thereafter. User chat comments require an idempotent UUID `clientRequestId`. Conversation delivery preserves comment order through the existing issue execution queue; the durable comment outbox repairs the commit-to-enqueue crash window. +The Chat navigation entry reopens the last agent conversation visited by the current user in the current company. The browser keeps this recent order and any existing conversation ID locally; unavailable agents and removed conversations are skipped. An agent chat that has no issue yet can still reopen from the agent roster. The agent chooser remains the landing view when no saved chat is available. + The server owns conversation state: `waiting` plus `in_review` denotes a healthy idle conversation, and `active` denotes an unanswered or executing turn. Successful replies settle a turn; they do not finish the issue. Idle containers are excluded from execution-work counts, ordinary task lists, timer work, and recovery invocations. Failed/unanswered turns retain normal handling. Child completion never wakes or completes the conversation. Search and direct task access preserve history. Standalone `/new` is an ordered queue command with no model response. It advances a durable session generation and boundary comment, resets only this issue's provider context, and preserves the issue ID and history. Generation checks reject stale context writes and replies. Fresh replay excludes earlier messages and summaries. The shared transcript renders a session divider. diff --git a/doc/connections/CONNECTOR-PLAYBOOK.md b/doc/connections/CONNECTOR-PLAYBOOK.md index 58bf3c5e9e..facfb02001 100644 --- a/doc/connections/CONNECTOR-PLAYBOOK.md +++ b/doc/connections/CONNECTOR-PLAYBOOK.md @@ -513,6 +513,9 @@ connection work or enforce a real tenant boundary. Follow these rules: controls and per-action Test dialog. Do not build a parallel permissions list or provider-specific testing page. Discovery errors stay inline on Connect; an empty returned catalog belongs to the ordinary saved-connection state. +- The per-action Test dialog shows structured MCP results as readable fields, + tables, or cards when possible. It keeps the full raw response available for + diagnosis and opens it by default when the result cannot be rendered safely. - Enable every discovered tool automatically. Put later Allowed / Ask first / Off controls in management, separate from connection access. Reconnect and refresh retain existing restrictions; new tools are Allowed under the existing access rules. diff --git a/doc/execution-semantics.md b/doc/execution-semantics.md index fb1c1cae67..df10f22d84 100644 --- a/doc/execution-semantics.md +++ b/doc/execution-semantics.md @@ -482,6 +482,8 @@ The handshake failure code is distinct from a session-identity mismatch. A timeo An explicit recovery action is a typed liveness repair path for a source issue. It is the recovery primitive; the action can be rendered directly on the source issue or backed by a separate recovery issue when the repair needs its own work item. +A terminal native failure can retain a result accepted before checkpoint or cleanup failed. That result is historical evidence, not a live controller. New user input may start a fresh turn after the controller and execution environment have stopped and ordinary admission checks pass. Preserve the failed run, its result, and its recovery budget. Do not commit the old result, infer action outcomes, or replay the failed turn. A message saved while cleanup is pending must be reconsidered after verified cleanup and delivered once. Admission must consume its deferred receipt in the same transaction that creates the new run, including in agent chat, where later messages keep their separate turns. Completing that run must not promote the consumed receipt again. Workspace-export repair retains its separate saved-result recovery path. + A new user message can continue a terminal native run whose process fields were cleared before local stop receipts existed. Admission must verify the exact run, runner, workspace, and provider session in the retained suspended state, with no active provider turn, pending tool call, or undelivered output. Missing or mismatched state keeps the hold. A later recorded process launch also keeps the hold until its stop is verified. Normal assignment, decision, controller, environment cleanup, and active-run gates still apply. The message starts one fresh conversation turn; it does not replay the failed run, reset its recovery budget, or certify unknown action outcomes. The task thread exposes the existing guarded Retry action for failed or timed-out legacy conversation runs. Where the server supports an explicit new attempt after a stopped legacy conversation, the thread must not hide that action solely because the old run still has a recovery-needed projection. Native and process recovery holds, pending decisions, active execution, and other retry gates remain in force. When a gate hides Retry, the thread says the message is preserved instead of promising an unavailable action. This presentation change does not rewrite historical outcomes or certify prior actions. diff --git a/doc/workspace-restore-locks.md b/doc/workspace-restore-locks.md new file mode 100644 index 0000000000..60a00fc0b7 --- /dev/null +++ b/doc/workspace-restore-locks.md @@ -0,0 +1,56 @@ +# Workspace restore locks + +Workspace restore and agent-file collection serialize writes to each canonical +target directory. Their lock files live in +`/locks/directory-merge`, outside the writable target. All writers +must use the same instance root and a filesystem with reliable SQLite file +locking. Network filesystems that do not provide that locking are not supported. + +Each target has a permanent `.lock.sqlite` file. An open SQLite +`BEGIN IMMEDIATE` transaction holds its reserved file lock for the entire write +operation. Contenders retry without blocking the Node event loop, up to the +existing 30-second limit. Closing the connection releases the lock; the operating +system also releases it when the process exits or crashes. Independent targets +use different files and can proceed concurrently. + +This uses the same built-in `node:sqlite` dependency as workspace manifests. +See [SQLite file locking](https://www.sqlite.org/lockingv3.html) for the reserved +lock contract. No application database or schema migration is involved. + +The adjacent `.lock.owner.json` records a PID and creation time only for +bounded timeout diagnostics. Missing, stale, or incorrect metadata cannot grant +or retain ownership. PID reuse, PID namespaces, and wall-clock changes do not +decide whether a writer owns the lock. + +**Never delete, replace, or move a `.lock.sqlite` file while an instance can +write to it.** Its stable inode is part of the locking contract. Replacing it +could let two writers lock different files for the same target. Files remain +after release, including for targets that no longer exist. Their diagnostic +owner sidecars are normally removed after release. + +## Upgrade from directory locks + +Older versions created `.lock/owner.json` and checked only whether the +recorded PID existed. A server restart could reuse that PID and leave an orphaned +lock permanently protected. Those records cannot establish a process lifetime or +PID namespace, so the new implementation never guesses that a legacy holder is +dead. An existing legacy directory continues to block admission. + +Do not run old and new lock protocols concurrently against the same instance +root. An old process does not participate in the SQLite lock protocol. + +1. Drain and stop **all** old server and worker processes that can write to the + instance root, including processes on other hosts or in other containers. +2. Preserve any unfinished-run evidence needed for recovery. After all writers + are stopped, move leftover legacy `.lock/` directories to an operator + scratch directory outside the lock root. Do not remove `.lock.sqlite` files. +3. Start all writers on the new version. Verify that a run completes both the + provider turn and file collection/restore. A successful model response alone + does not prove that its local file changes were saved. + +The same drain requirement applies to rollback. Existing permanent SQLite files +can remain on disk; older versions ignore them. Do not infer that a legacy lock +is safe to remove from its age, an absent PID, or a successful task response. + +This change prevents new orphaned ownership. It cannot recover file changes +that an earlier failed collection discarded. diff --git a/docs/deploy/environment-variables.md b/docs/deploy/environment-variables.md index 78ca705b7e..a663f1f0e8 100644 --- a/docs/deploy/environment-variables.md +++ b/docs/deploy/environment-variables.md @@ -25,7 +25,7 @@ All environment variables that Paperclip uses for server configuration. | `PAPERCLIP_RUNNER_REMOTE_BINARY_PATH` | (host build) | Host-local path to a `paperclip-runnerd` artifact built for the remote target OS and architecture. Required when Paperclip and the remote sandbox do not share a compatible platform; build metadata and the required transport mode are verified before launch. | | `PAPERCLIP_RUNNER_REMOTE_CODEX_PATH` | (unset) | Optional host-local path to a Codex executable built for the remote target OS and architecture. For remote Codex-backed runners, Paperclip stages and verifies this executable beside `paperclip-runnerd`. | | `PAPERCLIP_RUNNER_REMOTE_CODEX_NPM_SPEC` | (unset) | Optional pinned npm package spec (for example, `@openai/codex@0.156.0`) installed inside each fresh remote lease when its Codex harness is not baked into the sandbox image. Mutually exclusive with `PAPERCLIP_RUNNER_REMOTE_CODEX_PATH`; Paperclip verifies the installed executable before starting `runnerd`. | -| `PAPERCLIP_RUNNER_REMOTE_PROVIDER_PACK_PATH` | (unset) | Host-local path to the immutable provider pack built by `pnpm --filter @paperclipai/paperclip-runner build:provider-pack`. The pack includes its target-built Node 24.11 runtime, locked production dependencies, OpenCode proxy/executable, and ACPX sidecar. Remote OpenCode and ACPX fail closed without it. A preinstalled pack is accepted only when its complete digested manifest matches this build-owned pack; otherwise Paperclip stages this pack into the sandbox. | +| `PAPERCLIP_RUNNER_REMOTE_PROVIDER_PACK_PATH` | `/opt/paperclip-runner/provider-pack` in Docker; otherwise unset | Host-local path to the immutable provider pack built by `pnpm --filter @paperclipai/paperclip-runner build:provider-pack`. Stamped standard Docker images include the pack; downstream compositions and the `cloud` target inherit it. Unstamped local Docker builds skip pack generation. The pack includes its target-built Node 24.11+ runtime, locked production dependencies, OpenCode proxy/executable, and ACPX sidecar. Remote OpenCode and ACPX fail closed without it. A preinstalled pack is accepted only when its complete digested manifest matches this build-owned pack; otherwise Paperclip stages this pack into the sandbox. | | `PAPERCLIP_HIDDEN_SETTINGS` | (unset) | Comma-separated settings surfaces to hide from the UI and floor at the API, for operators hosting Paperclip for others (managed cloud, internal shared server). See [Hiding settings surfaces](#hiding-settings-surfaces). | | `PAPERCLIP_SETTING_DEFAULTS` | (unset) | JSON object replacing the schema default of selected instance settings, for hosting operators. See [Operator setting defaults](#operator-setting-defaults). | diff --git a/packages/adapter-utils/src/directory-merge-lock.test.ts b/packages/adapter-utils/src/directory-merge-lock.test.ts new file mode 100644 index 0000000000..c05305fb8d --- /dev/null +++ b/packages/adapter-utils/src/directory-merge-lock.test.ts @@ -0,0 +1,182 @@ +import { execFile, spawn, type ChildProcess } from "node:child_process"; +import { createHash } from "node:crypto"; +import { once } from "node:events"; +import { access, link, mkdir, mkdtemp, readFile, realpath, rm, stat, symlink, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { promisify } from "node:util"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { withDirectoryMergeLock, WORKSPACE_RESTORE_LOCK_TIMEOUT_CODE } from "./workspace-restore-merge.js"; + +describe("directory merge lock process lifetime", () => { + const loader = fileURLToPath(new URL("../../../cli/node_modules/tsx/dist/cli.mjs", import.meta.url)); + const module = fileURLToPath(new URL("./workspace-restore-merge.ts", import.meta.url)); + const directories: string[] = []; + const children: ChildProcess[] = []; + afterEach(async () => { + vi.restoreAllMocks(); + for (const child of children.splice(0)) { + if (child.exitCode === null && child.signalCode === null) { + const exited = once(child, "exit"); + child.kill("SIGKILL"); + await exited; + } + } + await Promise.all(directories.splice(0).map((dir) => rm(dir, { recursive: true, force: true }))); + }); + + async function fixture() { + const root = await mkdtemp(path.join(os.tmpdir(), "paperclip-lock-process-")); + directories.push(root); + const target = path.join(root, "target"); + await mkdir(target); + const env = { ...process.env, PAPERCLIP_HOME: path.join(root, "home"), PAPERCLIP_INSTANCE_ID: "test" }; + const key = createHash("sha256").update(await realpath(target)).digest("hex"); + const lock = path.join(env.PAPERCLIP_HOME, "instances", "test", "locks", "directory-merge", `${key}.lock`); + return { target, env, lock }; + } + + async function holder(target: string, env: NodeJS.ProcessEnv) { + const child = spawn(process.execPath, [loader, "--eval", ` + import { withDirectoryMergeLock } from ${JSON.stringify(module)}; + withDirectoryMergeLock(${JSON.stringify(target)}, async () => { + process.send?.("locked"); + await new Promise(resolve => process.once("message", resolve)); + }).then(() => process.exit(0)).catch(error => { console.error(error); process.exit(1); }); + `], { env, stdio: ["ignore", "ignore", "pipe", "ipc"] }); + children.push(child); + let stderr = ""; + child.stderr?.on("data", (chunk) => { stderr += chunk; }); + await new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error(`Child did not acquire lock: ${stderr}`)), 10_000); + child.once("message", () => { clearTimeout(timer); resolve(); }); + child.once("error", (error) => { clearTimeout(timer); reject(error); }); + child.once("exit", (code) => { clearTimeout(timer); reject(new Error(`Child exited ${code}: ${stderr}`)); }); + }); + return child; + } + + // Both protocols carry diagnostic owner records. This also lets the crash + // regression demonstrate the old implementation's PID-reuse failure. + async function ownerPath(lock: string) { + return await access(`${lock}.owner.json`).then(() => `${lock}.owner.json`, () => path.join(lock, "owner.json")); + } + + function expireWait() { + const now = Date.now(); + return vi.spyOn(Date, "now").mockReturnValueOnce(now).mockReturnValue(now + 60_000); + } + + it("recovers a killed holder even when its recorded PID has been reused", async () => { + const { target, env, lock } = await fixture(); + const child = await holder(target, env); + const exited = once(child, "exit"); + child.kill("SIGKILL"); + await exited; + const recordPath = await ownerPath(lock); + const record = JSON.parse(await readFile(recordPath, "utf8")); + await writeFile(recordPath, JSON.stringify({ ...record, pid: process.pid })); + const clock = expireWait(); + try { + await expect(withDirectoryMergeLock(target, async () => "restored", env)).resolves.toBe("restored"); + } finally { clock.mockRestore(); } + }, 15_000); + + it("protects a live holder in another process regardless of diagnostic PID or age", async () => { + const { target, env, lock } = await fixture(); + const child = await holder(target, env); + const recordPath = await ownerPath(lock); + await writeFile(recordPath, JSON.stringify({ pid: 2_147_483_647, createdAt: "2000-01-01T00:00:00.000Z" })); + const contender = vi.fn(); + const clock = expireWait(); + try { + await expect(withDirectoryMergeLock(target, contender, env)).rejects.toMatchObject({ code: WORKSPACE_RESTORE_LOCK_TIMEOUT_CODE }); + } finally { clock.mockRestore(); } + expect(contender).not.toHaveBeenCalled(); + const exited = once(child, "exit"); + child.send("release"); + await exited; + await expect(withDirectoryMergeLock(target, async () => "released", env)).resolves.toBe("released"); + }, 15_000); + + it("releases ownership when the protected operation throws", async () => { + const { target, env } = await fixture(); + await expect(withDirectoryMergeLock(target, async () => { throw new Error("operation failed"); }, env)).rejects.toThrow("operation failed"); + await expect(withDirectoryMergeLock(target, async () => "next", env)).resolves.toBe("next"); + }); + + it("keeps the OS lock when a same-process contender closes its connection", async () => { + const { target, env } = await fixture(); + await withDirectoryMergeLock(target, async () => { + const clock = expireWait(); + try { + await expect(withDirectoryMergeLock(target, async () => undefined, env)).rejects.toMatchObject({ code: WORKSPACE_RESTORE_LOCK_TIMEOUT_CODE }); + } finally { clock.mockRestore(); } + // A same-process test alone cannot prove that the OS lock survived: on + // POSIX, closing an unmanaged descriptor can drop process-wide locks. + const result = await promisify(execFile)(process.execPath, [loader, "--eval", ` + import { withDirectoryMergeLock, WORKSPACE_RESTORE_LOCK_TIMEOUT_CODE } from ${JSON.stringify(module)}; + const now = Date.now(); + let calls = 0; + Date.now = () => now + calls++ * 60_000; + withDirectoryMergeLock(${JSON.stringify(target)}, async () => "entered") + .then(() => { console.error("Entered a live holder's lock"); process.exit(1); }) + .catch(error => { + if (error.code !== WORKSPACE_RESTORE_LOCK_TIMEOUT_CODE) { console.error(error); process.exit(1); } + console.log("blocked"); + }); + `], { env, timeout: 10_000 }); + expect(result.stdout.trim()).toBe("blocked"); + }, env); + }, 15_000); + + it("does not serialize unrelated target directories", async () => { + const { target, env } = await fixture(); + const other = `${target}-other`; + await mkdir(other); + await withDirectoryMergeLock(target, async () => { + await expect(withDirectoryMergeLock(other, async () => "independent", env)).resolves.toBe("independent"); + }, env); + }); + + it("retains the same database inode across holders", async () => { + const { target, env, lock } = await fixture(); + await withDirectoryMergeLock(target, async () => undefined, env); + const before = await stat(`${lock}.sqlite`); + await withDirectoryMergeLock(target, async () => undefined, env); + const after = await stat(`${lock}.sqlite`); + expect({ dev: after.dev, ino: after.ino }).toEqual({ dev: before.dev, ino: before.ino }); + }); + + it("closes the kernel lock when writing diagnostics fails", async () => { + const { target, env, lock } = await fixture(); + await mkdir(`${lock}.owner.json`, { recursive: true }); + await expect(withDirectoryMergeLock(target, async () => undefined, env)).rejects.toThrow(); + await rm(`${lock}.owner.json`, { recursive: true }); + await expect(withDirectoryMergeLock(target, async () => "next", env)).resolves.toBe("next"); + }); + + it.each(["missing", "malformed", "dead PID"])("does not guess ownership of a legacy lock with %s metadata", async (kind) => { + const { target, env, lock } = await fixture(); + await mkdir(lock, { recursive: true }); + if (kind !== "missing") await writeFile(path.join(lock, "owner.json"), kind === "malformed" ? "{broken" : JSON.stringify({ pid: 2_147_483_647, createdAt: "2000-01-01T00:00:00.000Z" })); + const contender = vi.fn(); + const clock = expireWait(); + try { + await expect(withDirectoryMergeLock(target, contender, env)).rejects.toMatchObject({ code: WORKSPACE_RESTORE_LOCK_TIMEOUT_CODE }); + } finally { clock.mockRestore(); } + expect(contender).not.toHaveBeenCalled(); + expect((await stat(lock)).isDirectory()).toBe(true); + }); + + it.skipIf(process.platform === "win32").each(["symlink", "hard link"])("rejects a lock database that is a %s", async (kind) => { + const { target, env, lock } = await fixture(); + await mkdir(path.dirname(lock), { recursive: true }); + const decoy = `${target}-decoy`; + await writeFile(decoy, "keep"); + await (kind === "symlink" ? symlink(decoy, `${lock}.sqlite`) : link(decoy, `${lock}.sqlite`)); + await expect(withDirectoryMergeLock(target, async () => undefined, env)).rejects.toThrow("not a plain, unshared file"); + await expect(readFile(decoy, "utf8")).resolves.toBe("keep"); + }); +}); diff --git a/packages/adapter-utils/src/workspace-restore-merge.test.ts b/packages/adapter-utils/src/workspace-restore-merge.test.ts index aaacd5d7da..bfa57996a9 100644 --- a/packages/adapter-utils/src/workspace-restore-merge.test.ts +++ b/packages/adapter-utils/src/workspace-restore-merge.test.ts @@ -376,7 +376,7 @@ describe("workspace restore merge", () => { } }); - it("creates the lock root at mode 0o700 and removes the lock directory after release", async () => { + it("keeps the private lock database and removes diagnostic ownership after release", async () => { const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-restore-merge-")); cleanupDirs.push(rootDir); const paperclipHome = path.join(rootDir, "paperclip-home"); @@ -392,8 +392,11 @@ describe("workspace restore merge", () => { }); expect((await stat(lockRootDir)).mode & 0o777).toBe(0o700); - expect(entriesDuringLock).toHaveLength(1); - await expect(readdir(lockRootDir)).resolves.toHaveLength(0); + expect(entriesDuringLock.filter((name) => name.endsWith(".owner.json"))).toHaveLength(1); + const entriesAfterRelease = await readdir(lockRootDir); + expect(entriesAfterRelease).toHaveLength(1); + expect(entriesAfterRelease[0]).toMatch(/\.lock\.sqlite$/); + expect((await stat(path.join(lockRootDir, entriesAfterRelease[0]!))).mode & 0o777).toBe(0o600); }); it("classifies the real lock-timeout error by its stable code, never by the message text", async () => { @@ -469,9 +472,9 @@ describe("workspace restore merge", () => { }); } finally { clock.mockRestore(); } expect(contender).not.toHaveBeenCalled(); - expect(await readdir(lockRoot)).toHaveLength(1); + expect((await readdir(lockRoot)).filter((name) => name.endsWith(".owner.json"))).toHaveLength(1); }); - expect(await readdir(lockRoot)).toHaveLength(0); + expect(await readdir(lockRoot)).toEqual([expect.stringMatching(/\.lock\.sqlite$/)]); }); it("delivers the timeout when the diagnostic owner read stalls and ignores its late rejection", async () => { diff --git a/packages/adapter-utils/src/workspace-restore-merge.ts b/packages/adapter-utils/src/workspace-restore-merge.ts index 755bce9be4..60bdd8cc90 100644 --- a/packages/adapter-utils/src/workspace-restore-merge.ts +++ b/packages/adapter-utils/src/workspace-restore-merge.ts @@ -2,6 +2,7 @@ import { createHash, randomUUID } from "node:crypto"; import { createReadStream } from "node:fs"; import { constants as fsConstants, promises as fs } from "node:fs"; import path from "node:path"; +import { DatabaseSync } from "node:sqlite"; import { createWorkspaceManifest, WorkspaceManifestMap, workspacePathMatcher, type PathManifest, type WorkspacePaths, type WorkspaceManifestWriter } from "./workspace-manifest.js"; import { shouldExcludePath } from "./exclude-patterns.js"; import { resolvePaperclipInstanceRootForAdapter } from "./server-utils.js"; @@ -200,7 +201,7 @@ function entriesMatch(left: SnapshotEntry | null | undefined, right: SnapshotEnt return false; } -const LOCK_STALE_MS = 30_000; +const LOCK_WAIT_MS = 30_000; const LOCK_DIAGNOSTIC_READ_TIMEOUT_MS = 100; const activeDirectoryMergeLocks = new Set(); const MAX_LOCK_DIAGNOSTIC_AGE_MS = 7 * 24 * 60 * 60 * 1000; @@ -212,7 +213,7 @@ export type DirectoryMergeLockOperation = /** Evidence only: neither process age nor this module's holder set can prove * that a lock in another process or PID namespace is safe to reclaim. */ -async function directoryMergeLockDiagnostics(lockDir: string, waitMs: number): Promise> { +async function directoryMergeLockDiagnostics(lockDir: string, waitMs: number, ownerPath: string): Promise> { const diagnostics: Record = { ownerState: "unknown", knownLocalHolder: activeDirectoryMergeLocks.has(lockDir), @@ -224,7 +225,7 @@ async function directoryMergeLockDiagnostics(lockDir: string, waitMs: number): P // Abort is best effort: race the read as well so a stalled filesystem // cannot keep the original lock timeout from reaching its caller. const raw = await Promise.race([ - fs.readFile(path.join(lockDir, "owner.json"), { encoding: "utf8", signal: controller.signal }), + fs.readFile(ownerPath, { encoding: "utf8", signal: controller.signal }), new Promise((resolve) => { timeout = setTimeout(() => resolve(undefined), LOCK_DIAGNOSTIC_READ_TIMEOUT_MS); }), @@ -339,75 +340,86 @@ export function describeWorkspaceRestoreFailure(code: WorkspaceRestoreFailureCod } } -async function isLockStale(lockDir: string): Promise { - try { - const raw = await fs.readFile(path.join(lockDir, "owner.json"), "utf8"); - const owner = JSON.parse(raw) as { pid?: unknown }; - const pid = typeof owner.pid === "number" && Number.isFinite(owner.pid) && owner.pid > 0 ? owner.pid : null; - if (pid === null) { - // Owner record is unparseable / missing pid — treat as stale. - return true; - } - try { - process.kill(pid, 0); - return false; - } catch { - return true; - } - } catch { - // owner.json is missing or unreadable. A live holder also passes through - // this exact state, briefly, between its own `fs.mkdir(lockDir)` and its - // `fs.writeFile(owner.json)` below. Reading "missing" as "stale" here would - // let a concurrent acquirer delete a live holder's lock directory during - // that window. Mirror the materializePaperclipSkillCopy lock pattern: fall - // back to the lock directory's own mtime, and only call it stale once the - // directory itself has outlived the stale threshold. - const stat = await fs.stat(lockDir).catch(() => null); - return !stat || Date.now() - stat.mtimeMs > LOCK_STALE_MS; - } -} - async function acquireDirectoryMergeLock(lockDir: string, operation?: DirectoryMergeLockOperation): Promise<() => Promise> { const startedAt = performance.now(); - const deadline = Date.now() + LOCK_STALE_MS; - while (true) { - try { - await fs.mkdir(lockDir); - await fs.writeFile( - path.join(lockDir, "owner.json"), - `${JSON.stringify({ pid: process.pid, createdAt: new Date().toISOString() })}\n`, - "utf8", + const deadline = Date.now() + LOCK_WAIT_MS; + const databasePath = `${lockDir}.sqlite`; + const ownerPath = `${lockDir}.owner.json`; + async function waitForLock(diagnosticOwnerPath: string) { + if (Date.now() >= deadline) { + const timeoutError: NodeJS.ErrnoException & { workspaceRestoreLock?: Record } = new Error( + `Timed out waiting for workspace restore lock at ${lockDir}`, ); - activeDirectoryMergeLocks.add(lockDir); - return async () => { - try { - await fs.rm(lockDir, { recursive: true, force: true }).catch(() => undefined); - } finally { - activeDirectoryMergeLocks.delete(lockDir); - } - }; - } catch (error) { - const code = error && typeof error === "object" ? (error as { code?: unknown }).code : null; - if (code !== "EEXIST") throw error; - // Stale-lock detection: if the owner PID is dead (SIGKILL / OOM / crash), - // the lockDir would otherwise persist forever and stall restores. Mirror - // the materializePaperclipSkillCopy lock pattern — remove and retry. - if (await isLockStale(lockDir)) { - await fs.rm(lockDir, { recursive: true, force: true }).catch(() => undefined); - continue; - } - if (Date.now() >= deadline) { - const timeoutError: NodeJS.ErrnoException & { workspaceRestoreLock?: Record } = new Error( - `Timed out waiting for workspace restore lock at ${lockDir}`, - ); - timeoutError.code = WORKSPACE_RESTORE_LOCK_TIMEOUT_CODE; - // Keep the original timeout if the diagnostic read itself fails. - timeoutError.workspaceRestoreLock = await directoryMergeLockDiagnostics(lockDir, performance.now() - startedAt).catch(() => undefined); - if (operation && timeoutError.workspaceRestoreLock) timeoutError.workspaceRestoreLock.operation = operation; - throw timeoutError; - } - await new Promise((resolve) => setTimeout(resolve, 50)); + timeoutError.code = WORKSPACE_RESTORE_LOCK_TIMEOUT_CODE; + // Keep the original timeout if the diagnostic read itself fails. + timeoutError.workspaceRestoreLock = await directoryMergeLockDiagnostics(lockDir, performance.now() - startedAt, diagnosticOwnerPath).catch(() => undefined); + if (operation && timeoutError.workspaceRestoreLock) timeoutError.workspaceRestoreLock.operation = operation; + throw timeoutError; } + await new Promise((resolve) => setTimeout(resolve, 50)); + } + + // SQLite's RESERVED file lock is the authority, including across processes + // and PID namespaces. It is released by the OS on a crash. The empty database + // is permanent: unlinking it would let contenders lock different inodes. + // node:sqlite is already required for workspace manifests; no native add-on + // or external flock command is needed on macOS, Linux, or Windows. + const databaseStat = await fs.lstat(databasePath).catch((error: NodeJS.ErrnoException) => { + if (error.code === "ENOENT") return null; + throw error; + }); + if (databaseStat && (!databaseStat.isFile() || databaseStat.isSymbolicLink() || databaseStat.nlink !== 1)) { + throw new Error("Directory merge lock database is not a plain, unshared file."); + } + // Let SQLite create and manage every descriptor for this inode. On POSIX, + // closing a raw fs.open descriptor could release another connection's locks. + // The parent is private (0700), including while a new file is chmodded. + const database = new DatabaseSync(databasePath, { allowExtension: false }); + try { + await fs.chmod(databasePath, 0o600); + // Never block the event loop while another async operation holds the lock. + database.exec("PRAGMA busy_timeout=0;"); + while (true) { + try { + database.exec("BEGIN IMMEDIATE;"); + break; + } catch (error) { + const code = (error as { errcode?: number }).errcode; + if (typeof code !== "number" || (code & 0xff) !== 5) throw error; // SQLITE_BUSY + await waitForLock(ownerPath); + } + } + + // Old processes do not participate in the SQLite protocol. Never infer + // that a legacy owner is dead from PID existence, age, or missing metadata. + // Drain old writers before upgrading. A leftover legacy directory requires + // explicit offline cleanup; a live legacy holder can still release normally. + while (await fs.lstat(lockDir).then(() => true, (error: NodeJS.ErrnoException) => { + if (error.code === "ENOENT") return false; + throw error; + })) await waitForLock(path.join(lockDir, "owner.json")); + + const owner = await fs.open(ownerPath, fsConstants.O_WRONLY | fsConstants.O_CREAT | fsConstants.O_TRUNC | fsConstants.O_NOFOLLOW, 0o600); + try { + await owner.writeFile(`${JSON.stringify({ pid: process.pid, createdAt: new Date().toISOString() })}\n`, "utf8"); + } finally { await owner.close(); } + activeDirectoryMergeLocks.add(lockDir); + let released = false; + return async () => { + if (released) return; + released = true; + try { + // This sidecar is diagnostic only. A failed removal cannot retain + // ownership, and the next holder replaces it while holding the DB lock. + await fs.unlink(ownerPath).catch(() => undefined); + } finally { + activeDirectoryMergeLocks.delete(lockDir); + database.close(); + } + }; + } catch (error) { + database.close(); + throw error; } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d97f5c2029..6f2b5f9cef 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -875,8 +875,8 @@ importers: server: dependencies: '@aws-sdk/client-s3': - specifier: ^3.1122.0 - version: 3.1122.0 + specifier: ^3.1141.0 + version: 3.1141.0 '@chat-adapter/discord': specifier: 4.39.0 version: 4.39.0(patch_hash=nywlhls7dd4npegrgax7jqdbxa)(zod@4.4.3) @@ -1179,8 +1179,8 @@ importers: specifier: 0.48.0 version: 0.48.0(typescript@7.0.2) lucide-react: - specifier: ^1.38.0 - version: 1.38.0(react@19.2.8) + specifier: ^1.45.0 + version: 1.45.0(react@19.2.8) mermaid: specifier: ^11.17.2 version: 11.17.2 @@ -1408,72 +1408,72 @@ packages: react: optional: true - '@aws-sdk/checksums@3.1000.29': - resolution: {integrity: sha512-Dtu0gr4dnATZAPwEYbpCsG+MpLM7OAliy2gTepEFQwl1vZ6DL3QMH2FveMa3HLvPsOdhJsPRB3KtxVhph9T75A==} + '@aws-sdk/checksums@3.1001.1': + resolution: {integrity: sha512-x12Q17KYlJAd3nKf8LV5LV0vt8sh8/6YfQLGPtrGnQf/tW4jqxPGq5GPpuVitpQYM3eUR4XB7CbxZf751NMbLw==} engines: {node: '>=20.0.0'} - '@aws-sdk/client-s3@3.1122.0': - resolution: {integrity: sha512-efrWRspDhSf+bbL/yOnM8IzZhX7FZqkb6K35XkIRk1KqR6Vxll6weZzmeiIWlYmERI7BmaB5BhGZaw4lrYtk7Q==} + '@aws-sdk/client-s3@3.1141.0': + resolution: {integrity: sha512-uOVH37xGLenAdJkCPCin/JJG2PgWrFcSsDnQ9+C9Zq8N9Oalo5ol4xmn5fG28iWAlA/b/9boQZgHbMh+UsIhcg==} engines: {node: '>=20.0.0'} - '@aws-sdk/core@3.977.9': - resolution: {integrity: sha512-reqPFEQrZxDZpeGj4PFMepBeR5LGYHRqq/L0motTzgFkCRBA4rFdaVXDSLYyGHhxVz7sT2PDnPN9CluGSfgyJA==} + '@aws-sdk/core@3.978.1': + resolution: {integrity: sha512-LbY9aGsEiznDWmUc30Nwv3aIX/+dbwTx8KfS0yOC3NPYMO+O91e6jkT1azf34FwjOndq8/Q+RcVVZz5xnerwdg==} engines: {node: '>=20.0.0'} - '@aws-sdk/credential-provider-env@3.972.70': - resolution: {integrity: sha512-H404B7dJl2mCrBqahDEYsanB0xhdDp6tXnXcTUnXmmpy2Q3J0Ho0bUajZ2jr/RdwzCyS59Gi8xXIFwPLGBl6Uw==} + '@aws-sdk/credential-provider-env@3.972.72': + resolution: {integrity: sha512-xTKO/FWJPozTIXbozVnVGoNBhaGba8TBcx+KyUjRVeOlXE+dUc7GTR1cLvu0uTdIdmemzaFbqqCshXeZA1fZew==} engines: {node: '>=20.0.0'} - '@aws-sdk/credential-provider-http@3.972.72': - resolution: {integrity: sha512-X98zYOrVOeuosCX+6ktf29FC2N2GHPLia7qv6mzPzTc+RPAuHWCDS++Z6JK7eGYqb/v6uaW7bAXaOvDBfol+0w==} + '@aws-sdk/credential-provider-http@3.972.74': + resolution: {integrity: sha512-u91E/hT8f4d1xy0Jl7VG4nVKJ3lxbrZkoBTeSVoJdWBiSEUMwMS/9+e0H/aJVQV//Lt5wuzP+E69v4aRSsNTmw==} engines: {node: '>=20.0.0'} - '@aws-sdk/credential-provider-ini@3.973.15': - resolution: {integrity: sha512-Rykg6s5ceBuynMOGWgoowO4N+27JfnqXAnVaSunZl0hOO1XodSrxGNz6sCEbnmS0lAfQZDKyb3fbr46gSuv6Sg==} + '@aws-sdk/credential-provider-ini@3.973.17': + resolution: {integrity: sha512-ged4KXdBkvIC81bLvNHHuQKdKak/VXhQTR1NWYTTqW0474nlmsxy9O/vlgTIohDDWH3xpBdtVMZRyjb+DnocDA==} engines: {node: '>=20.0.0'} - '@aws-sdk/credential-provider-login@3.972.77': - resolution: {integrity: sha512-Jb59xfEISoN5mmbnA+HYqdtrSX3CgCtJoof+V5D8/TgUI56W63GEEd5Y58WijU3Ou6+WEgaLD1feVzaRXV5IDQ==} + '@aws-sdk/credential-provider-login@3.972.79': + resolution: {integrity: sha512-L+Z85anONJd8MaiuraO4wRxATCdEejBZ3K3eymzWI5JPXa9sOS9CkIm72PBKqXKX+Z9p9NGMX5AIMXm0LEflgw==} engines: {node: '>=20.0.0'} - '@aws-sdk/credential-provider-node@3.972.82': - resolution: {integrity: sha512-znDkEOGXB8W3kG1LJUKP3foBZY/9qLM0eil/DxWXSp37XsdsRLQHE/d/OaCGGVgKpA6znR38h/+INk8do1FjiA==} + '@aws-sdk/credential-provider-node@3.972.84': + resolution: {integrity: sha512-oHt854odINVwzwsh+c5x69j0ajm4DbqqqVJ+O1ECsCIZeMDAbzFpXItaqP7UZstJj/ATdTk/KFSH0LaNAgV+kA==} engines: {node: '>=20.0.0'} - '@aws-sdk/credential-provider-process@3.972.70': - resolution: {integrity: sha512-2ry03fGRJr4sV3jI+ocjj5JqALnFD6ymM5KiNCDZMvq8bX2GSbE0vji4aM43TVCl2nXqqLRZaUxdq/KeWRAY4Q==} + '@aws-sdk/credential-provider-process@3.972.72': + resolution: {integrity: sha512-rLIp2xbMjX/k9/od7APpqq1ZgXXnV0pOL1Th3ZsL8Wu0TRtBsDTVS8iPqcfRFcHakFxPvR04OSTv2ka2qOb/2A==} engines: {node: '>=20.0.0'} - '@aws-sdk/credential-provider-sso@3.973.14': - resolution: {integrity: sha512-jkhg/8ocAAoc0RFyLMhCw+/zZh7gystQgd4F4hznNa8P4Cc501PQmxd+jGLiMHodPJ+7Zv/3znM62gZojyasmA==} + '@aws-sdk/credential-provider-sso@3.973.16': + resolution: {integrity: sha512-IGihaJfFZYacJJr/odqILCoK7W/mvrZ7cuK7ECn3sAu4vLC6u0V8bS7mCGbdugJ8Aum2tnvqmx0F2MRFp2rn9g==} engines: {node: '>=20.0.0'} - '@aws-sdk/credential-provider-web-identity@3.972.76': - resolution: {integrity: sha512-d3AGyVu759PGr35mEB2s22xxlNEA5rpdxtSPJthfPFJvoQ8dt357iVPECqWfUxXp1toJAvKmbtcIYVGigaGsCA==} + '@aws-sdk/credential-provider-web-identity@3.972.78': + resolution: {integrity: sha512-/y9WvNtlcPBGLR0qc1a+9J/xtYZfVczvLUOuXaVWylzttH7ewsxwHtjmiJSolNrVSDorIxHGHMU61CbonRkmwA==} engines: {node: '>=20.0.0'} - '@aws-sdk/middleware-sdk-s3@3.972.75': - resolution: {integrity: sha512-wMIsNumRVKaNMKhvU/s9VrdEwE8S6gSzXp4RygFG5BEMnGkkXf8cjh8zf7cKJBpUDpqTWqwbz5isEgp9rH6Lng==} + '@aws-sdk/middleware-sdk-s3@3.972.77': + resolution: {integrity: sha512-E7W2UOeUoc+lg3uIfR/dM7ZwusHwhBQrKMnlkRv4EXRR+C0YtV1pg25xC7GdZIhXH+NAMgZPCbE7o5to2cjFiw==} engines: {node: '>=20.0.0'} - '@aws-sdk/nested-clients@3.997.44': - resolution: {integrity: sha512-NhEgryjlBF9w38ZXqGymQV28IhkYa1mKhlbYnqIis57AYwWGVYfUPgg/qC2rLRqOUfblxx++irvju10kVTa8Vw==} + '@aws-sdk/nested-clients@3.997.46': + resolution: {integrity: sha512-oRxtBcka/JGHGs9l9p9IVajGoTP8vTPmoAzdHGy4Qcy9P5vPnDf6nhIeM/COQNY9k/OahImTRaLkHftoXvfcmQ==} engines: {node: '>=20.0.0'} - '@aws-sdk/signature-v4-multi-region@3.996.46': - resolution: {integrity: sha512-L+2xZTye/2T96f3lwCws0Zw6GG2JHZW9e8FpVgGBeeExSKyeoZ6CWRpBml/7DNiK/O26jrgPM9F+Ay8VkgzUWQ==} + '@aws-sdk/signature-v4-multi-region@3.996.47': + resolution: {integrity: sha512-Zk08macMvQTHzQJCLJVkOlviVoqwYMrpXv4lmLN7b7sAbiMoOK7Go0NYdR5UeF+MW8LIbRmwrNy9u/5VvX1U5g==} engines: {node: '>=20.0.0'} - '@aws-sdk/token-providers@3.1116.0': - resolution: {integrity: sha512-ygIivKqh8aHzNkucOCXHyIBgBpLPfrSI0mCqXF+vLBsPTUKqj0VSqAY0GFPe7lQl4HntjOcQ+KSyS7oUV2C54Q==} + '@aws-sdk/token-providers@3.1138.0': + resolution: {integrity: sha512-GpyAr0DD63YOEmYFM6Df+gJuIgC92MMTiBK4FTKfxii5MJ9ge20epR7LyroulscYlG89J+ZB2ivFDPjvfQhzdw==} engines: {node: '>=20.0.0'} - '@aws-sdk/types@3.974.5': - resolution: {integrity: sha512-LkwLL2BLbC6wNNm4JaH9mbEqBMdOZCct6VAYqhdN4U1xrWM+fUJQEfbHwQgDypapOWTRtlk25akb5afM0P8CIQ==} + '@aws-sdk/types@3.974.6': + resolution: {integrity: sha512-v/clNZzZnDxGyvpHMOGpJKVXFAExJzUNAAjaWGdcx8QAcXLGwTaOkw33p5SHAi0YAioK32xB3hWwOekRVfmfKg==} engines: {node: '>=20.0.0'} - '@aws-sdk/xml-builder@3.972.40': - resolution: {integrity: sha512-wlFmCIGUlwF4zx/kncw+bmxTQh1HeSJq4mYV/V5cZUSJadDP3kXvGW8Rn21cimj/7y9ju+47oYWXi97vF7czaA==} + '@aws-sdk/xml-builder@3.972.41': + resolution: {integrity: sha512-ctjVSyCMegrWfXlx6VqzSBFI6UqmQ5ZlnfMhdLIiWmhoH8UAQxSCP5N3OpG7X3k4LnS7ou74C4mt20+bfTW2aQ==} engines: {node: '>=20.0.0'} '@aws/lambda-invoke-store@0.3.0': @@ -4761,8 +4761,8 @@ packages: resolution: {integrity: sha512-ItjyjEZml+LDH8CjcCLRLJHh7VZtevPKExrRN3l5KWyBliyDnGAeoO4Y+K+fFBmRpKLYVPgqWMX4THldv2HVtA==} engines: {node: '>= 18', npm: '>= 8.6.0'} - '@smithy/core@3.33.3': - resolution: {integrity: sha512-CsOeKq/9kA3y6VJHt+/+VTCtBaxJ4OTFpgrjIUhPpDIKxBci1k2bJaQASF2h/ELWrulGp+t97DZ0mevfAD8idg==} + '@smithy/core@3.35.1': + resolution: {integrity: sha512-i4YPS4B6ts7bjn7UwLnGjiZdprOvHvgGobFZsYK3GIY3E5hIqtj0rReU69BcTpGp+fvtraSNXeG1l+jtJvF55w==} engines: {node: '>=18.0.0'} '@smithy/credential-provider-imds@4.5.2': @@ -4777,12 +4777,12 @@ packages: resolution: {integrity: sha512-ThMkboGeONWXAelq9FvGsuJC4rOi+qyC4/zhUF58xYpxUg5sQKx2VXZYJmtNjr4dSuBJ1HeJXETQILCz3wOHvw==} engines: {node: '>=18.0.0'} - '@smithy/signature-v4@5.7.3': - resolution: {integrity: sha512-7ImGm+FkHRLcBaRttIAMZ6bzJZWb2cJGoYjq46F2UjycujWzrL9GEN9h4w7eQyXJYnltrUhxbbieBAIRrdqpow==} + '@smithy/signature-v4@5.7.4': + resolution: {integrity: sha512-tHy0K0VtqNd5Y7Y41h0a0Lhh0L1GzC08dTWg0F7vRJWFtTENg7IZikf3wQkanYIRdb7ngoIPMTmqgUi401fEeQ==} engines: {node: '>=18.0.0'} - '@smithy/types@4.18.0': - resolution: {integrity: sha512-CgB6HHWer/vrKps24ulRIbpcpb7K4xAU7SkZ7YHzBPlwHsvsrCJFEXK421s+cJzX+ZrqtA/TuU5w1HzI7k9N8A==} + '@smithy/types@4.19.0': + resolution: {integrity: sha512-r7jh49VJxGerfAcTQA6gXcKc+98zOp/tqRwzYjgOE+iSQsP6cEU1hq2QzbuipmP68QtYdY9wKEhiCQZIzHgZ4Q==} engines: {node: '>=18.0.0'} '@stablelib/base64@1.0.1': @@ -7281,8 +7281,8 @@ packages: lru_map@0.4.1: resolution: {integrity: sha512-I+lBvqMMFfqaV8CJCISjI3wbjmwVu/VyOoU7+qtu9d7ioW5klMgsTTiUOUp+DJvfTTzKXoPbyC6YfgkNcyPSOg==} - lucide-react@1.38.0: - resolution: {integrity: sha512-xZCyBd/wiVUDactoCc+42TjL0aB7EBOXsuX+tjz+W/sGzw2KhHpL1NOH3FIaVUcpimvUBpIYfz34Ofj9S5JEzQ==} + lucide-react@1.45.0: + resolution: {integrity: sha512-yH1ubCAduho9UR7oJhRXIQXogksRILBiTuZC4/bQIGeB9JOkxMlSuEHyyZpo1Z3S0yWJO2KTSUZbjiNvVxeOUw==} peerDependencies: react: ^19.2.8 @@ -9166,167 +9166,167 @@ snapshots: '@types/react': 19.2.18 react: 19.2.8 - '@aws-sdk/checksums@3.1000.29': + '@aws-sdk/checksums@3.1001.1': dependencies: - '@aws-sdk/core': 3.977.9 - '@aws-sdk/types': 3.974.5 - '@smithy/core': 3.33.3 - '@smithy/types': 4.18.0 + '@aws-sdk/core': 3.978.1 + '@aws-sdk/types': 3.974.6 + '@smithy/core': 3.35.1 + '@smithy/types': 4.19.0 tslib: 2.8.1 - '@aws-sdk/client-s3@3.1122.0': + '@aws-sdk/client-s3@3.1141.0': dependencies: - '@aws-sdk/checksums': 3.1000.29 - '@aws-sdk/core': 3.977.9 - '@aws-sdk/credential-provider-node': 3.972.82 - '@aws-sdk/middleware-sdk-s3': 3.972.75 - '@aws-sdk/signature-v4-multi-region': 3.996.46 - '@aws-sdk/types': 3.974.5 - '@smithy/core': 3.33.3 + '@aws-sdk/checksums': 3.1001.1 + '@aws-sdk/core': 3.978.1 + '@aws-sdk/credential-provider-node': 3.972.84 + '@aws-sdk/middleware-sdk-s3': 3.972.77 + '@aws-sdk/signature-v4-multi-region': 3.996.47 + '@aws-sdk/types': 3.974.6 + '@smithy/core': 3.35.1 '@smithy/fetch-http-handler': 5.8.0 '@smithy/node-http-handler': 4.12.1 - '@smithy/types': 4.18.0 + '@smithy/types': 4.19.0 tslib: 2.8.1 - '@aws-sdk/core@3.977.9': + '@aws-sdk/core@3.978.1': dependencies: - '@aws-sdk/types': 3.974.5 - '@aws-sdk/xml-builder': 3.972.40 + '@aws-sdk/types': 3.974.6 + '@aws-sdk/xml-builder': 3.972.41 '@aws/lambda-invoke-store': 0.3.0 - '@smithy/core': 3.33.3 - '@smithy/signature-v4': 5.7.3 - '@smithy/types': 4.18.0 + '@smithy/core': 3.35.1 + '@smithy/signature-v4': 5.7.4 + '@smithy/types': 4.19.0 bowser: 2.14.1 tslib: 2.8.1 - '@aws-sdk/credential-provider-env@3.972.70': + '@aws-sdk/credential-provider-env@3.972.72': dependencies: - '@aws-sdk/core': 3.977.9 - '@aws-sdk/types': 3.974.5 - '@smithy/core': 3.33.3 - '@smithy/types': 4.18.0 + '@aws-sdk/core': 3.978.1 + '@aws-sdk/types': 3.974.6 + '@smithy/core': 3.35.1 + '@smithy/types': 4.19.0 tslib: 2.8.1 - '@aws-sdk/credential-provider-http@3.972.72': + '@aws-sdk/credential-provider-http@3.972.74': dependencies: - '@aws-sdk/core': 3.977.9 - '@aws-sdk/types': 3.974.5 - '@smithy/core': 3.33.3 + '@aws-sdk/core': 3.978.1 + '@aws-sdk/types': 3.974.6 + '@smithy/core': 3.35.1 '@smithy/fetch-http-handler': 5.8.0 '@smithy/node-http-handler': 4.12.1 - '@smithy/types': 4.18.0 + '@smithy/types': 4.19.0 tslib: 2.8.1 - '@aws-sdk/credential-provider-ini@3.973.15': + '@aws-sdk/credential-provider-ini@3.973.17': dependencies: - '@aws-sdk/core': 3.977.9 - '@aws-sdk/credential-provider-env': 3.972.70 - '@aws-sdk/credential-provider-http': 3.972.72 - '@aws-sdk/credential-provider-login': 3.972.77 - '@aws-sdk/credential-provider-process': 3.972.70 - '@aws-sdk/credential-provider-sso': 3.973.14 - '@aws-sdk/credential-provider-web-identity': 3.972.76 - '@aws-sdk/nested-clients': 3.997.44 - '@aws-sdk/types': 3.974.5 - '@smithy/core': 3.33.3 + '@aws-sdk/core': 3.978.1 + '@aws-sdk/credential-provider-env': 3.972.72 + '@aws-sdk/credential-provider-http': 3.972.74 + '@aws-sdk/credential-provider-login': 3.972.79 + '@aws-sdk/credential-provider-process': 3.972.72 + '@aws-sdk/credential-provider-sso': 3.973.16 + '@aws-sdk/credential-provider-web-identity': 3.972.78 + '@aws-sdk/nested-clients': 3.997.46 + '@aws-sdk/types': 3.974.6 + '@smithy/core': 3.35.1 '@smithy/credential-provider-imds': 4.5.2 - '@smithy/types': 4.18.0 + '@smithy/types': 4.19.0 tslib: 2.8.1 - '@aws-sdk/credential-provider-login@3.972.77': + '@aws-sdk/credential-provider-login@3.972.79': dependencies: - '@aws-sdk/core': 3.977.9 - '@aws-sdk/nested-clients': 3.997.44 - '@aws-sdk/types': 3.974.5 - '@smithy/core': 3.33.3 - '@smithy/types': 4.18.0 + '@aws-sdk/core': 3.978.1 + '@aws-sdk/nested-clients': 3.997.46 + '@aws-sdk/types': 3.974.6 + '@smithy/core': 3.35.1 + '@smithy/types': 4.19.0 tslib: 2.8.1 - '@aws-sdk/credential-provider-node@3.972.82': + '@aws-sdk/credential-provider-node@3.972.84': dependencies: - '@aws-sdk/credential-provider-env': 3.972.70 - '@aws-sdk/credential-provider-http': 3.972.72 - '@aws-sdk/credential-provider-ini': 3.973.15 - '@aws-sdk/credential-provider-process': 3.972.70 - '@aws-sdk/credential-provider-sso': 3.973.14 - '@aws-sdk/credential-provider-web-identity': 3.972.76 - '@aws-sdk/types': 3.974.5 - '@smithy/core': 3.33.3 + '@aws-sdk/credential-provider-env': 3.972.72 + '@aws-sdk/credential-provider-http': 3.972.74 + '@aws-sdk/credential-provider-ini': 3.973.17 + '@aws-sdk/credential-provider-process': 3.972.72 + '@aws-sdk/credential-provider-sso': 3.973.16 + '@aws-sdk/credential-provider-web-identity': 3.972.78 + '@aws-sdk/types': 3.974.6 + '@smithy/core': 3.35.1 '@smithy/credential-provider-imds': 4.5.2 - '@smithy/types': 4.18.0 + '@smithy/types': 4.19.0 tslib: 2.8.1 - '@aws-sdk/credential-provider-process@3.972.70': + '@aws-sdk/credential-provider-process@3.972.72': dependencies: - '@aws-sdk/core': 3.977.9 - '@aws-sdk/types': 3.974.5 - '@smithy/core': 3.33.3 - '@smithy/types': 4.18.0 + '@aws-sdk/core': 3.978.1 + '@aws-sdk/types': 3.974.6 + '@smithy/core': 3.35.1 + '@smithy/types': 4.19.0 tslib: 2.8.1 - '@aws-sdk/credential-provider-sso@3.973.14': + '@aws-sdk/credential-provider-sso@3.973.16': dependencies: - '@aws-sdk/core': 3.977.9 - '@aws-sdk/nested-clients': 3.997.44 - '@aws-sdk/token-providers': 3.1116.0 - '@aws-sdk/types': 3.974.5 - '@smithy/core': 3.33.3 - '@smithy/types': 4.18.0 + '@aws-sdk/core': 3.978.1 + '@aws-sdk/nested-clients': 3.997.46 + '@aws-sdk/token-providers': 3.1138.0 + '@aws-sdk/types': 3.974.6 + '@smithy/core': 3.35.1 + '@smithy/types': 4.19.0 tslib: 2.8.1 - '@aws-sdk/credential-provider-web-identity@3.972.76': + '@aws-sdk/credential-provider-web-identity@3.972.78': dependencies: - '@aws-sdk/core': 3.977.9 - '@aws-sdk/nested-clients': 3.997.44 - '@aws-sdk/types': 3.974.5 - '@smithy/core': 3.33.3 - '@smithy/types': 4.18.0 + '@aws-sdk/core': 3.978.1 + '@aws-sdk/nested-clients': 3.997.46 + '@aws-sdk/types': 3.974.6 + '@smithy/core': 3.35.1 + '@smithy/types': 4.19.0 tslib: 2.8.1 - '@aws-sdk/middleware-sdk-s3@3.972.75': + '@aws-sdk/middleware-sdk-s3@3.972.77': dependencies: - '@aws-sdk/core': 3.977.9 - '@aws-sdk/signature-v4-multi-region': 3.996.46 - '@aws-sdk/types': 3.974.5 - '@smithy/core': 3.33.3 - '@smithy/types': 4.18.0 + '@aws-sdk/core': 3.978.1 + '@aws-sdk/signature-v4-multi-region': 3.996.47 + '@aws-sdk/types': 3.974.6 + '@smithy/core': 3.35.1 + '@smithy/types': 4.19.0 tslib: 2.8.1 - '@aws-sdk/nested-clients@3.997.44': + '@aws-sdk/nested-clients@3.997.46': dependencies: - '@aws-sdk/core': 3.977.9 - '@aws-sdk/signature-v4-multi-region': 3.996.46 - '@aws-sdk/types': 3.974.5 - '@smithy/core': 3.33.3 + '@aws-sdk/core': 3.978.1 + '@aws-sdk/signature-v4-multi-region': 3.996.47 + '@aws-sdk/types': 3.974.6 + '@smithy/core': 3.35.1 '@smithy/fetch-http-handler': 5.8.0 '@smithy/node-http-handler': 4.12.1 - '@smithy/types': 4.18.0 + '@smithy/types': 4.19.0 tslib: 2.8.1 - '@aws-sdk/signature-v4-multi-region@3.996.46': + '@aws-sdk/signature-v4-multi-region@3.996.47': dependencies: - '@aws-sdk/types': 3.974.5 - '@smithy/signature-v4': 5.7.3 - '@smithy/types': 4.18.0 + '@aws-sdk/types': 3.974.6 + '@smithy/signature-v4': 5.7.4 + '@smithy/types': 4.19.0 tslib: 2.8.1 - '@aws-sdk/token-providers@3.1116.0': + '@aws-sdk/token-providers@3.1138.0': dependencies: - '@aws-sdk/core': 3.977.9 - '@aws-sdk/nested-clients': 3.997.44 - '@aws-sdk/types': 3.974.5 - '@smithy/core': 3.33.3 - '@smithy/types': 4.18.0 + '@aws-sdk/core': 3.978.1 + '@aws-sdk/nested-clients': 3.997.46 + '@aws-sdk/types': 3.974.6 + '@smithy/core': 3.35.1 + '@smithy/types': 4.19.0 tslib: 2.8.1 - '@aws-sdk/types@3.974.5': + '@aws-sdk/types@3.974.6': dependencies: - '@smithy/types': 4.18.0 + '@smithy/types': 4.19.0 tslib: 2.8.1 - '@aws-sdk/xml-builder@3.972.40': + '@aws-sdk/xml-builder@3.972.41': dependencies: - '@smithy/types': 4.18.0 + '@smithy/types': 4.19.0 tslib: 2.8.1 '@aws/lambda-invoke-store@0.3.0': {} @@ -12614,36 +12614,36 @@ snapshots: - debug - supports-color - '@smithy/core@3.33.3': + '@smithy/core@3.35.1': dependencies: - '@smithy/types': 4.18.0 + '@smithy/types': 4.19.0 tslib: 2.8.1 '@smithy/credential-provider-imds@4.5.2': dependencies: - '@smithy/core': 3.33.3 - '@smithy/types': 4.18.0 + '@smithy/core': 3.35.1 + '@smithy/types': 4.19.0 tslib: 2.8.1 '@smithy/fetch-http-handler@5.8.0': dependencies: - '@smithy/core': 3.33.3 - '@smithy/types': 4.18.0 + '@smithy/core': 3.35.1 + '@smithy/types': 4.19.0 tslib: 2.8.1 '@smithy/node-http-handler@4.12.1': dependencies: - '@smithy/core': 3.33.3 - '@smithy/types': 4.18.0 + '@smithy/core': 3.35.1 + '@smithy/types': 4.19.0 tslib: 2.8.1 - '@smithy/signature-v4@5.7.3': + '@smithy/signature-v4@5.7.4': dependencies: - '@smithy/core': 3.33.3 - '@smithy/types': 4.18.0 + '@smithy/core': 3.35.1 + '@smithy/types': 4.19.0 tslib: 2.8.1 - '@smithy/types@4.18.0': + '@smithy/types@4.19.0': dependencies: tslib: 2.8.1 @@ -15060,7 +15060,7 @@ snapshots: lru_map@0.4.1: {} - lucide-react@1.38.0(react@19.2.8): + lucide-react@1.45.0(react@19.2.8): dependencies: react: 19.2.8 diff --git a/scripts/__tests__/provision-worktree-self-heal.test.mjs b/scripts/__tests__/provision-worktree-self-heal.test.mjs index bff638cdc4..dcedc7f075 100644 --- a/scripts/__tests__/provision-worktree-self-heal.test.mjs +++ b/scripts/__tests__/provision-worktree-self-heal.test.mjs @@ -102,7 +102,7 @@ process.exit(0); return baseCwd; } -function runProvision(baseCwd, { pathPrefix, setupWorktree, setupInstance, existingWorktree } = {}) { +function runProvision(baseCwd, { pathPrefix, setupWorktree, setupInstance, existingWorktree, env = {} } = {}) { const worktreeCwd = existingWorktree ?? makeTempDir("paperclip-provision-worktree-"); setupWorktree?.(worktreeCwd); const worktreesHome = makeTempDir("paperclip-provision-home-"); @@ -121,14 +121,16 @@ function runProvision(baseCwd, { pathPrefix, setupWorktree, setupInstance, exist PAPERCLIP_HOME: paperclipHome, PAPERCLIP_PROJECT_WORKSPACE_ID: "project-workspace-1", PAPERCLIP_SEED_EXPECTED_COMPANY_ID: "company-1", + ...(typeof env === "function" ? env(paperclipHome) : env), }, }); return { result, worktreeCwd, worktreesHome, paperclipHome }; } -function runRuntimeProvision(baseCwd, worktreeCwd) { +function runRuntimeProvision(baseCwd, worktreeCwd, { setupInstance } = {}) { const worktreesHome = makeTempDir("paperclip-provision-runtime-home-"); const paperclipHome = makeInstanceHome(); + setupInstance?.(paperclipHome); return spawnSync("bash", [runtimeScript], { cwd: worktreeCwd, encoding: "utf8", @@ -183,22 +185,122 @@ test("uses the base CLI when its import graph boots", () => { ); }); -test("explains unavailable instance source config without creating target state", () => { +for (const defaultConfigInEnv of [false, true]) { + test(`prepares a plain checkout without a default instance config (image default env: ${defaultConfigInEnv})`, () => { + const baseCwd = makeBaseWorkspace({ helpExit: 0, initExit: 0 }); + const { result, worktreeCwd } = runProvision(baseCwd, { + setupInstance: (home) => fs.rmSync(path.join(home, "instances", "default", "config.json")), + env: (home) => defaultConfigInEnv ? { PAPERCLIP_CONFIG: path.join(home, "instances", "default", "config.json") } : {}, + }); + + assert.equal(result.status, 0, result.stderr); + assert.match(result.stderr, /preparing checkout dependencies without a seeded development instance/); + for (const file of ["config.json", ".env", "seed-manifest.json", "seed-pending", "seed-complete"]) { + assert.equal(fs.existsSync(path.join(worktreeCwd, ".paperclip", file)), false); + } + assert.equal(fs.realpathSync(path.join(worktreeCwd, "cli", "node_modules")), path.join(baseCwd, "cli", "node_modules")); + assert.deepEqual(readCliInvocations(baseCwd), []); + }); +} + +test("rejects a missing custom seed source", () => { const baseCwd = makeBaseWorkspace({ helpExit: 0, initExit: 0 }); const { result, worktreeCwd } = runProvision(baseCwd, { - setupInstance: (home) => fs.rmSync(path.join(home, "instances", "default", "config.json")), + env: { PAPERCLIP_CONFIG: path.join(baseCwd, "missing", "config.json") }, }); assert.notEqual(result.status, 0); assert.match(result.stderr, /config is unavailable \(control-plane instance\)/); assert.match(result.stderr, /For a seeded development instance, configure a canonical config/); - assert.match(result.stderr, /Only for a checkout-only worktree/); - assert.match(result.stderr, /workspaceStrategy\.provisionCommand to "true"/); - assert.match(result.stderr, /does not prepare a development runtime/); assert.equal(fs.existsSync(path.join(worktreeCwd, ".paperclip")), false); assert.deepEqual(readCliInvocations(baseCwd), []); }); +test("initializes a previously plain checkout once a registered seed source is available", () => { + const baseCwd = makeBaseWorkspace({ helpExit: 0, initExit: 0 }); + const first = runProvision(baseCwd, { + setupInstance: (home) => fs.rmSync(path.join(home, "instances"), { recursive: true }), + }); + assert.equal(first.result.status, 0, first.result.stderr); + assert.deepEqual(readCliInvocations(baseCwd), []); + + const second = runProvision(baseCwd, { existingWorktree: first.worktreeCwd }); + assert.equal(second.result.status, 0, second.result.stderr); + assert.equal(readWorktreeConfig(first.worktreeCwd).$meta.source, "fake-cli"); + assert.equal(JSON.parse(fs.readFileSync(path.join(first.worktreeCwd, ".paperclip", "seed-manifest.json"), "utf8")).state, "pending"); +}); + +test("a plain checkout without a source is not ready for a seeded runtime", () => { + const baseCwd = makeBaseWorkspace({ helpExit: 0, initExit: 0 }); + fs.mkdirSync(path.join(baseCwd, "scripts")); + fs.copyFileSync(script, path.join(baseCwd, "scripts", "provision-worktree.sh")); + const setupInstance = (home) => fs.rmSync(path.join(home, "instances"), { recursive: true }); + const { result, worktreeCwd } = runProvision(baseCwd, { setupInstance }); + assert.equal(result.status, 0, result.stderr); + + const runtime = runRuntimeProvision(baseCwd, worktreeCwd, { setupInstance }); + assert.notEqual(runtime.status, 0); + assert.match(runtime.stderr, /Worktree config still does not exist after built-in provisioning/); + assert.equal(fs.existsSync(path.join(worktreeCwd, ".paperclip", "seed-manifest.json")), false); + assert.deepEqual(readCliInvocations(baseCwd), []); +}); + +for (const relativePath of ["instances/default/config.json", "instances/default"]) { + test(`rejects a dangling implicit source symlink at ${relativePath}`, () => { + const baseCwd = makeBaseWorkspace({ helpExit: 0, initExit: 0 }); + const { result, worktreeCwd } = runProvision(baseCwd, { + setupInstance(home) { + const sourcePath = path.join(home, relativePath); + fs.rmSync(sourcePath, { recursive: true }); + fs.symlinkSync(path.join(home, "missing"), sourcePath); + }, + }); + assert.notEqual(result.status, 0); + assert.match(result.stderr, /canonical/); + assert.equal(fs.existsSync(path.join(worktreeCwd, ".paperclip")), false); + assert.deepEqual(readCliInvocations(baseCwd), []); + }); +} + +for (const file of ["config.json", ".env", "seed-manifest.json", "seed-pending", "seed-complete"]) { + test(`does not downgrade an existing development instance with ${file} when its source disappears`, () => { + const baseCwd = makeBaseWorkspace({ helpExit: 0, initExit: 0 }); + const { result, worktreeCwd } = runProvision(baseCwd, { + setupInstance: (home) => fs.rmSync(path.join(home, "instances", "default", "config.json")), + setupWorktree(root) { + fs.mkdirSync(path.join(root, ".paperclip")); + fs.writeFileSync(path.join(root, ".paperclip", file), "retained state\n"); + }, + }); + assert.notEqual(result.status, 0); + assert.match(result.stderr, /config is unavailable/); + assert.equal(fs.readFileSync(path.join(worktreeCwd, ".paperclip", file), "utf8"), "retained state\n"); + assert.deepEqual(readCliInvocations(baseCwd), []); + }); +} + +for (const installExit of [0, 42]) { + test(`plain checkout dependency provisioning preserves install exit ${installExit}`, () => { + const baseCwd = makeBaseWorkspace({ helpExit: 0, initExit: 0 }); + const bin = makeTempDir("paperclip-checkout-pnpm-"); + fs.writeFileSync(path.join(bin, "pnpm"), `#!/bin/sh\nprintf '%s\\n' "$*" >> pnpm-calls\nmkdir -p node_modules cli/node_modules\nexit ${installExit}\n`, { mode: 0o700 }); + const { result, worktreeCwd } = runProvision(baseCwd, { + pathPrefix: bin, + setupInstance: (home) => fs.rmSync(path.join(home, "instances", "default", "config.json")), + setupWorktree(root) { + fs.writeFileSync(path.join(root, "package.json"), "{}\n"); + fs.writeFileSync(path.join(root, "pnpm-lock.yaml"), "lockfileVersion: '9.0'\n"); + }, + }); + assert.equal(result.status, installExit, result.stderr); + assert.match(fs.readFileSync(path.join(worktreeCwd, "pnpm-calls"), "utf8"), /^install --prod=false --frozen-lockfile/); + assert.equal(fs.existsSync(path.join(worktreeCwd, ".paperclip", "pnpm-install-fingerprint")), installExit === 0); + assert.equal(fs.existsSync(path.join(worktreeCwd, ".paperclip", "config.json")), false); + assert.equal(fs.existsSync(path.join(worktreeCwd, ".paperclip", "seed-manifest.json")), false); + assert.deepEqual(readCliInvocations(baseCwd), []); + }); +} + test("rejects a dangling base workspace config symlink instead of falling back", () => { const baseCwd = makeBaseWorkspace({ helpExit: 0, initExit: 0 }); fs.mkdirSync(path.join(baseCwd, ".paperclip"), { recursive: true }); diff --git a/scripts/provision-worktree.sh b/scripts/provision-worktree.sh index 2418fa3bb0..bb57604c83 100644 --- a/scripts/provision-worktree.sh +++ b/scripts/provision-worktree.sh @@ -5,6 +5,7 @@ base_cwd="${PAPERCLIP_WORKSPACE_BASE_CWD:?PAPERCLIP_WORKSPACE_BASE_CWD is requir worktree_cwd="${PAPERCLIP_WORKSPACE_CWD:?PAPERCLIP_WORKSPACE_CWD is required}" paperclip_home="${PAPERCLIP_HOME:-$HOME/.paperclip}" paperclip_instance_id="${PAPERCLIP_INSTANCE_ID:-default}" +default_source_config_path="$paperclip_home/instances/$paperclip_instance_id/config.json" paperclip_dir="$worktree_cwd/.paperclip" worktree_config_path="$paperclip_dir/config.json" worktree_env_path="$paperclip_dir/.env" @@ -53,26 +54,52 @@ if [[ ! -e "$source_config_path" && ! -L "$source_config_path" ]]; then # A base workspace that is a plain checkout carries no instance config of its own. # Fall back to the control plane's own registered instance config, which is process # state this workspace cannot rewrite. - source_config_path="${PAPERCLIP_CONFIG:-$paperclip_home/instances/$paperclip_instance_id/config.json}" + source_config_path="${PAPERCLIP_CONFIG:-$default_source_config_path}" source_config_origin="control-plane instance" -fi -if [[ ! -f "$source_config_path" || -L "$source_config_path" ]]; then - if [[ ! -e "$source_config_path" && ! -L "$source_config_path" ]]; then - echo "Registered Paperclip seed source config is unavailable ($source_config_origin): $source_config_path" >&2 - echo "For a seeded development instance, configure a canonical config for that registered source before retrying." >&2 - echo 'Only for a checkout-only worktree, explicitly set workspaceStrategy.provisionCommand to "true". This skips setup; it does not prepare a development runtime.' >&2 - else - echo "Registered Paperclip seed source config is not a canonical file ($source_config_origin): $source_config_path" >&2 - echo "Repair the registered source path; symlinks and non-regular files are not accepted." >&2 + # Environment-configured servers need no local instance config. A new plain + # checkout can still prepare dependencies without inventing a seed source. + # The Docker image sets PAPERCLIP_CONFIG to this default even without a file. + # Custom sources and existing development instances must still fail closed. + if [[ "$source_config_path" == "$default_source_config_path" && ! -e "$source_config_path" && ! -L "$source_config_path" ]]; then + source_required=0 + for target_state in "$worktree_config_path" "$worktree_env_path" "$seed_manifest_path" "$seed_pending_marker_path" "$seed_complete_marker_path"; do + if [[ -e "$target_state" || -L "$target_state" ]]; then + source_required=1 + fi + done + if [[ "$source_required" -eq 0 ]]; then + # Do not mistake a broken or aliased parent path for an absent instance. + source_parent="$(dirname "$source_config_path")" + while [[ ! -e "$source_parent" && ! -L "$source_parent" ]]; do + source_parent="$(dirname "$source_parent")" + done + if [[ ! -d "$source_parent" || -L "$source_parent" || "$(cd "$source_parent" && pwd -P)" != "$source_parent" ]]; then + echo "Registered Paperclip seed source config has a non-canonical parent: $source_config_path" >&2 + exit 1 + fi + echo "No local Paperclip seed source config; preparing checkout dependencies without a seeded development instance." >&2 + source_config_path="" + fi fi - exit 1 fi -canonical_source_dir="$(cd "$(dirname "$source_config_path")" && pwd -P)" -if [[ "$canonical_source_dir/config.json" != "$source_config_path" ]]; then - echo "Registered Paperclip seed source config uses a symlink alias: $source_config_path" >&2 - exit 1 +if [[ -n "$source_config_path" ]]; then + if [[ ! -f "$source_config_path" || -L "$source_config_path" ]]; then + if [[ ! -e "$source_config_path" && ! -L "$source_config_path" ]]; then + echo "Registered Paperclip seed source config is unavailable ($source_config_origin): $source_config_path" >&2 + echo "For a seeded development instance, configure a canonical config for that registered source before retrying." >&2 + else + echo "Registered Paperclip seed source config is not a canonical file ($source_config_origin): $source_config_path" >&2 + echo "Repair the registered source path; symlinks and non-regular files are not accepted." >&2 + fi + exit 1 + fi + canonical_source_dir="$(cd "$(dirname "$source_config_path")" && pwd -P)" + if [[ "$canonical_source_dir/config.json" != "$source_config_path" ]]; then + echo "Registered Paperclip seed source config uses a symlink alias: $source_config_path" >&2 + exit 1 + fi + source_env_path="$(dirname "$source_config_path")/.env" fi -source_env_path="$(dirname "$source_config_path")/.env" mkdir -p "$paperclip_dir" @@ -619,44 +646,46 @@ main().catch((error) => { EOF } -if [[ -e "$worktree_config_path" && -e "$worktree_env_path" ]] && existing_worktree_config_is_usable; then - echo "Reusing existing isolated Paperclip worktree config at $worktree_config_path" >&2 -else - if [[ -e "$worktree_config_path" || -e "$worktree_env_path" ]]; then - echo "Existing isolated Paperclip worktree config is stale for this host; regenerating." >&2 - fi - if paperclipai_command_available; then - if run_isolated_worktree_init; then - : - else - init_exit_code=$? - if [[ "$init_exit_code" -eq 127 ]]; then - # Every CLI candidate was unusable (e.g. an unhealthy base install that - # the repair could not fix); degrade instead of stranding the run. - echo "No usable paperclipai CLI found; writing isolated fallback config without DB seeding." >&2 - write_fallback_worktree_config - else - # A CLI that ran and failed signals a real problem; do not paper over - # it with an unseeded fallback config. - echo "paperclipai worktree init failed (exit $init_exit_code); failing provisioning instead of writing an unseeded fallback config." >&2 - exit "$init_exit_code" - fi - fi +if [[ -n "$source_config_path" ]]; then + if [[ -e "$worktree_config_path" && -e "$worktree_env_path" ]] && existing_worktree_config_is_usable; then + echo "Reusing existing isolated Paperclip worktree config at $worktree_config_path" >&2 else - echo "paperclipai worktree init unavailable; writing isolated fallback config without DB seeding." >&2 - write_fallback_worktree_config + if [[ -e "$worktree_config_path" || -e "$worktree_env_path" ]]; then + echo "Existing isolated Paperclip worktree config is stale for this host; regenerating." >&2 + fi + if paperclipai_command_available; then + if run_isolated_worktree_init; then + : + else + init_exit_code=$? + if [[ "$init_exit_code" -eq 127 ]]; then + # Every CLI candidate was unusable (e.g. an unhealthy base install that + # the repair could not fix); degrade instead of stranding the run. + echo "No usable paperclipai CLI found; writing isolated fallback config without DB seeding." >&2 + write_fallback_worktree_config + else + # A CLI that ran and failed signals a real problem; do not paper over + # it with an unseeded fallback config. + echo "paperclipai worktree init failed (exit $init_exit_code); failing provisioning instead of writing an unseeded fallback config." >&2 + exit "$init_exit_code" + fi + fi + else + echo "paperclipai worktree init unavailable; writing isolated fallback config without DB seeding." >&2 + write_fallback_worktree_config + fi + created_worktree_config=1 fi - created_worktree_config=1 -fi -# The target config can predate a deployment-mode change on the registered -# source, and older/fallback CLI writers may default this field independently. -# Reconcile it after either create or reuse so the final guest config always -# carries the source's deployment/auth contract without replacing its database. -reconcile_worktree_deployment_mode + # The target config can predate a deployment-mode change on the registered + # source, and older/fallback CLI writers may default this field independently. + # Reconcile it after either create or reuse so the final guest config always + # carries the source's deployment/auth contract without replacing its database. + reconcile_worktree_deployment_mode -if [[ "$created_worktree_config" -eq 1 && ! -e "$seed_manifest_path" && ! -e "$seed_pending_marker_path" && ! -e "$seed_complete_marker_path" ]]; then - write_seed_pending_manifest + if [[ "$created_worktree_config" -eq 1 && ! -e "$seed_manifest_path" && ! -e "$seed_pending_marker_path" && ! -e "$seed_complete_marker_path" ]]; then + write_seed_pending_manifest + fi fi list_base_node_modules_paths() { diff --git a/server/package.json b/server/package.json index fe28203048..8705db49b7 100644 --- a/server/package.json +++ b/server/package.json @@ -44,7 +44,7 @@ "typecheck": "pnpm run prepare:runner-vendor && pnpm --filter @paperclipai/plugin-sdk ensure-build-deps && tsc --noEmit" }, "dependencies": { - "@aws-sdk/client-s3": "^3.1122.0", + "@aws-sdk/client-s3": "^3.1141.0", "@chat-adapter/discord": "4.39.0", "@chat-adapter/github": "4.39.0", "@chat-adapter/slack": "4.39.0", diff --git a/server/src/__tests__/agent-cross-tenant-authz-routes.test.ts b/server/src/__tests__/agent-cross-tenant-authz-routes.test.ts index ddb3f40da0..d5fabca417 100644 --- a/server/src/__tests__/agent-cross-tenant-authz-routes.test.ts +++ b/server/src/__tests__/agent-cross-tenant-authz-routes.test.ts @@ -420,7 +420,7 @@ describe.sequential("agent cross-tenant route authorization", () => { expect(res.body.error).toContain("Key not found"); expect(mockAgentService.getKeyById).toHaveBeenCalledWith(keyId); expect(mockAgentService.revokeKey).not.toHaveBeenCalled(); - }); + }, 30_000); it("requires board access before clearing an agent error", async () => { const app = await createApp({ diff --git a/server/src/__tests__/dashboard-service.test.ts b/server/src/__tests__/dashboard-service.test.ts index 05a1a738c9..8642844ce5 100644 --- a/server/src/__tests__/dashboard-service.test.ts +++ b/server/src/__tests__/dashboard-service.test.ts @@ -6,6 +6,7 @@ import { startEmbeddedPostgresTestDatabase, } from "./helpers/embedded-postgres.js"; import { dashboardService, getUtcMonthStart } from "../services/dashboard.ts"; +import { selectDashboardRunIds } from "../services/dashboard-run-selection.ts"; const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport(); const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip; @@ -56,6 +57,42 @@ describeEmbeddedPostgres("dashboard service", () => { await tempDb?.cleanup(); }); + it("selects distinct task cards before limiting and keeps taskless runs separate", async () => { + const companyId = randomUUID(); + const otherCompanyId = randomUUID(); + const agentId = randomUUID(); + const otherAgentId = randomUUID(); + const repeatedIssueId = randomUUID(); + const otherIssueId = randomUUID(); + const activeRunId = randomUUID(); + const otherTaskRunId = randomUUID(); + const tasklessRunId = randomUUID(); + + await db.insert(companies).values([ + { id: companyId, name: "Dashboard cards", issuePrefix: "CARDS" }, + { id: otherCompanyId, name: "Other company", issuePrefix: "OTHER" }, + ]); + await db.insert(agents).values([ + { id: agentId, companyId, name: "Builder", role: "engineer", adapterType: "codex_local", adapterConfig: {}, runtimeConfig: {}, permissions: {} }, + { id: otherAgentId, companyId: otherCompanyId, name: "Other", role: "engineer", adapterType: "codex_local", adapterConfig: {}, runtimeConfig: {}, permissions: {} }, + ]); + await db.insert(heartbeatRuns).values([ + ...Array.from({ length: 6 }, (_, index) => ({ + id: randomUUID(), companyId, agentId, status: "succeeded", + contextSnapshot: { issueId: repeatedIssueId }, + createdAt: new Date(`2026-04-10T10:0${index}:00.000Z`), + })), + { id: activeRunId, companyId, agentId, status: "running", contextSnapshot: { issueId: repeatedIssueId }, createdAt: new Date("2026-04-10T08:00:00.000Z") }, + { id: otherTaskRunId, companyId, agentId, status: "succeeded", contextSnapshot: { issueId: otherIssueId }, createdAt: new Date("2026-04-10T09:04:00.000Z") }, + { id: tasklessRunId, companyId, agentId, status: "succeeded", createdAt: new Date("2026-04-10T09:03:00.000Z") }, + { id: randomUUID(), companyId: otherCompanyId, agentId: otherAgentId, status: "running", contextSnapshot: { issueId: otherIssueId }, createdAt: new Date("2026-04-10T11:00:00.000Z") }, + ]); + + expect(await selectDashboardRunIds(db, companyId, 3)).toEqual([ + activeRunId, otherTaskRunId, tasklessRunId, + ]); + }); + it("aggregates the full 14-day run activity window without recent-run truncation", async () => { const companyId = randomUUID(); const otherCompanyId = randomUUID(); diff --git a/server/src/__tests__/docker-build-stamp.test.ts b/server/src/__tests__/docker-build-stamp.test.ts index 8d2b726bba..3e52e6e465 100644 --- a/server/src/__tests__/docker-build-stamp.test.ts +++ b/server/src/__tests__/docker-build-stamp.test.ts @@ -106,21 +106,32 @@ describe("Docker Rust dependency cache", () => { }); -describe("Cloud remote provider pack", () => { - it("ships a build-owned pack without provisioning Grok on the controller", () => { - const pack = stageBody(dockerfile, "cloud-provider-pack"); - const cloud = stageBody(dockerfile, "cloud"); +describe("Standard image remote provider pack", () => { + it("ships the build-owned pack in the production base used by downstream images", () => { + const pack = stageBody(dockerfile, "runner-provider-pack"); + const production = stageBody(dockerfile, "production"); + expect(pack).toContain("FROM build AS runner-provider-pack"); expect(pack).toContain('PAPERCLIP_RUNNER_SOURCE_REVISION="${PAPERCLIP_BUILD_COMMIT}"'); expect(pack).toContain("build-provider-pack.mjs /provider-pack"); expect(pack).toContain('if [ -n "${PAPERCLIP_BUILD_COMMIT}" ]; then'); expect(pack).toContain("mkdir -p /provider-pack"); expect(pack).toContain("Skipping remote provider pack"); - expect(cloud).toContain("--from=cloud-provider-pack /provider-pack /opt/paperclip-runner/provider-pack"); - expect(cloud).not.toContain("--chown=node:node --from=cloud-provider-pack"); - expect(cloud).toContain("chmod -R a+rX /opt/paperclip-runner/provider-pack"); - expect(cloud).toContain("gosu 65534:65534 node"); - expect(cloud).toContain("Object.values(manifest.payload.artifacts)"); - expect(cloud).toContain("PAPERCLIP_RUNNER_REMOTE_PROVIDER_PACK_PATH=/opt/paperclip-runner/provider-pack"); + expect(production).toContain("--from=runner-provider-pack /provider-pack /opt/paperclip-runner/provider-pack"); + expect(production).not.toContain("--chown=node:node --from=runner-provider-pack"); + expect(production).toContain("chmod -R a+rX /opt/paperclip-runner/provider-pack"); + expect(production).toContain("gosu 65534:65534 node"); + expect(production).toContain("Object.values(manifest.payload.artifacts)"); + expect(production).toContain("PAPERCLIP_RUNNER_REMOTE_PROVIDER_PACK_PATH=/opt/paperclip-runner/provider-pack"); + expect(workflow).toMatch(/^\s*target: production$/m); expect(dockerfile).not.toContain("provision-grok.mjs"); }); + + it("lets explicit cloud preview builds inherit the same pack and configuration", () => { + const cloud = stageBody(dockerfile, "cloud"); + expect(cloud).toContain("FROM production AS cloud"); + expect(cloud).not.toContain("/opt/paperclip-runner/provider-pack"); + expect(cloud).not.toContain("PAPERCLIP_RUNNER_REMOTE_PROVIDER_PACK_PATH"); + expect(dockerfile.match(/build-provider-pack\.mjs \/provider-pack/g)).toHaveLength(1); + expect(previewWorkflow).toMatch(/^\s*target: cloud$/m); + }); }); diff --git a/server/src/__tests__/workspace-runtime.test.ts b/server/src/__tests__/workspace-runtime.test.ts index 97dd8430f6..25ac38e32b 100644 --- a/server/src/__tests__/workspace-runtime.test.ts +++ b/server/src/__tests__/workspace-runtime.test.ts @@ -920,6 +920,43 @@ describe("realizeExecutionWorkspace", () => { } }); + it.each([false, true])("realizes a plain Paperclip checkout without a local seed instance (image default env: %s)", async (defaultConfigInEnv) => { + const repoRoot = await createTempRepo(); + const paperclipHome = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-env-only-home-"))); + const previousEnv = { + PAPERCLIP_HOME: process.env.PAPERCLIP_HOME, + PAPERCLIP_CONFIG: process.env.PAPERCLIP_CONFIG, + PAPERCLIP_INSTANCE_ID: process.env.PAPERCLIP_INSTANCE_ID, + }; + try { + await fs.mkdir(path.join(repoRoot, "scripts")); + await fs.copyFile(provisionWorktreeScriptPath, path.join(repoRoot, "scripts", "provision-worktree.sh")); + await runGit(repoRoot, ["add", "scripts/provision-worktree.sh"]); + await runGit(repoRoot, ["commit", "-m", "Add Paperclip worktree provisioner"]); + process.env.PAPERCLIP_HOME = paperclipHome; + process.env.PAPERCLIP_INSTANCE_ID = "default"; + if (defaultConfigInEnv) process.env.PAPERCLIP_CONFIG = path.join(paperclipHome, "instances", "default", "config.json"); + else delete process.env.PAPERCLIP_CONFIG; + + const workspace = await realizeWorktreeForTest(repoRoot, "HEAD"); + + expect(workspace.strategy).toBe("git_worktree"); + expect(workspace.created).toBe(true); + expect(await readGit(workspace.cwd, ["branch", "--show-current"])).toBe(workspace.branchName); + for (const file of ["config.json", ".env", "seed-manifest.json", "seed-pending", "seed-complete"]) { + expect(existsSync(path.join(workspace.cwd, ".paperclip", file))).toBe(false); + } + expect(await fs.readdir(paperclipHome)).toEqual([]); + } finally { + for (const [key, value] of Object.entries(previousEnv)) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + await fs.rm(repoRoot, { recursive: true, force: true }); + await fs.rm(paperclipHome, { recursive: true, force: true }); + } + }); + it("defaults the repo-provided worktree provisioner for git worktree strategies", async () => { const repoRoot = await createTempRepo(); await fs.mkdir(path.join(repoRoot, "scripts"), { recursive: true }); diff --git a/server/src/routes/agents.ts b/server/src/routes/agents.ts index 7dcf5c5403..9528d597d6 100644 --- a/server/src/routes/agents.ts +++ b/server/src/routes/agents.ts @@ -16,6 +16,7 @@ import { applyConnectorSkills, resolveConnectorAssignments, annotateConnectorSki import { getExecutionBlocker } from "../services/execution-blocker.js"; import { paperclipRunnerTransitionConfig, normalizeLegacyRunnerProvider, isPaperclipRunnerProvider } from "@paperclipai/adapter-utils"; import { executionProjectionForRun, executionProjectionsForRuns } from "../services/execution-projection.js"; +import { selectDashboardRunIds } from "../services/dashboard-run-selection.js"; import { Router, type NextFunction, type Request, type Response } from "express"; import { generateKeyPairSync, randomUUID } from "node:crypto"; import { readFile, rm } from "node:fs/promises"; @@ -6874,6 +6875,7 @@ export function agentRoutes( // padded in and renders bogus "live" counts. const minCount = readLiveRunsQueryInt(req.query.minCount, 50, 0); const limit = readLiveRunsQueryInt(req.query.limit, 50, 50); + const distinctTasks = req.query.distinctTasks === "true"; const columns = { id: heartbeatRuns.id, @@ -6889,7 +6891,7 @@ export function agentRoutes( createdAt: heartbeatRuns.createdAt, agentId: heartbeatRuns.agentId, agentName: agentsTable.name, - agentAppearance: agentsTable.appearance, + agentAppearance: agentsTable.appearance, adapterType: agentsTable.adapterType, logBytes: heartbeatRuns.logBytes, livenessState: heartbeatRuns.livenessState, @@ -6917,10 +6919,25 @@ export function agentRoutes( ) .orderBy(desc(heartbeatRuns.createdAt)); - const liveRuns = await liveRunsQuery.limit(limit); + const liveRuns = distinctTasks ? [] : await liveRunsQuery.limit(limit); + let rows = liveRuns; const targetRunCount = Math.min(minCount, limit); - if (targetRunCount > 0 && liveRuns.length < targetRunCount) { + if (distinctTasks) { + // Return enough representatives for the dashboard to count cards beyond + // its visible four, rather than stopping at the minimum display count. + const selectedIds = await selectDashboardRunIds(db, companyId, limit); + const selectedRows = selectedIds.length === 0 ? [] : await db + .select(columns) + .from(heartbeatRuns) + .innerJoin(agentsTable, eq(heartbeatRuns.agentId, agentsTable.id)) + .where(and(eq(heartbeatRuns.companyId, companyId), inArray(heartbeatRuns.id, selectedIds))); + const byId = new Map(selectedRows.map((run) => [run.id, run])); + rows = selectedIds.flatMap((id) => { + const run = byId.get(id); + return run ? [run] : []; + }); + } else if (targetRunCount > 0 && liveRuns.length < targetRunCount) { const activeIds = liveRuns.map((r) => r.id); const recentRuns = await db .select(columns) @@ -6936,24 +6953,15 @@ export function agentRoutes( .orderBy(desc(heartbeatRuns.createdAt)) .limit(targetRunCount - liveRuns.length); - const rows = [...liveRuns, ...recentRuns]; - const projections = await executionProjectionsForRuns(db, companyId, rows.map(run => run.id)); - res.json(await runRedactions.redactForRuns(companyId, await Promise.all(rows.map(async (run) => ({ - ...heartbeat.decorateActiveRunStatus(run), - agentAppearance: resolveAgentAppearance(run.agentAppearance, run.agentId), - avatarUrl: agentAvatarUrl(resolveAgentAppearance(run.agentAppearance, run.agentId), 512), - execution: projections.get(run.id) ?? null, - outputSilence: await heartbeat.buildRunOutputSilence(run), - }))))); - return; + rows = [...liveRuns, ...recentRuns]; } - const projections = await executionProjectionsForRuns(db, companyId, liveRuns.map(run => run.id)); - res.json(await runRedactions.redactForRuns(companyId, await Promise.all(liveRuns.map(async (run) => ({ + const projections = await executionProjectionsForRuns(db, companyId, rows.map(run => run.id)); + res.json(await runRedactions.redactForRuns(companyId, await Promise.all(rows.map(async (run) => ({ ...heartbeat.decorateActiveRunStatus(run), - agentAppearance: resolveAgentAppearance(run.agentAppearance, run.agentId), - avatarUrl: agentAvatarUrl(resolveAgentAppearance(run.agentAppearance, run.agentId), 512), - execution: projections.get(run.id) ?? null, + agentAppearance: resolveAgentAppearance(run.agentAppearance, run.agentId), + avatarUrl: agentAvatarUrl(resolveAgentAppearance(run.agentAppearance, run.agentId), 512), + execution: projections.get(run.id) ?? null, outputSilence: await heartbeat.buildRunOutputSilence(run), }))))); }); diff --git a/server/src/services/dashboard-run-selection.ts b/server/src/services/dashboard-run-selection.ts new file mode 100644 index 0000000000..1efe82f9da --- /dev/null +++ b/server/src/services/dashboard-run-selection.ts @@ -0,0 +1,49 @@ +import type { Db } from "@paperclipai/db"; +import { heartbeatRuns } from "@paperclipai/db"; +import { and, desc, eq, inArray, sql } from "drizzle-orm"; + +const MAX_CANDIDATES_PER_POOL = 1_000; +const ACTIVE_STATUSES = ["queued", "running"]; + +/** Select recent dashboard cards without ranking the company's full run history. */ +export async function selectDashboardRunIds(db: Db, companyId: string, limit: number): Promise { + if (limit <= 0) return []; + + const columns = { + id: heartbeatRuns.id, + status: heartbeatRuns.status, + createdAt: heartbeatRuns.createdAt, + issueId: sql`${heartbeatRuns.contextSnapshot} ->> 'issueId'`, + }; + const [activeRuns, recentRuns] = await Promise.all([ + db.select(columns) + .from(heartbeatRuns) + .where(and(eq(heartbeatRuns.companyId, companyId), inArray(heartbeatRuns.status, ACTIVE_STATUSES))) + .orderBy(desc(heartbeatRuns.createdAt), desc(heartbeatRuns.id)) + .limit(MAX_CANDIDATES_PER_POOL), + db.select(columns) + .from(heartbeatRuns) + .where(eq(heartbeatRuns.companyId, companyId)) + .orderBy(desc(heartbeatRuns.createdAt), desc(heartbeatRuns.id)) + .limit(MAX_CANDIDATES_PER_POOL), + ]); + + const candidates = [...new Map([...activeRuns, ...recentRuns].map((run) => [run.id, run])).values()]; + candidates.sort((a, b) => { + const activeOrder = Number(ACTIVE_STATUSES.includes(b.status)) - Number(ACTIVE_STATUSES.includes(a.status)); + if (activeOrder !== 0) return activeOrder; + const createdOrder = b.createdAt.getTime() - a.createdAt.getTime(); + return createdOrder || (a.id < b.id ? 1 : a.id > b.id ? -1 : 0); + }); + + const selectedIds: string[] = []; + const seenCards = new Set(); + for (const run of candidates) { + const cardKey = run.issueId ? `issue:${run.issueId}` : `run:${run.id}`; + if (seenCards.has(cardKey)) continue; + seenCards.add(cardKey); + selectedIds.push(run.id); + if (selectedIds.length === limit) break; + } + return selectedIds; +} diff --git a/server/src/services/explicit-native-continuation.test.ts b/server/src/services/explicit-native-continuation.test.ts index 3c8c869137..d7aae395d6 100644 --- a/server/src/services/explicit-native-continuation.test.ts +++ b/server/src/services/explicit-native-continuation.test.ts @@ -1,3 +1,6 @@ +import { instanceSettingsService } from "./instance-settings.js"; +import { createPostgresWakeQueueAdapter } from "../modules/wake-queue/adapters/postgres.js"; +import { createReleaseIssueExecution } from "../modules/wake-queue/application/use-cases.js"; import * as nativeExecutor from "./native-runtime/native-session-executor.js"; import { appendHeartbeatRunEvent } from "./heartbeat-run-events.js"; import { recordNativeLocalProcessStop, hasNativeLocalProcessStop, PROCESS_START_REQUESTED } from "./native-local-process-stop.js"; @@ -19,6 +22,7 @@ import { buildExecutionContinuation } from "./execution-continuation.js"; import { heartbeatService, persistHeartbeatRunProcessMetadata, type HeartbeatEnvironmentRuntime } from "./heartbeat.js"; import { getExecutionBlocker } from "./execution-blocker.js"; import { createDurableChatWakeupRequest } from "./durable-chat-wakeup.js"; +import { recordNativeFinalizationFailure } from "./native-runtime/native-run-finalizer.js"; const support = await getEmbeddedPostgresTestSupport(); (support.supported ? describe : describe.skip)("explicit native conversation continuation", () => { let database: Awaited>; @@ -1029,6 +1033,188 @@ const support = await getEmbeddedPostgresTestSupport(); expect(await getExecutionBlocker(db, f.companyId, f.issueId)).not.toBeNull(); } }); + async function seedResultBeforeTerminalFailure(f: Fixture) { + const contractId = randomUUID(), resultId = randomUUID(); + await db.insert(completionContracts).values({ id: contractId, companyId: f.companyId, issueId: f.issueId, + revision: 1, schemaVersion: "paperclip.completion-contract.v1", policyVersion: "test", + risk: "standard", completionAuthority: "server_arbiter", incompleteCriteriaPolicy: "preserve_non_terminal", + contractJson: {}, canonicalSha256: contractId, createdByActorType: "system", createdByActorId: "test" }); + await db.update(heartbeatRuns).set({ completionContractId: contractId, errorCode: "provider_transport_failed" }) + .where(eq(heartbeatRuns.id, f.sourceRunId)); + // Dickens returned a result before checkpoint/suspension failed. Accepting + // that result did not commit its disposition or make the failed run live. + await db.insert(nativeRunResults).values({ id: resultId, companyId: f.companyId, issueId: f.issueId, + runId: f.sourceRunId, completionContractId: contractId, serverFingerprint: resultId, + schemaStatus: "accepted", canonicalSha256: resultId, + resultJson: { terminal: { runTerminalState: "succeeded", reportedWorkDisposition: "blocked" } } }); + await db.update(nativeRunFinalizations).set({ resultId, failureCode: "native_session_retry_exhausted" }) + .where(eq(nativeRunFinalizations.runId, f.sourceRunId)); + await db.update(issueRecoveryActions).set({ status: "active", cause: "native_session_retry_exhausted", + evidence: { runId: f.sourceRunId, coordinatorAttempt: 3, sourceFailureCode: "provider_transport_failed" }, + }).where(eq(issueRecoveryActions.sourceIssueId, f.issueId)); + return resultId; + } + it.each(["stopped", "controller", "successor", "still_committing", "live_process", "identity_missing"])( + "continues after terminal failure with a retained result only when execution stopped (%s)", async scenario => { + const f = await seed(), resultId = await seedResultBeforeTerminalFailure(f); + await db.update(nativeRunFinalizations).set({ + phase: scenario === "still_committing" ? "result_accepted" : "terminal_failure", + leaseOwner: scenario === "controller" ? "active-controller" : null, + failureDetail: scenario === "successor" ? { successorRunId: randomUUID() } : null, + }).where(eq(nativeRunFinalizations.runId, f.sourceRunId)); + if (scenario === "live_process" || scenario === "identity_missing") { + await db.update(heartbeatRuns).set({ processPid: scenario === "live_process" ? process.pid : null }) + .where(eq(heartbeatRuns.id, f.sourceRunId)); + } + const result = await admit(f); + expect(result).toEqual(scenario === "stopped" ? { previousRunId: f.sourceRunId, commentId: f.commentId } : null); + expect(await getExecutionBlocker(db, f.companyId, f.issueId)).toEqual(scenario === "stopped" ? null : expect.anything()); + const [coordinator] = await db.select().from(nativeRunFinalizations).where(eq(nativeRunFinalizations.runId, f.sourceRunId)); + expect(coordinator).toMatchObject({ resultId, attempt: 3, + phase: scenario === "still_committing" ? "result_accepted" : "terminal_failure" }); + const [source] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, f.sourceRunId)); + expect(source).toMatchObject({ status: "failed", errorCode: "provider_transport_failed" }); + const [savedResult] = await db.select().from(nativeRunResults).where(eq(nativeRunResults.id, resultId)); + expect(savedResult.resultJson).toEqual({ terminal: { runTerminalState: "succeeded", reportedWorkDisposition: "blocked" } }); + }); + it.each(["task", "chat", "chat_with_next_message"])("delivers a queued %s message exactly once after a retained-result failure and delayed remote stop", async kind => { + const f = await seed(), resultId = await seedResultBeforeTerminalFailure(f); + const settings = instanceSettingsService(db); + const experimental = await settings.getExperimental(); + await settings.updateExperimental({ enableAgentChat: true }); + try { + if (kind !== "task") await db.update(issues).set({ conversationAgentId: f.agentId, + conversationUserId: "board", conversationState: "active", + }).where(eq(issues.id, f.issueId)); + // Occupy the agent slot so admission is real without launching a provider. + await db.insert(heartbeatRuns).values({ companyId: f.companyId, agentId: f.agentId, status: "running" }); + const lease = { id: randomUUID(), companyId: f.companyId, heartbeatRunId: f.sourceRunId, + provider: "daytona", providerLeaseId: "dickens-stopped-sandbox" }; + await db.insert(environmentLeases).values({ ...lease, status: "active", leasePolicy: "ephemeral" }); + const heartbeat = heartbeatService(db); + const wake = await heartbeat.wakeup(f.agentId, { + source: "on_demand", triggerDetail: "manual", reason: "issue_commented", + requestedByActorType: "user", requestedByActorId: "board", + payload: { issueId: f.issueId, commentId: f.commentId }, + contextSnapshot: { source: "issue.comment", issueId: f.issueId, wakeCommentId: f.commentId }, + }); + expect(wake).toBeNull(); + const [waiting] = await db.select().from(agentWakeupRequests).where(eq(agentWakeupRequests.companyId, f.companyId)); + expect(waiting).toMatchObject({ status: "deferred_issue_execution", runId: null, + payload: { executionWait: { reason: "remote_cleanup" } } }); + let nextCommentId: string | null = null; + let nextWakeId: string | null = null; + if (kind === "chat_with_next_message") { + nextCommentId = randomUUID(); + nextWakeId = randomUUID(); + await db.insert(issueComments).values({ id: nextCommentId, companyId: f.companyId, issueId: f.issueId, + authorType: "user", authorUserId: "board", body: "A separate follow-up." }); + await db.insert(agentWakeupRequests).values({ id: nextWakeId, companyId: f.companyId, agentId: f.agentId, + source: "on_demand", triggerDetail: "manual", reason: "issue_commented", status: "deferred_issue_execution", + requestedByActorType: "user", requestedByActorId: "board", payload: { + issueId: f.issueId, commentId: nextCommentId, + _paperclipWakeContext: { source: "issue.comment", issueId: f.issueId, wakeCommentId: nextCommentId, wakeReason: "issue_commented" }, + }, + }); + } + const [source] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, f.sourceRunId)); + await heartbeat.resumeRemoteStopComments(source); + expect(await db.select().from(heartbeatRuns).where(and(eq(heartbeatRuns.companyId, f.companyId), eq(heartbeatRuns.status, "queued")))).toHaveLength(0); + await db.update(environmentLeases).set({ status: "released", releasedAt: new Date(), cleanupStatus: "success", + metadata: { remoteExecutionTermination: remoteTerminationReceipt(lease, { providerLeaseId: lease.providerLeaseId, state: "stopped" }) }, + }).where(eq(environmentLeases.id, lease.id)); + // Cleanup and periodic recovery can race to dispatch the same receipt. + await Promise.all([heartbeat.resumeRemoteStopComments(source), heartbeat.resumeRemoteStopComments(source)]); + const successors = await db.select().from(heartbeatRuns).where(and(eq(heartbeatRuns.companyId, f.companyId), eq(heartbeatRuns.status, "queued"))); + expect(successors).toHaveLength(1); + expect(successors[0].contextSnapshot).toMatchObject({ forceFreshSession: true, previousRunId: f.sourceRunId, wakeCommentId: f.commentId }); + if (nextWakeId) { + expect(successors[0].contextSnapshot?.wakeCommentIds).not.toContain(nextCommentId); + const [nextWake] = await db.select().from(agentWakeupRequests).where(eq(agentWakeupRequests.id, nextWakeId)); + expect(nextWake.status).toBe("deferred_issue_execution"); + } + // Complete the admitted turn and run the same transactional queue drain + // used by finalization. A stale receipt must not create another turn. + await db.update(heartbeatRuns).set({ status: "succeeded", startedAt: new Date(), finishedAt: new Date() }) + .where(eq(heartbeatRuns.id, successors[0].id)); + await db.update(issues).set({ executionRunId: successors[0].id, status: "in_progress" }) + .where(eq(issues.id, f.issueId)); + const release = createReleaseIssueExecution({ + issueLock: createPostgresWakeQueueAdapter(db, { + resolveResponsibleUserId: async () => "board", + getRoutineEnv: async () => ({ routineId: null, env: null, responsibleUserId: null }), + resolveSessionBeforeForWakeup: async () => null, + }), + recovery: { escalateStrandedAssignedIssue: async () => {}, escalateStrandedRecoveryIssueInPlace: async () => {} }, + }); + const drained = await release({ companyId: f.companyId, runId: successors[0].id, now: new Date(), suppressImmediateRecovery: true }); + expect(drained.outcome.kind).toBe(nextWakeId ? "promoted" : "released"); + await heartbeat.resumeRemoteStopComments(source); + await release({ companyId: f.companyId, runId: successors[0].id, now: new Date(), suppressImmediateRecovery: true }); + const afterDrain = await db.select().from(heartbeatRuns).where(and(eq(heartbeatRuns.companyId, f.companyId), eq(heartbeatRuns.status, "queued"))); + expect(afterDrain).toHaveLength(nextWakeId ? 1 : 0); + if (nextWakeId) { + expect(afterDrain[0]).toMatchObject({ wakeupRequestId: nextWakeId, contextSnapshot: { wakeCommentId: nextCommentId } }); + expect(afterDrain[0].contextSnapshot?.wakeCommentIds ?? []).not.toContain(f.commentId); + } + const [delivered] = await db.select().from(agentWakeupRequests).where(eq(agentWakeupRequests.id, waiting.id)); + expect(delivered).toMatchObject({ status: "coalesced", runId: successors[0].id }); + const [coordinator] = await db.select().from(nativeRunFinalizations).where(eq(nativeRunFinalizations.runId, f.sourceRunId)); + expect(coordinator).toMatchObject({ phase: "terminal_failure", resultId, attempt: 3 }); + expect(await getExecutionBlocker(db, f.companyId, f.issueId)).toBeNull(); + } finally { + await settings.updateExperimental({ enableAgentChat: experimental.enableAgentChat }); + } + }); + it("keeps exhausted workspace export on its own repair path after the sandbox stops", async () => { + const f = await seed(), resultId = await seedResultBeforeTerminalFailure(f); + await db.delete(issueRecoveryActions).where(eq(issueRecoveryActions.sourceIssueId, f.issueId)); + await db.update(nativeRunFinalizations).set({ phase: "result_accepted", attempt: 1, failureCode: null, failureDetail: null }) + .where(eq(nativeRunFinalizations.runId, f.sourceRunId)); + await db.update(heartbeatRuns).set({ status: "running", nativePhase: "result_accepted", finishedAt: null }) + .where(eq(heartbeatRuns.id, f.sourceRunId)); + const lease = { id: randomUUID(), companyId: f.companyId, heartbeatRunId: f.sourceRunId, + provider: "daytona", providerLeaseId: "pending-workspace-export" }; + await db.insert(environmentLeases).values({ ...lease, status: "active", leasePolicy: "ephemeral" }); + const [run] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, f.sourceRunId)); + await db.update(heartbeatRuns).set({ runnerProfileJson: { ...run.runnerProfileJson, + nativeWorkspaceSync: { schema: "paperclip.native-workspace-sync/v1", state: "prepared", + descriptorSha256: "a".repeat(64), baselineSha256: "b".repeat(64), finalHostSha256: null, + workspaceId: randomUUID(), leaseId: lease.id, providerLeaseId: lease.providerLeaseId, + remoteCwd: "/work", resourceDisposition: "destroy" }, + } }).where(eq(heartbeatRuns.id, f.sourceRunId)); + for (let attempt = 0; attempt < 3; attempt++) { + await recordNativeFinalizationFailure({ db, runId: f.sourceRunId, + error: new Error("native_workspace_sync_out_failed"), failureScope: "workspace", projectRunStatus: true }); + } + const [retainedLease] = await db.select().from(environmentLeases).where(eq(environmentLeases.id, lease.id)); + expect(retainedLease).toMatchObject({ status: "pending_cleanup", metadata: { + nativeWorkspaceExportResume: { runId: f.sourceRunId, leaseId: lease.id, resultId }, + } }); + await db.update(environmentLeases).set({ status: "released", releasedAt: new Date(), cleanupStatus: "success", + metadata: { ...retainedLease.metadata, + remoteExecutionTermination: remoteTerminationReceipt(lease, { providerLeaseId: lease.providerLeaseId, state: "stopped" }) }, + }).where(eq(environmentLeases.id, lease.id)); + const exportState = () => Promise.all([ + db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, f.sourceRunId)), + db.select().from(nativeRunResults).where(eq(nativeRunResults.id, resultId)), + db.select().from(environmentLeases).where(eq(environmentLeases.id, lease.id)), + db.select().from(issueRecoveryActions).where(eq(issueRecoveryActions.sourceIssueId, f.issueId)), + ]); + const before = await exportState(); + // Export repair is intentionally excluded before conversation stop checks. + // A new message must leave the accepted result, retention, and repair intact. + await db.update(issueComments).set({ createdAt: new Date(Date.now() + 1000) }).where(eq(issueComments.id, f.commentId)); + expect(await admit(f)).toBeNull(); + expect(await exportState()).toEqual(before); + const [coordinator] = await db.select().from(nativeRunFinalizations).where(eq(nativeRunFinalizations.runId, f.sourceRunId)); + expect(coordinator).toMatchObject({ phase: "terminal_failure", resultId, attempt: 1, + failureCode: "native_workspace_sync_out_retry_exhausted", failureDetail: { workspaceFinalizeAttempt: 3 } }); + const [action] = await db.select().from(issueRecoveryActions).where(and( + eq(issueRecoveryActions.sourceIssueId, f.issueId), eq(issueRecoveryActions.cause, "native_workspace_sync_out_retry_exhausted"), + )); + expect(action).toMatchObject({ status: "active", cause: "native_workspace_sync_out_retry_exhausted" }); + }); it.each(["linked", "revoked", "unlinked", "wrong_author"])( "admits a fresh Slack message after failure only with current linked-user authority (%s)", async scenario => { const f = await seed(); diff --git a/server/src/services/explicit-native-continuation.ts b/server/src/services/explicit-native-continuation.ts index 5c6c0349d1..2eafaf4d3e 100644 --- a/server/src/services/explicit-native-continuation.ts +++ b/server/src/services/explicit-native-continuation.ts @@ -212,9 +212,13 @@ export async function admitExplicitNativeContinuation(input: { sql`${nativeRunResults.resultJson}->'terminal'->>'runTerminalState' = 'failed'`, )).limit(1) : []; + // A terminal failure may retain a result accepted before checkpoint or + // cleanup failed. That immutable result is history, not an active commit. + // Keep it intact and require the same controller/process/lease stop proofs + // before admitting new user input; never apply or replay the old result. if (!cancelledStartup && coordinator && ( (coordinator.phase !== "terminal_failure" && !committedFailure) || coordinator.leaseOwner || - (coordinator.resultId && !committedFailure) || coordinator.failureDetail?.successorRunId)) return blocked("controller_settling", + coordinator.failureDetail?.successorRunId)) return blocked("controller_settling", run.status === "cancelled" && !coordinator.leaseOwner ? "The cancelled run still needs verified cleanup. Your message is saved. Inspect the run and its environment for details." : "Waiting for the previous run to finish recovery. Your message will start automatically."); diff --git a/server/src/services/heartbeat.ts b/server/src/services/heartbeat.ts index 7f1f7ab940..b3360ad773 100644 --- a/server/src/services/heartbeat.ts +++ b/server/src/services/heartbeat.ts @@ -28524,8 +28524,12 @@ export function heartbeatService( or(isNull(heartbeatRuns.nativeIssueId), eq(heartbeatRuns.nativeIssueId, issue.id)), )).then(rows => rows[0] ?? null) : null; + const canCoalesceComments = !isConversation(issue) && opts.allowRunCoalescing !== false; + // A resumed receipt already passed admission under this issue lock. + // Consume it with its successor even when chat keeps other messages + // in separate turns, or finalization will deliver it a second time. const pendingComments = - !isConversation(issue) && opts.allowRunCoalescing !== false && + (executionWaitRequestId || canCoalesceComments) && !(await getExecutionBlocker(tx as unknown as Db, issue.companyId, issue.id)) ? await tx .select() @@ -28536,11 +28540,13 @@ export function heartbeatService( inArray(agentWakeupRequests.agentId, handoffSource ? [agentId, handoffSource.agentId] : [agentId]), eq(agentWakeupRequests.status, "deferred_issue_execution"), sql`${agentWakeupRequests.payload}->>'issueId' = ${issue.id}`, + canCoalesceComments ? undefined : eq(agentWakeupRequests.id, executionWaitRequestId!), ), ) .orderBy(asc(agentWakeupRequests.requestedAt)) : []; const adoptedComments = pendingComments.filter((wake) => { + if (wake.id === executionWaitRequestId) return true; if (wake.id === opts.queuedCommentInterruptId || wake.id === opts.queuedCommentRequestId) return true; const deferredPayload = parseObject(wake.payload); const deferredContext = parseObject( diff --git a/tests/e2e/agent-chat-sessions.spec.ts b/tests/e2e/agent-chat-sessions.spec.ts index f087e1ad77..185d78548a 100644 --- a/tests/e2e/agent-chat-sessions.spec.ts +++ b/tests/e2e/agent-chat-sessions.spec.ts @@ -322,9 +322,9 @@ test("secondary chat navigation preserves layout, unique conversations, history, await expect(nav.getByRole("link")).toHaveCount(2); await page.getByRole("link", { name: "Chat", exact: true }).click(); - await expect(page.getByRole("heading", { name: "Who would you like to talk to?" })).toBeVisible(); + await expect(page.getByRole("link", { name: "Configure Alpha", exact: true })).toBeVisible(); + await expect(editor).toContainText("Unsent draft for Alpha"); expect(await sidebar.boundingBox()).toEqual(landingBounds); - await alphaLink.click(); await page.getByRole("link", { name: "Configure Alpha", exact: true }).click(); await expect(page).toHaveURL(/\/agents\/.*\/runtime/); await json(await request.post(`/api/issues/${beta.id}/comments`, { @@ -335,6 +335,7 @@ test("secondary chat navigation preserves layout, unique conversations, history, })); await idle(request, betaPath); await page.getByRole("link", { name: "Chat", exact: true }).click(); + await expect(page.getByRole("link", { name: "Configure Alpha", exact: true })).toBeVisible(); await compose.click(); await picker.getByRole("combobox").fill("Beta"); await picker.getByRole("combobox").press("Enter"); diff --git a/ui/package.json b/ui/package.json index 1b380b7432..15533b4a89 100644 --- a/ui/package.json +++ b/ui/package.json @@ -59,7 +59,7 @@ "cmdk": "^1.1.1", "i18next": "^26.4.0", "lexical": "0.48.0", - "lucide-react": "^1.38.0", + "lucide-react": "^1.45.0", "mermaid": "^11.17.2", "motion": "^13.1.1", "radix-ui": "^1.6.7", diff --git a/ui/src/api/heartbeats.test.ts b/ui/src/api/heartbeats.test.ts index 8d65eeb9ab..4de7d5a69f 100644 --- a/ui/src/api/heartbeats.test.ts +++ b/ui/src/api/heartbeats.test.ts @@ -55,6 +55,12 @@ describe("heartbeatsApi.liveRunsForCompany", () => { expect(mockApi.get).toHaveBeenCalledWith("/companies/company-1/live-runs?minCount=50&limit=50"); }); + + it("requests distinct task cards before the server applies the dashboard limit", async () => { + await heartbeatsApi.liveRunsForCompany("company-1", { minCount: 4, limit: 4, distinctTasks: true }); + + expect(mockApi.get).toHaveBeenCalledWith("/companies/company-1/live-runs?minCount=4&limit=4&distinctTasks=true"); + }); }); describe("heartbeatsApi.downloadProviderTrace", () => { diff --git a/ui/src/api/heartbeats.ts b/ui/src/api/heartbeats.ts index cf913ef005..84c7990f66 100644 --- a/ui/src/api/heartbeats.ts +++ b/ui/src/api/heartbeats.ts @@ -232,7 +232,7 @@ export const heartbeatsApi = { api.get(`/issues/${issueId}/active-run`), liveRunsForCompany: ( companyId: string, - options?: number | { minCount?: number; limit?: number }, + options?: number | { minCount?: number; limit?: number; distinctTasks?: boolean }, ) => { const searchParams = new URLSearchParams(); if (typeof options === "number") { @@ -241,6 +241,7 @@ export const heartbeatsApi = { if (options.minCount) searchParams.set("minCount", String(options.minCount)); if (options.limit) searchParams.set("limit", String(options.limit)); + if (options.distinctTasks) searchParams.set("distinctTasks", "true"); } const qs = searchParams.toString(); return api.get( diff --git a/ui/src/components/ActiveAgentsPanel.test.tsx b/ui/src/components/ActiveAgentsPanel.test.tsx index 80a7b2813f..532785a228 100644 --- a/ui/src/components/ActiveAgentsPanel.test.tsx +++ b/ui/src/components/ActiveAgentsPanel.test.tsx @@ -146,6 +146,7 @@ describe("ActiveAgentsPanel", () => { expect(mockHeartbeatsApi.liveRunsForCompany).toHaveBeenCalledWith("company-1", { minCount: 4, limit: undefined, + distinctTasks: false, }); const moreLink = [...container.querySelectorAll("a")].find((anchor) => @@ -159,7 +160,54 @@ describe("ActiveAgentsPanel", () => { }); }); + it("shows each linked task once on the dashboard while keeping distinct taskless runs", async () => { + const repeatedIssueId = "issue-repeated"; + mockHeartbeatsApi.liveRunsForCompany.mockResolvedValue([ + createIssueRun(1, repeatedIssueId), + { ...createIssueRun(2, repeatedIssueId), status: "succeeded", finishedAt: "2026-04-24T11:00:00.000Z" }, + createIssueRun(3, "issue-other"), + createRun(4), + createRun(5), + createRun(6), + ]); + + const root = createRoot(container); + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + + await act(async () => { + root.render( + + + , + ); + }); + await flushReact(); + + expect(mockHeartbeatsApi.liveRunsForCompany).toHaveBeenCalledWith("company-1", { + minCount: 4, + limit: undefined, + distinctTasks: true, + }); + expect([...container.querySelectorAll(".dashboard-agent-card")].map((card) => + card.querySelector('a[aria-label$=". View run"]')?.getAttribute("href"), + )).toEqual([ + "/agents/agent-1/runs/run-1", + "/agents/agent-3/runs/run-3", + "/agents/agent-4/runs/run-4", + "/agents/agent-5/runs/run-5", + ]); + expect(container.textContent).toContain("1 more active/recent run"); + + await act(async () => root.unmount()); + }); + it("can request the full live dashboard page limit without a hidden-runs link", async () => { + mockHeartbeatsApi.liveRunsForCompany.mockResolvedValue([ + createIssueRun(1, "issue-repeated"), + { ...createIssueRun(2, "issue-repeated"), status: "succeeded" }, + ]); const root = createRoot(container); const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } }, @@ -184,9 +232,11 @@ describe("ActiveAgentsPanel", () => { expect(mockHeartbeatsApi.liveRunsForCompany).toHaveBeenCalledWith("company-1", { minCount: 50, limit: 50, + distinctTasks: false, }); expect(container.textContent).not.toContain("more active/recent"); expect(container.textContent).not.toContain("Run output"); + expect(container.querySelectorAll(".dashboard-agent-card")).toHaveLength(2); await act(async () => { root.unmount(); diff --git a/ui/src/components/ActiveAgentsPanel.tsx b/ui/src/components/ActiveAgentsPanel.tsx index 34e12a65ce..b3aaf50a2d 100644 --- a/ui/src/components/ActiveAgentsPanel.tsx +++ b/ui/src/components/ActiveAgentsPanel.tsx @@ -44,6 +44,7 @@ interface ActiveAgentsPanelProps { queryScope?: string; showMoreLink?: boolean; showTranscripts?: boolean; + dedupeLinkedTasks?: boolean; } export function ActiveAgentsPanel({ @@ -58,25 +59,44 @@ export function ActiveAgentsPanel({ queryScope = "dashboard", showMoreLink = true, showTranscripts = false, + dedupeLinkedTasks = false, }: ActiveAgentsPanelProps) { - const liveRunsQueryKey = [...queryKeys.liveRuns(companyId), queryScope, { minRunCount, fetchLimit }] as const; + const effectiveFetchLimit = fetchLimit; + const liveRunsQueryKey = [...queryKeys.liveRuns(companyId), queryScope, { minRunCount, fetchLimit: effectiveFetchLimit, dedupeLinkedTasks }] as const; const sharedLiveRuns = useSharedPollingQuery({ companyId, - resourceKey: `live-runs:${queryScope}:${minRunCount}:${fetchLimit ?? "default"}`, + resourceKey: `live-runs:${queryScope}:${minRunCount}:${effectiveFetchLimit ?? "default"}:${dedupeLinkedTasks}`, queryKey: liveRunsQueryKey, enabled: !!companyId, leaderOnly: true, }); const { data: liveRuns, dataUpdatedAt: liveRunsUpdatedAt } = useQuery({ queryKey: liveRunsQueryKey, - queryFn: () => heartbeatsApi.liveRunsForCompany(companyId, { minCount: minRunCount, limit: fetchLimit }), + queryFn: () => heartbeatsApi.liveRunsForCompany(companyId, { + minCount: minRunCount, + limit: effectiveFetchLimit, + distinctTasks: dedupeLinkedTasks, + }), enabled: sharedLiveRuns.enabled, }); usePublishSharedQueryData(sharedLiveRuns, liveRuns, liveRunsUpdatedAt); const runs = liveRuns ?? []; - const visibleRuns = useMemo(() => runs.slice(0, cardLimit), [cardLimit, runs]); - const hiddenRunCount = Math.max(0, runs.length - visibleRuns.length); + const cardRuns = useMemo(() => { + if (!dedupeLinkedTasks) return runs; + + // The endpoint orders active runs first, then recent completed runs. Keep + // the first run for each task so an active attempt wins over its history. + const seenIssueIds = new Set(); + return runs.filter((run) => { + if (!run.issueId) return true; + if (seenIssueIds.has(run.issueId)) return false; + seenIssueIds.add(run.issueId); + return true; + }); + }, [dedupeLinkedTasks, runs]); + const visibleRuns = useMemo(() => cardRuns.slice(0, cardLimit), [cardLimit, cardRuns]); + const hiddenRunCount = Math.max(0, cardRuns.length - visibleRuns.length); const visibleIssueIds = useMemo( () => [...new Set(visibleRuns.map((run) => run.issueId).filter((issueId): issueId is string => Boolean(issueId)))], [visibleRuns], diff --git a/ui/src/components/AgentSetupPrompt.tsx b/ui/src/components/AgentSetupPrompt.tsx new file mode 100644 index 0000000000..df025d7eb3 --- /dev/null +++ b/ui/src/components/AgentSetupPrompt.tsx @@ -0,0 +1,281 @@ +import { useEffect, useId, useLayoutEffect, useRef, useState, type Ref } from "react"; +import { createPortal } from "react-dom"; +import { Check, Copy, Terminal, X } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; +import { Textarea } from "@/components/ui/textarea"; +import { usePrefersReducedMotion } from "@/hooks/usePrefersReducedMotion"; +import { copyTextToClipboard } from "@/lib/clipboard"; +import { motionMilliseconds, motionNumber } from "@/lib/onboarding-motion-tokens"; +import { cn } from "@/lib/utils"; + +export interface AgentSetupPromptProps { + /** The complete text to preview and copy; never truncated on the clipboard. */ + prompt: string; + title?: string; + description?: string; + label?: string; + variant?: "outline" | "ghost"; + side?: "top" | "bottom" | "left" | "right"; + align?: "start" | "center" | "end"; + className?: string; + /** Carry over the clipboard result when generating a prompt already copied it. */ + initialCopyStatus?: "idle" | "copied" | "failed"; + onCopied?: () => void; +} + +const agents = [ + { name: "Codex", src: "/brands/codex-color.svg", className: "" }, + { name: "ChatGPT", src: "/brands/apps/openai.svg", className: "dark:invert" }, + { name: "Claude", src: "/brands/claude-color.svg", className: "" }, +]; + +function AgentLogos({ ref, hidden }: { ref: Ref; hidden: boolean }) { + return ( + + ); +} + +function readLogoPositions(group: HTMLSpanElement | null) { + return Array.from(group?.querySelectorAll(".agent-setup-logo") ?? []).map((logo) => { + const bounds = logo.getBoundingClientRect(); + const transform = getComputedStyle(logo).transform; + const matrix = transform && transform !== "none" ? new DOMMatrixReadOnly(transform) : null; + return { + // A rotated bounding box is larger than the logo. Anchor its unscaled + // box at the same center, preserving the current hover angle separately. + left: bounds.left + (bounds.width - logo.offsetWidth) / 2, + top: bounds.top + (bounds.height - logo.offsetHeight) / 2, + width: logo.offsetWidth, + height: logo.offsetHeight, + transform: `rotate(${matrix ? Math.atan2(matrix.b, matrix.a) * 180 / Math.PI : 0}deg)`, + }; + }); +} + +/** A small handoff to the user's agent, with an inspectable prompt and local copy feedback. */ +export function AgentSetupPrompt({ + prompt, + title = "Agent setup", + description = "Paste this into your agent to take care of setup.", + label = "Set up with an agent", + variant = "outline", + side = "top", + align = "start", + className, + initialCopyStatus = "idle", + onCopied, +}: AgentSetupPromptProps) { + const id = useId(); + const reducedMotion = usePrefersReducedMotion(); + const [open, setOpen] = useState(false); + const [status, setStatus] = useState<"idle" | "copying" | "copied" | "failed">(initialCopyStatus); + const [flight, setFlight] = useState<{ origins: ReturnType; toPreview: boolean } | null>(null); + const triggerLogos = useRef(null); + const previewLogos = useRef(null); + const flyingLogos = useRef(null); + const activationOrigins = useRef | null>(null); + const copyAttempt = useRef(0); + const hasUserCopyAttempt = useRef(false); + const copying = useRef(false); + const copyButton = useRef(null); + + useEffect(() => { + setStatus("idle"); + copying.current = false; + hasUserCopyAttempt.current = false; + // A pending clipboard result must not label a changed prompt as copied. + return () => { copyAttempt.current += 1; }; + }, [prompt]); + + useEffect(() => { + // A delayed generation-time copy must not replace the user's newer result. + if (!hasUserCopyAttempt.current) setStatus(initialCopyStatus); + }, [prompt, initialCopyStatus]); + + useEffect(() => { + if (status !== "copied") return; + const hold = motionMilliseconds("--agent-setup-copied-hold"); + if (hold <= 0) return; + const timeout = window.setTimeout(() => setStatus("idle"), hold); + return () => window.clearTimeout(timeout); + }, [status]); + + useLayoutEffect(() => { + if (!flight) return; + if (reducedMotion || typeof Element.prototype.animate !== "function") { + setFlight(null); + return; + } + let frame: number; + let cancelled = false; + let animations: Animation[] = []; + const finish = () => { if (!cancelled) setFlight(null); }; + const start = () => { + const destination = flight.toPreview ? previewLogos.current : triggerLogos.current; + // Radix publishes this measurement only after positioning its portal. + // Until then the full-size flight logos remain at the captured origin. + if (!destination || (flight.toPreview && !getComputedStyle(destination).getPropertyValue("--radix-popover-content-transform-origin").trim())) { + frame = requestAnimationFrame(start); + return; + } + const targets = readLogoPositions(destination); + const logos = flyingLogos.current?.querySelectorAll(".agent-setup-logo"); + const easing = getComputedStyle(document.documentElement).getPropertyValue("--motion-ease-out-expo").trim(); + animations = Array.from(logos ?? []).map((logo, index) => logo.animate( + [flight.origins[index], targets[index]].map(({ left, top, transform }) => ({ left: `${left}px`, top: `${top}px`, transform })), + { duration: motionMilliseconds("--agent-setup-flight-duration"), easing, fill: "both" }, + )); + void Promise.all(animations.map((animation) => animation.finished)).then(finish, () => {}); + }; + frame = requestAnimationFrame(start); + // If the viewport moves mid-flight, settle at its live destination. + window.addEventListener("resize", finish); + document.addEventListener("scroll", finish, true); + return () => { + cancelled = true; + cancelAnimationFrame(frame); + animations.forEach((animation) => animation.cancel()); + window.removeEventListener("resize", finish); + document.removeEventListener("scroll", finish, true); + }; + }, [flight, reducedMotion]); + + function changeOpen(nextOpen: boolean) { + const origins = activationOrigins.current ?? readLogoPositions(flyingLogos.current ?? (open ? previewLogos.current : triggerLogos.current)); + activationOrigins.current = null; + setFlight(!reducedMotion && origins.length === agents.length ? { origins, toPreview: nextOpen } : null); + setOpen(nextOpen); + } + + async function copyPrompt() { + if (copying.current || !prompt.trim()) return; + hasUserCopyAttempt.current = true; + copying.current = true; + const attempt = ++copyAttempt.current; + setStatus("copying"); + try { + await copyTextToClipboard(prompt); + if (attempt === copyAttempt.current) { + setStatus("copied"); + onCopied?.(); + } + } catch { + if (attempt === copyAttempt.current) setStatus("failed"); + } finally { + if (attempt === copyAttempt.current) copying.current = false; + } + } + + return ( + <> + + + + + { + event.preventDefault(); + copyButton.current?.focus(); + }} + onFocusOutside={(event) => { + // The legacy clipboard fallback briefly focuses a textarea in + // document.body. Keep the preview open so its result stays visible. + if (copying.current) event.preventDefault(); + }} + > +
+
+

{title}

+

{description}

+
+ +
+ +
+
+ {status === "failed" ? ( +