Merge commit '213c472ab43ed750964157cce9725bff4853f3de' into HEAD

* commit '213c472ab43ed750964157cce9725bff4853f3de':
  refactor(heartbeat): extract queue admission and wakeup dispatch (#15626)
  build(deps): bump rustls from 0.23.43 to 0.23.45 in /packages/paperclip-runner/runner (#15295)
  fix: retain bounded workspace transfer failure evidence (#15637)
  fix: checkpoint database backups before idle sleep (#15629)
  fix(ci): cache native server integration builds in release verification (#15619)
This commit is contained in:
Dotta committed 2026-10-09 08:10:12 -05:00
commit af0b2b786a
45 files changed
+6154 -4543

No files matched your search

@@ -38,14 +38,19 @@ test("parallel lanes cover check:all exactly once and never bypass verification"
const scripts = JSON.parse(readFileSync(new URL("../../../packages/paperclip-runner/package.json", import.meta.url))).scripts;
const checks = [...runner.matchAll(/^ checks: (.+)$/gm)].flatMap(([, value]) => value.split(" "));
assert.deepEqual(checks, scripts["check:all"].split(" && ").map((command) => command.replace(/^pnpm run /, "")));
assert.deepEqual([...runner.matchAll(/^ - lane: (.+)$/gm)].map(([, value]) => value), ["protocol", "rust"]);
assert.deepEqual([...runner.matchAll(/^ - lane: (.+)$/gm)].map(([, value]) => value), ["protocol", "rust", "server-integration"]);
assert.match(runner, /fail-fast: false/);
assert.doesNotMatch(runner, /max-parallel: 1|^ needs:|continue-on-error:/m);
const verify = runner.split(" - name: Verify Paperclip Runner\n")[1].split(" - name: Warm debug")[0];
assert.match(verify, /RUNNER_CHECKS: \$\{\{ matrix.checks \}\}/);
assert.match(verify, /set -euo pipefail/);
assert.match(verify, /for check in \$RUNNER_CHECKS; do\s+pnpm --filter @paperclipai\/paperclip-runner "\$check"\s+done/);
assert.doesNotMatch(verify, /if:|cache-hit/);
const verifyIf = verify.match(/^\s*if: \$\{\{ (.+) \}\}$/m)?.[1];
assert.equal(verifyIf, "matrix.lane != 'server-integration'");
for (const lane of ["protocol", "rust", "server-integration"]) {
assert.equal(runInNewContext(verifyIf, { matrix: { lane } }), lane !== "server-integration");
}
assert.doesNotMatch(verify, /cache-hit/);
assert.doesNotMatch(runner, /id-token: write|packages: write|secrets: inherit/);
});
@@ -57,7 +62,7 @@ test("only the trusted Rust lane writes, and warms both build profiles before sa
assert.match(warm, /run: pnpm --filter @paperclipai\/paperclip-runner build:rust/);
const sha = "a".repeat(40);
const base = { repository: "paperclipai/paperclip", event_name: "push", ref: "refs/heads/master", sha };
for (const lane of ["protocol", "rust"]) {
for (const lane of ["protocol", "rust", "server-integration"]) {
for (const [overrides, ref, trusted] of [
[{}, sha, true],
[{ event_name: "pull_request", ref: "refs/pull/1/merge" }, sha, false],
+29 -13
View File
@@ -107,45 +107,46 @@ jobs:
matrix:
include:
# Split the long chat file by collected test locations, and balance
# the remaining server files across ten runners. Normal PR/local
# invocations retain their complete general-server group.
- group: general-server-without-chat
# the remaining server files across ten runners. Rust-backed server
# suites run in the cached Runner lane below. Local invocations retain
# their complete general-server group.
- group: general-server-without-chat-or-native-runner
group_label: server (1/10)
shard_index: 0
shard_count: 10
- group: general-server-without-chat
- group: general-server-without-chat-or-native-runner
group_label: server (2/10)
shard_index: 1
shard_count: 10
- group: general-server-without-chat
- group: general-server-without-chat-or-native-runner
group_label: server (3/10)
shard_index: 2
shard_count: 10
- group: general-server-without-chat
- group: general-server-without-chat-or-native-runner
group_label: server (4/10)
shard_index: 3
shard_count: 10
- group: general-server-without-chat
- group: general-server-without-chat-or-native-runner
group_label: server (5/10)
shard_index: 4
shard_count: 10
- group: general-server-without-chat
- group: general-server-without-chat-or-native-runner
group_label: server (6/10)
shard_index: 5
shard_count: 10
- group: general-server-without-chat
- group: general-server-without-chat-or-native-runner
group_label: server (7/10)
shard_index: 6
shard_count: 10
- group: general-server-without-chat
- group: general-server-without-chat-or-native-runner
group_label: server (8/10)
shard_index: 7
shard_count: 10
- group: general-server-without-chat
- group: general-server-without-chat-or-native-runner
group_label: server (9/10)
shard_index: 8
shard_count: 10
- group: general-server-without-chat
- group: general-server-without-chat-or-native-runner
group_label: server (10/10)
shard_index: 9
shard_count: 10
@@ -297,6 +298,7 @@ jobs:
checks: check:eval-kernel check:protocol
- lane: rust
checks: check:runner check:api-authority
- lane: server-integration
steps:
- name: Checkout repository
@@ -373,14 +375,28 @@ jobs:
# dependencies; never restore installed executables from cargo/bin.
cache-workspace-crates: false
cache-bin: false
# Both lanes restore the existing dependency cache. Only the Rust
# All lanes restore the existing dependency cache. Only the Rust
# lane saves it, after warming both release and debug dependencies.
save-if: ${{ matrix.lane == 'rust' && github.repository == 'paperclipai/paperclip' && github.event_name == 'push' && github.ref == 'refs/heads/master' && inputs.ref == github.sha }}
- name: Install dependencies
run: pnpm install --no-frozen-lockfile
- name: Build native server test binaries
if: ${{ matrix.lane == 'server-integration' }}
timeout-minutes: 10
working-directory: packages/paperclip-runner
run: |
set -euo pipefail
pnpm build:rust
cargo build --release --manifest-path runner/Cargo.toml --locked -p paperclip-runner-core --bin paperclip-runnerd --bin fake-codex-app-server
- name: Run native server integration suites
if: ${{ matrix.lane == 'server-integration' }}
run: pnpm test:run:general -- --group general-server-native-runner
- name: Verify Paperclip Runner
if: ${{ matrix.lane != 'server-integration' }}
env:
RUNNER_CHECKS: ${{ matrix.checks }}
run: |
+14 -2
View File
@@ -1240,8 +1240,8 @@ Run preparation is in `server/src/services/heartbeat/run-preparation.ts`. It own
issue and wake context, responsible-user resolution, routine environment snapshots,
skill mentions, adapter environment configuration, and MCP/tool access setup.
`createHeartbeatRunPreparation(db)` binds the context loaders to a service's database
without doing database work during construction. `heartbeat.ts` keeps queueing,
dispatch, cancellation, and execution order, and re-exports the existing
without doing database work during construction. `heartbeat.ts` composes queue
dispatch with cancellation and execution, and re-exports the existing
public helpers and configuration-incomplete error class. Keep preparation policy
changes in this module and its tests.
@@ -1272,6 +1272,15 @@ and shared execution sets so separate service instances keep the same ownership
and shutdown barriers. Cleanup single-flight state stays at module scope.
Keep recovery policy changes separate from retry scheduling and execution changes.
Queue admission and wakeup dispatch are in `server/src/services/heartbeat/queue.ts`.
It owns wake coalescing and batching, queued-run claims, daily heartbeat caps,
concurrency and priority checks, timer admission, and native status wake intents.
`createHeartbeatQueue` binds these operations without querying or starting work.
The service supplies lifecycle effects, execution callbacks, worktree gates, and
its process-wide execution and wakeup promise sets. Forwarding callbacks preserve
construction order for retry and recovery services. Keep queue policy changes
separate from this extraction and from adapter execution changes.
## Wake Context Delivery
Built-in adapters deliver wake context through the run prompt, including structured
@@ -1687,6 +1696,9 @@ Environment overrides:
- `PAPERCLIP_DB_BACKUP_ENABLED=true|false`
- `PAPERCLIP_DB_BACKUP_INTERVAL_MINUTES=<minutes>`
- `PAPERCLIP_DB_BACKUP_IDLE_CHECKPOINT_ENABLED=1` enables verified final backups
for owned idle sleep, with restart catch-up. Off by default; see
[idle sleep safety](idle-sleep-safety.md) for the hosting and storage contract.
- `PAPERCLIP_DB_BACKUP_RETENTION_DAYS=<days>`
- `PAPERCLIP_DB_BACKUP_DIR=/absolute/or/~/path`
- `PAPERCLIP_DB_BACKUP_MAX_AGE_HOURS=<hours>` controls the `/api/health`
+8 -3
View File
@@ -33,9 +33,14 @@ test jobs on standard runners. Measure queue time to assess the timing gain.
Release verification spreads the general server suites across ten standard hosted
runners, with the long chat suite split separately across three jobs. Each server
job still runs one test worker. The partition covers every suite exactly once;
normal PR and local test groups keep their existing shape. More jobs increase
concurrent runner demand, so compare queue time as well as test duration.
job still runs one test worker. The three Rust-backed server suites run in a
separate Runner lane with the shared dependency cache. That lane builds both
debug and release test binaries in a visible, bounded step before Vitest starts;
the suites still check Cargo freshness and run against the current source. A
cache miss builds from source. Native test failures block source verification.
The partition covers every suite exactly once; normal PR and local test groups
keep their existing shape. More jobs increase concurrent runner demand, so compare
queue time as well as test duration.
All release verification installs, including the Runner scorer and chaos evals,
allow pnpm to refresh an outdated lockfile. Contributor PRs leave lockfile updates
+41 -2
View File
@@ -52,11 +52,50 @@ counted after readiness until its writes settle.
Scheduler work already in flight remains counted until its promise settles.
Idle holds pause new scheduler admissions. Database backup promises remain
counted through success or failure. Configured periodic backups also block
sleep until a host owns a durable wake schedule for them. A generation counter invalidates a
counted through success or failure. Configured periodic backups block sleep by
default. The opt-in checkpoint mode below preserves a final backup instead of
running the timer while asleep. A generation counter invalidates a
scan if tracked work both starts and finishes during inspection. Reports remain
unknown until startup recovery and HTTP tracking are installed.
### Backup checkpoints for idle instances
`PAPERCLIP_DB_BACKUP_IDLE_CHECKPOINT_ENABLED=1` opts an instance with automatic
backups enabled into this mode. Keep its database and persistent backup volume
attached across sleep. All writers must use the instance's admission protocol;
this mode cannot cover independent writers to the same database.
On managed instances, checkpoint-enabled safety reads require a verified signed
Cloud control actor. Tenant instance-admin elevation does not grant platform
backup authority. Self-hosted instance administrators retain operator access.
Each owned safety read first checks local work, plugin drains and durable work.
Known local blockers return before opening a database transaction. If every
other check passes, the server takes a fresh backup with the existing backup
engine and the retention policy from Instance Settings. It reads the full gzip
archive, checks its trailer and nonempty content, and syncs the archive and
directory before returning `none`. The final pre-stop revalidation takes another
fresh backup. Reads can therefore take longer; the host must bound its request
and still leave enough lease time for a confirmed provider stop.
There is no cached backup exemption. Failed, concurrent or untracked backups,
other work during the scan, changed owners and expired holds cannot authorize
sleep. Client timeouts do not clear the backup's in-flight token. Slow backups
may retain an awake instance until a later sleep attempt.
A durable `.idle-backup-wake-required` marker is written before the dump.
No recurring duplicate archives are produced while the instance is asleep.
The last pre-sleep archive and existing history stay on persistent storage;
no retention pruning runs during sleep. On restart, the server attempts a fresh
backup before enabling idle eligibility, then resumes the normal interval.
Only a verified new backup clears the marker. Failure keeps it for the next
scheduled retry. Disabling automatic backups explicitly still disables this
startup backup. Disabling only checkpoint mode restores the sleep blocker and
continues to honor an existing restart marker.
This does not replace filesystem backups, encryption-key recovery or off-provider
disaster recovery. Validate restoration of a staging checkpoint before enabling
the flag on customer instances.
The report inspects accounting and sandbox-cleanup spools directly. Any entry,
including malformed JSON, temporary files and failed write probes, blocks sleep.
Read errors and invalid spool paths return unknown. Recovery readers that skip
+18
View File
@@ -661,6 +661,24 @@ The envelope excludes provider messages, response bodies, names, paths, and
credentials. The existing RPC error message and code remain unchanged, as do
restore classification, retries, and source retention. Older workers without the
envelope still report `unknown` when no structured cause is available.
For native transfers, optional `workspaceRestoreTransferStep` identifies
`sandbox_access`, `sandbox_guard`, `file_download`, `file_finalize`,
`archive_create`, `archive_download`, `archive_validate`, or `archive_extract`.
`workspaceRestoreTransferFailureKind` distinguishes a producer-confirmed
`command_failed`, `download_failed`, `download_missing`, `unsafe_archive`,
`listing_timeout`, `listing_entry_limit`, `listing_byte_limit`,
`listing_line_limit`, or `listing_stderr_limit`. These labels are fixed values,
not classifications inferred from provider message text. Missing labels remain
absent. A listing deadline records `ETIMEDOUT`; sandbox and listing commands
retain bounded nonzero exit codes even when their error text is wrapped.
`workspaceRestoreRpcCode` retains only known numeric plugin RPC error codes from
a typed error at the host's sync-out boundary. In particular, RPC `TIMEOUT`
(`-32003`) is separate from the model execution's `timedOut` value; successful
model execution does not exclude a later transfer timeout. Transfer evidence is
isolated per outbound request and per restore settlement, then independently
revalidated before persistence and Sentry projection. These fields only accompany
`workspace_transfer` or `asset_restore` diagnostics and do not authorize cleanup
or retries. Older workers remain compatible.
For `git_integration`, optional `workspaceRestoreGitCommand` identifies the fixed
command family: `rev_parse`, `symbolic_ref`, `merge_base`, `merge_tree`,
`commit_tree`, `update_ref`, or `log`. `workspaceRestoreGitFailureKind` is
@@ -1,7 +1,7 @@
import { describe, expect, it, vi } from "vitest";
import { classifyWorkspaceRestoreFailure } from "./workspace-restore-merge.js";
import {
getWorkspaceRestoreDiagnostic, preserveWorkspaceRestoreErrorDiagnostic, recordWorkspaceRestoreDiagnostic,
withWorkspaceRestoreDiagnosticCapture, getWorkspaceRestoreDiagnostic, preserveWorkspaceRestoreErrorDiagnostic, recordWorkspaceRestoreDiagnostic,
sanitizeWorkspaceRestoreDiagnostic, withWorkspaceRestoreDiagnostics, withWorkspaceRestoreStep, withWorkspaceRestoreGitCommand,
type WorkspaceRestoreDiagnostic,
} from "./workspace-restore-diagnostics.js";
@@ -297,3 +297,33 @@ describe("Git integration diagnostic privacy and attribution", () => {
});
});
});
it("isolates wrapped transfer evidence across settlements reusing a frozen error", async () => {
const shared = Object.freeze(new Error("private-message"));
let resume!: () => void;
const gate = new Promise<void>(resolve => { resume = resolve; });
let started!: () => void;
const ready = new Promise<void>(resolve => { started = resolve; });
const first = withWorkspaceRestoreDiagnosticCapture(async () => {
await expect(withWorkspaceRestoreDiagnostics("workspace", () => withWorkspaceRestoreStep("workspace_transfer", async () => {
preserveWorkspaceRestoreErrorDiagnostic(shared, {}, { transferStep: "archive_create" });
started(); await gate; throw shared;
}))).rejects.toBe(shared);
return getWorkspaceRestoreDiagnostic(shared);
});
await ready;
const second = await withWorkspaceRestoreDiagnosticCapture(async () => {
await expect(withWorkspaceRestoreDiagnostics("workspace", () => withWorkspaceRestoreStep("workspace_transfer", async () => {
throw preserveWorkspaceRestoreErrorDiagnostic(shared, {}, { transferStep: "archive_download" });
}))).rejects.toBe(shared);
return getWorkspaceRestoreDiagnostic(shared);
});
resume();
expect((await first)?.transferStep).toBe("archive_create");
expect(second?.transferStep).toBe("archive_download");
await withWorkspaceRestoreDiagnosticCapture(async () => {
await expect(withWorkspaceRestoreDiagnostics("workspace", () => withWorkspaceRestoreStep("workspace_transfer", async () => { throw shared; }))).rejects.toBe(shared);
expect(getWorkspaceRestoreDiagnostic(shared)?.transferStep).toBeUndefined();
});
});
@@ -15,6 +15,18 @@ const GIT_FAILURE_KINDS = new Set([
"merge_conflict", "invalid_object", "ref_conflict", "permission_denied", "unknown",
] as const);
type GitFailureKind = typeof GIT_FAILURE_KINDS extends Set<infer T> ? T : never;
const TRANSFER_STEPS = new Set([
"sandbox_access", "sandbox_guard", "file_download", "file_finalize",
"archive_create", "archive_download", "archive_validate", "archive_extract",
] as const);
type TransferStep = typeof TRANSFER_STEPS extends Set<infer T> ? T : never;
const TRANSFER_FAILURE_KINDS = new Set([
"command_failed", "download_failed", "download_missing", "unsafe_archive",
"listing_timeout", "listing_entry_limit", "listing_byte_limit", "listing_line_limit", "listing_stderr_limit",
] as const);
type TransferFailureKind = typeof TRANSFER_FAILURE_KINDS extends Set<infer T> ? T : never;
// Deliberately revalidate the known plugin RPC constants at the persistence boundary.
const RPC_CODES = new Set([-32000, -32001, -32002, -32003, -32004, -32005, -32006, -32099]);
export interface WorkspaceRestoreDiagnostic {
phase: RestorePhase;
step?: WorkspaceRestoreStep;
@@ -23,13 +35,16 @@ export interface WorkspaceRestoreDiagnostic {
exitCode?: number;
gitCommand?: WorkspaceRestoreGitCommand;
gitFailureKind?: GitFailureKind;
transferStep?: TransferStep;
transferFailureKind?: TransferFailureKind;
rpcCode?: number;
}
const ERROR_CODES = new Set([
"ENOENT", "EACCES", "EPERM", "ENOSPC", "EIO", "EXDEV", "ENOTDIR", "EISDIR",
"ECONNRESET", "ECONNREFUSED", "ETIMEDOUT", "EPIPE", "ENOTFOUND", "EAI_AGAIN",
"ABORT_ERR", "UND_ERR_CONNECT_TIMEOUT", "UND_ERR_SOCKET",
]);
type ErrorDiagnostic = Pick<WorkspaceRestoreDiagnostic, "errorCode" | "httpStatus" | "exitCode">;
type ErrorDiagnostic = Pick<WorkspaceRestoreDiagnostic, "errorCode" | "httpStatus" | "exitCode" | "transferStep" | "transferFailureKind" | "rpcCode">;
interface DiagnosticScope {
active: boolean;
sequence: number;
@@ -44,11 +59,14 @@ const activeDiagnostic = new AsyncLocalStorage<DiagnosticScope>();
// Never attach a raw cause to an error just to retain a numeric Git exit code.
const wrappedDiagnostics = new WeakMap<object, ErrorDiagnostic>();
const restoreDiagnostics = new WeakMap<object, WorkspaceRestoreDiagnostic>();
const diagnosticCapture = new AsyncLocalStorage<WeakMap<object, WorkspaceRestoreDiagnostic>>();
const diagnosticCapture = new AsyncLocalStorage<{
restores: WeakMap<object, WorkspaceRestoreDiagnostic>;
wrapped: WeakMap<object, ErrorDiagnostic>;
}>();
/** Keep each settlement's diagnostic receipt isolated across asynchronous work. */
export function withWorkspaceRestoreDiagnosticCapture<T>(operation: () => Promise<T>): Promise<T> {
return diagnosticCapture.run(new WeakMap(), operation);
return diagnosticCapture.run({ restores: new WeakMap(), wrapped: new WeakMap() }, operation);
}
function readField(value: Record<string, unknown>, key: string): unknown {
@@ -108,7 +126,10 @@ function diagnostic(error: unknown): ErrorDiagnostic {
// SDKs wrap transport errors in a cause. Bound traversal, including cycles.
for (let depth = 0; depth < 4 && current && typeof current === "object"; depth++) {
const value = current as Record<string, unknown>;
const saved = wrappedDiagnostics.get(value);
const saved = (diagnosticCapture.getStore()?.wrapped ?? wrappedDiagnostics).get(value);
result.transferStep ??= saved?.transferStep;
result.transferFailureKind ??= saved?.transferFailureKind;
result.rpcCode ??= saved?.rpcCode;
const code = saved?.errorCode !== "unknown" && saved?.errorCode !== undefined
? saved.errorCode : readField(value, "code");
if (result.errorCode === "unknown" && typeof code === "string" && ERROR_CODES.has(code)) {
@@ -129,14 +150,27 @@ function diagnostic(error: unknown): ErrorDiagnostic {
}
/** Retain bounded fields across an existing error wrapper, without a raw cause. */
export function preserveWorkspaceRestoreErrorDiagnostic<T extends object>(wrapper: T, source: unknown): T {
wrappedDiagnostics.set(wrapper, diagnostic(source));
export function preserveWorkspaceRestoreErrorDiagnostic<T extends object>(wrapper: T, source: unknown, transferEvidence?: unknown): T {
(diagnosticCapture.getStore()?.wrapped ?? wrappedDiagnostics).set(wrapper, { ...diagnostic(source), ...sanitizeTransferEvidence(transferEvidence) });
const scope = activeDiagnostic.getStore();
const failure = scope?.active ? scope.failures.get(source) : undefined;
if (failure) scope!.failures.set(wrapper, failure);
return wrapper;
}
function sanitizeTransferEvidence(value: unknown): Pick<WorkspaceRestoreDiagnostic, "transferStep" | "transferFailureKind" | "rpcCode"> {
if (!value || typeof value !== "object") return {};
const record = value as Record<string, unknown>;
const step = readField(record, "transferStep");
const kind = readField(record, "transferFailureKind");
const rpcCode = readField(record, "rpcCode");
return {
...(typeof step === "string" && TRANSFER_STEPS.has(step as TransferStep) ? { transferStep: step as TransferStep } : {}),
...(typeof kind === "string" && TRANSFER_FAILURE_KINDS.has(kind as TransferFailureKind) ? { transferFailureKind: kind as TransferFailureKind } : {}),
...(typeof rpcCode === "number" && RPC_CODES.has(rpcCode) ? { rpcCode } : {}),
};
}
/** Decode persisted adapter metadata; never copy arbitrary properties or text. */
export function sanitizeWorkspaceRestoreDiagnostic(value: unknown): WorkspaceRestoreDiagnostic | undefined {
if (!value || typeof value !== "object") return undefined;
@@ -153,6 +187,7 @@ export function sanitizeWorkspaceRestoreDiagnostic(value: unknown): WorkspaceRes
&& typeof gitCommand === "string" && GIT_COMMANDS.has(gitCommand as WorkspaceRestoreGitCommand);
return {
phase,
...(step === "workspace_transfer" || step === "asset_restore" ? sanitizeTransferEvidence(record) : {}),
...(typeof step === "string" && RESTORE_STEPS.has(step as WorkspaceRestoreStep)
? { step: step as WorkspaceRestoreStep } : {}),
errorCode: typeof code === "string" && ERROR_CODES.has(code) ? code : "unknown",
@@ -168,14 +203,14 @@ export function sanitizeWorkspaceRestoreDiagnostic(value: unknown): WorkspaceRes
export function getWorkspaceRestoreDiagnostic(error: unknown): WorkspaceRestoreDiagnostic | undefined {
return error && typeof error === "object"
? sanitizeWorkspaceRestoreDiagnostic((diagnosticCapture.getStore() ?? restoreDiagnostics).get(error)) : undefined;
? sanitizeWorkspaceRestoreDiagnostic((diagnosticCapture.getStore()?.restores ?? restoreDiagnostics).get(error)) : undefined;
}
/** Preserve the scheduler-selected task's snapshot when errors share identity. */
export function recordWorkspaceRestoreDiagnostic(error: unknown, diagnostic: WorkspaceRestoreDiagnostic | undefined): void {
const safe = sanitizeWorkspaceRestoreDiagnostic(diagnostic);
if (error && typeof error === "object") {
const receipts = diagnosticCapture.getStore() ?? restoreDiagnostics;
const receipts = diagnosticCapture.getStore()?.restores ?? restoreDiagnostics;
if (safe) receipts.set(error, safe);
else receipts.delete(error);
}
@@ -229,10 +264,10 @@ export async function withWorkspaceRestoreDiagnostics<T>(
} catch (error) {
const failure = scope.failures.get(error);
const step = failure?.step;
const fields = { phase, ...(step ? { step } : {}), ...diagnostic(error),
const fields = sanitizeWorkspaceRestoreDiagnostic({ phase, ...(step ? { step } : {}), ...diagnostic(error),
...(phase === "workspace" && step === "git_integration" && failure?.gitCommand
? { gitCommand: failure.gitCommand, gitFailureKind: failure.gitFailureKind } : {}),
};
})!;
recordWorkspaceRestoreDiagnostic(error, fields);
if (parent?.active && step) parent.failures.set(error, { ...failure, sequence: ++parent.sequence, step });
try {
+33 -1
View File
@@ -1,7 +1,7 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { gunzipSync } from "node:zlib";
import { gunzipSync, gzipSync } from "node:zlib";
import { afterEach, describe, expect, it } from "vitest";
import postgres from "postgres";
import { createBufferedTextFileWriter, runDatabaseBackup, runDatabaseRestore } from "./backup-lib.js";
@@ -75,6 +75,38 @@ describe("createBufferedTextFileWriter", () => {
});
describeEmbeddedPostgres("runDatabaseBackup", () => {
it("preserves a post-pg_dump verification error without falling back or pruning", async () => {
const connectionString = await createTempDatabase();
const backupDir = createTempDir("checkpoint-pg-dump-");
const executable = path.join(backupDir, "pg-dump-fixture");
fs.writeFileSync(executable, "#!/bin/sh\nprintf 'SELECT 1;\\n'\n", { mode: 0o700 });
const history = path.join(backupDir, "paperclip-history.sql.gz");
const historyBytes = gzipSync("SELECT 'history';");
fs.writeFileSync(history, historyBytes);
fs.utimesSync(history, new Date(0), new Date(0));
const previous = process.env.PAPERCLIP_PG_DUMP_PATH;
process.env.PAPERCLIP_PG_DUMP_PATH = executable;
const failure = new Error("fixture archive fsync failed");
let verified = 0;
try {
await expect(runDatabaseBackup({
connectionString, backupDir,
retention: { dailyDays: 7, weeklyWeeks: 4, monthlyMonths: 1 },
verifyBeforePrune: async archive => {
verified++;
expect(gunzipSync(fs.readFileSync(archive)).toString()).toBe("SELECT 1;\n");
throw failure;
},
})).rejects.toBe(failure);
expect(verified).toBe(1);
expect(fs.readFileSync(history)).toEqual(historyBytes);
expect(fs.readdirSync(backupDir).filter(name => name.endsWith(".sql.gz"))).toEqual(["paperclip-history.sql.gz"]);
} finally {
if (previous === undefined) delete process.env.PAPERCLIP_PG_DUMP_PATH;
else process.env.PAPERCLIP_PG_DUMP_PATH = previous;
}
});
it("preserves identity generation, sequence options and progress in JavaScript backups", async () => {
const source = await createTempDatabase();
const target = await createSiblingDatabase(source, "identity_restore_target");
+20 -9
View File
@@ -6,6 +6,7 @@ import { open as openFile } from "node:fs/promises";
import { pipeline } from "node:stream/promises";
import { createGunzip, createGzip } from "node:zlib";
import postgres from "postgres";
import { randomUUID } from "node:crypto";
export type BackupRetentionPolicy = {
dailyDays: number;
@@ -28,6 +29,8 @@ export type RunDatabaseBackupOptions = {
excludeTables?: string[];
nullifyColumns?: Record<string, string[]>;
backupEngine?: "auto" | "pg_dump" | "javascript";
/** Optional durability gate. A rejected archive must not prune history. */
verifyBeforePrune?: (backupFile: string) => Promise<void>;
};
export type RunDatabaseBackupResult = {
@@ -542,13 +545,17 @@ export async function runDatabaseBackup(opts: RunDatabaseBackupOptions): Promise
await sql.end();
};
mkdirSync(opts.backupDir, { recursive: true });
const sqlFile = resolve(opts.backupDir, `${filenamePrefix}-${timestamp()}.sql`);
// Initial and final sleep checks can run within the same second. A failed
// second checkpoint must never overwrite the first verified recovery point.
const checkpointSuffix = opts.verifyBeforePrune ? `-${randomUUID()}` : "";
const sqlFile = resolve(opts.backupDir, `${filenamePrefix}-${timestamp()}${checkpointSuffix}.sql`);
const backupFile = `${sqlFile}.gz`;
const writer = createBufferedTextFileWriter(sqlFile);
try {
if (backupEngine === "pg_dump" || (backupEngine === "auto" && canUsePgDump)) {
await sql`SELECT 1`;
let dumped = false;
try {
await closeSql();
await runPgDumpBackup({
@@ -556,14 +563,7 @@ export async function runDatabaseBackup(opts: RunDatabaseBackupOptions): Promise
backupFile,
connectTimeout,
});
await writer.abort();
const sizeBytes = statSync(backupFile).size;
const prunedCount = pruneOldBackups(opts.backupDir, retention, filenamePrefix);
return {
backupFile,
sizeBytes,
prunedCount,
};
dumped = true;
} catch (error) {
if (existsSync(backupFile)) {
try { unlinkSync(backupFile); } catch { /* ignore */ }
@@ -575,6 +575,16 @@ export async function runDatabaseBackup(opts: RunDatabaseBackupOptions): Promise
sql = postgres(opts.connectionString, { max: 1, connect_timeout: connectTimeout });
sqlClosed = false;
}
if (dumped) {
// Only an engine failure can select the JavaScript fallback. Once
// pg_dump succeeds, archive verification or persistence failures
// must retain their original error and must not reuse a closed writer.
await writer.abort();
const sizeBytes = statSync(backupFile).size;
await opts.verifyBeforePrune?.(backupFile);
const prunedCount = pruneOldBackups(opts.backupDir, retention, filenamePrefix);
return { backupFile, sizeBytes, prunedCount };
}
}
await sql`SELECT 1`;
@@ -1056,6 +1066,7 @@ export async function runDatabaseBackup(opts: RunDatabaseBackupOptions): Promise
unlinkSync(sqlFile);
const sizeBytes = statSync(backupFile).size;
await opts.verifyBeforePrune?.(backupFile);
const prunedCount = pruneOldBackups(opts.backupDir, retention, filenamePrefix);
return {
+2 -2
View File
@@ -2211,9 +2211,9 @@ dependencies = [
[[package]]
name = "rustls"
version = "0.23.43"
version = "0.23.45"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06"
checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634"
dependencies = [
"aws-lc-rs",
"once_cell",
@@ -15,10 +15,10 @@
// Contract, mirrored in scripts/run-vitest-stable.mjs (prWorkflowName) and
// pinned by scripts/__tests__/run-vitest-stable-shard.test.mjs:
// - Only the PR workflow (pr.yml, whose GITHUB_WORKFLOW the reusable
// pr-trusted.yml jobs inherit) excludes the suite from the server shards,
// and only there does this wrapper run it. Any other caller — local runs,
// release-verify.yml — keeps the suite in the server group, so a renamed
// workflow degrades to the slower covered path instead of losing coverage.
// pr-trusted.yml jobs inherit) excludes these suites from the existing
// without-chat group, and only there does this wrapper run them. Other
// callers keep that group complete. Release verification instead selects
// an explicit partition and its own required cached native lane.
// - The suite runs on the lane whose --shard=N/M has N === M (or an unsharded
// invocation), so exactly one PR lane carries it.
import { spawnSync } from "node:child_process";
@@ -0,0 +1,113 @@
import { promises as fs } from "node:fs";
import { execFileSync } from "node:child_process";
import os from "node:os";
import path from "node:path";
import { afterEach, expect, it, vi } from "vitest";
import { environmentSyncErrorData, withEnvironmentSyncErrorCapture, readEnvironmentSyncErrorDiagnostic } from "@paperclipai/plugin-sdk";
vi.mock("./plugin.js", async () => {
const { NOOP_PLUGIN_TRACER } = await import("@paperclipai/plugin-sdk");
return { getPluginTracer: () => NOOP_PLUGIN_TRACER };
});
import { performSyncOut } from "./file-sync.js";
const dirs: string[] = [];
afterEach(async () => { await Promise.all(dirs.splice(0).map(dir => fs.rm(dir, { recursive: true, force: true }))); });
it.each([
["guard", "sandbox_guard", "command_failed", 42],
["create", "archive_create", "command_failed", 2],
["download", "archive_download", "download_failed", undefined],
["missing", "archive_download", "download_missing", undefined],
["file", "file_download", "download_failed", undefined],
["file-missing", "file_download", "download_missing", undefined],
] as const)("records producer evidence for %s without copying output or paths", async (failure, step, kind, exitCode) => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "daytona-diagnostic-"));
dirs.push(dir);
const sandbox = {
process: { executeCommand: async (command: string) => ({
exitCode: failure === "guard" ? 42 : failure === "create" && command.includes("tar -c") ? 2 : 0,
result: "private-command-output",
}) },
fs: {
downloadFiles: async (requests: Array<{ source: string }>) => failure.endsWith("missing") ? []
: requests.map(request => ({ source: request.source, error: "private-download-error" })),
deleteFile: async () => undefined,
},
};
await withEnvironmentSyncErrorCapture(async () => {
let caught: unknown;
try {
await performSyncOut({ sandbox: sandbox as never, remoteDir: "/workspace", timeoutSeconds: 30,
operations: [{ operationId: "fixture", files: [{
sourcePath: "/workspace/private-path", targetPath: path.join(dir, "target"), kind: failure.startsWith("file") ? "file" : "directory",
}] }],
});
} catch (error) { caught = error; }
expect(caught).toBeInstanceOf(Error);
const diagnostic = readEnvironmentSyncErrorDiagnostic({ data: environmentSyncErrorData(caught) });
expect(diagnostic).toEqual({ errorCode: "unknown", transferStep: step, transferFailureKind: kind,
...(exitCode !== undefined ? { exitCode } : {}),
});
expect(JSON.stringify(diagnostic)).not.toContain("private-");
expect(caught).not.toHaveProperty("transferStep");
expect(caught).not.toHaveProperty("exitCode");
});
});
it("keeps a thrown download exception identical and its nested safe code", async () => {
const original = Object.freeze(new Error("private-download-error", { cause: { code: "ECONNRESET", token: "private-token" } }));
const sandbox = {
process: { executeCommand: async () => ({ exitCode: 0 }) },
fs: { downloadFiles: async () => { throw original; }, deleteFile: async () => undefined },
};
await withEnvironmentSyncErrorCapture(async () => {
await expect(performSyncOut({ sandbox: sandbox as never, remoteDir: "/workspace", timeoutSeconds: 30,
operations: [{ operationId: "fixture", files: [{ sourcePath: "/workspace/private-path", targetPath: "/unused", kind: "directory" }] }],
})).rejects.toBe(original);
expect(readEnvironmentSyncErrorDiagnostic({ data: environmentSyncErrorData(original) })).toEqual({
errorCode: "ECONNRESET", transferStep: "archive_download",
});
});
});
it.each(["file_finalize", "archive_validate", "archive_extract"] as const)("identifies the host-side %s failure without relaxing confinement", async (step) => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "daytona-host-diagnostic-"));
dirs.push(dir);
const target = path.join(dir, "target");
const archive = path.join(dir, "safe.tar");
if (step === "archive_extract") {
await fs.writeFile(path.join(dir, "safe.txt"), "fixture");
execFileSync("tar", ["-cf", archive, "-C", dir, "safe.txt"]);
await fs.writeFile(target, "preserve target");
}
const sandbox = {
process: { executeCommand: async () => ({ exitCode: 0 }) },
fs: {
downloadFiles: async (requests: Array<{ source: string; destination: string }>) => {
for (const request of requests) {
if (step === "archive_validate") await fs.writeFile(request.destination, "invalid private archive");
if (step === "archive_extract") await fs.copyFile(archive, request.destination);
}
return requests.map(request => ({ source: request.source }));
},
deleteFile: async () => undefined,
},
};
await withEnvironmentSyncErrorCapture(async () => {
let caught: unknown;
try {
await performSyncOut({ sandbox: sandbox as never, remoteDir: "/workspace", timeoutSeconds: 30,
operations: [{ operationId: "fixture", files: [{ sourcePath: "/workspace/private-path", targetPath: target,
kind: step === "file_finalize" ? "file" : "directory" }] }],
});
} catch (error) { caught = error; }
expect(caught).toBeInstanceOf(Error);
const diagnostic = readEnvironmentSyncErrorDiagnostic({ data: environmentSyncErrorData(caught) });
expect(diagnostic?.transferStep).toBe(step);
expect(JSON.stringify(diagnostic)).not.toContain("private");
if (step === "file_finalize") expect(diagnostic?.errorCode).toBe("ENOENT");
if (step === "archive_validate") expect(diagnostic?.transferFailureKind).toBe("command_failed");
if (step === "archive_extract") expect(await fs.readFile(target, "utf8")).toBe("preserve target");
});
});
@@ -14,6 +14,7 @@ import type {
PluginSyncFileMapping,
PluginSyncOperation,
} from "@paperclipai/plugin-sdk";
import { recordEnvironmentSyncError, withEnvironmentSyncTransferStep } from "@paperclipai/plugin-sdk";
import { getPluginTracer } from "./plugin.js";
const execFileAsync = promisify(execFile);
@@ -318,9 +319,13 @@ export async function assertTarballEntriesConfined(
const validateLine = (line: Buffer) => {
// Count empty lines too, so whitespace cannot evade the parsing-work quota.
if (++entries > TAR_LISTING_MAX_ENTRIES) {
throw new Error("Daytona syncOut tar listing entry limit exceeded (250000)");
throw recordEnvironmentSyncError(new Error("Daytona syncOut tar listing entry limit exceeded (250000)"), { transferFailureKind: "listing_entry_limit" });
}
try { assertTarListingLineConfined(line.toString("utf8")); }
catch (error) {
if (error instanceof UnsafeOutboundArchiveError) recordEnvironmentSyncError(error, { transferFailureKind: "unsafe_archive" });
throw error;
}
assertTarListingLineConfined(line.toString("utf8"));
};
const closed = new Promise<{ code: number | null; signal: NodeJS.Signals | null }>((resolve) => {
child.once("error", (error) => { spawnError = error; });
@@ -331,11 +336,11 @@ export async function assertTarballEntriesConfined(
child.kill("SIGKILL");
};
const timer = setTimeout(() => {
stop(new Error("Daytona syncOut tar listing validation timed out"));
stop(recordEnvironmentSyncError(new Error("Daytona syncOut tar listing validation timed out"), { transferFailureKind: "listing_timeout", errorCode: "ETIMEDOUT" }));
}, Math.max(1, Math.min(timeoutMs, TAR_LISTING_TIMEOUT_MS)));
child.stderr.on("data", (chunk: Buffer) => {
if (stderr.length + chunk.length > TAR_LISTING_MAX_STDERR_BYTES) {
stop(new Error("Daytona syncOut tar listing diagnostics exceed the byte limit"));
stop(recordEnvironmentSyncError(new Error("Daytona syncOut tar listing diagnostics exceed the byte limit"), { transferFailureKind: "listing_stderr_limit" }));
return;
}
stderr = Buffer.concat([stderr, chunk]);
@@ -346,14 +351,14 @@ export async function assertTarballEntriesConfined(
const bytes = chunk as Buffer;
totalBytes += bytes.length;
if (totalBytes > TAR_LISTING_MAX_TOTAL_BYTES) {
throw new Error("Daytona syncOut tar total listing byte limit exceeded (64 MiB)");
throw recordEnvironmentSyncError(new Error("Daytona syncOut tar total listing byte limit exceeded (64 MiB)"), { transferFailureKind: "listing_byte_limit" });
}
let start = 0;
while (start < bytes.length) {
const newline = bytes.indexOf(10, start);
const end = newline < 0 ? bytes.length : newline;
if (pending.length + end - start > TAR_LISTING_MAX_LINE_BYTES) {
throw new Error("Daytona syncOut refusing tarball with an entry listing exceeding the byte limit");
throw recordEnvironmentSyncError(new Error("Daytona syncOut refusing tarball with an entry listing exceeding the byte limit"), { transferFailureKind: "listing_line_limit" });
}
pending = Buffer.concat([pending, bytes.subarray(start, end)]);
if (newline < 0) break;
@@ -367,7 +372,9 @@ export async function assertTarballEntriesConfined(
if (failure) throw failure;
if (spawnError) throw spawnError;
if (result.code !== 0) {
throw new Error(`Daytona syncOut tar listing failed (${result.signal ?? result.code}): ${stderr.toString("utf8").trim()}`);
throw recordEnvironmentSyncError(new Error(`Daytona syncOut tar listing failed (${result.signal ?? result.code}): ${stderr.toString("utf8").trim()}`), {
transferFailureKind: "command_failed", exitCode: result.code ?? undefined,
});
}
} catch (error) {
stop(error instanceof Error ? error : new Error(String(error)));
@@ -381,11 +388,13 @@ export async function assertTarballEntriesConfined(
async function extractHostTarball(input: { archivePath: string; localDir: string }): Promise<void> {
// The archive is sandbox-authored and untrusted: validate every member (and
// link target) is confined before letting host-side tar write a single byte.
await assertTarballEntriesConfined(input.archivePath);
await fs.mkdir(input.localDir, { recursive: true });
await execFileAsync("tar", ["-xf", input.archivePath, "-C", input.localDir], {
env: { ...process.env, COPYFILE_DISABLE: "1" },
maxBuffer: 32 * 1024 * 1024,
await withEnvironmentSyncTransferStep("archive_validate", () => assertTarballEntriesConfined(input.archivePath));
await withEnvironmentSyncTransferStep("archive_extract", async () => {
await fs.mkdir(input.localDir, { recursive: true });
await execFileAsync("tar", ["-xf", input.archivePath, "-C", input.localDir], {
env: { ...process.env, COPYFILE_DISABLE: "1" },
maxBuffer: 32 * 1024 * 1024,
});
});
}
@@ -423,7 +432,9 @@ async function assertSandboxCommandOkWithOutput(
const result = await sandbox.process.executeCommand(command, undefined, undefined, timeoutSeconds);
if ((result.exitCode ?? 1) !== 0) {
const detail = (result.result ?? result.artifacts?.stdout ?? "").toString().trim();
throw new Error(`Daytona ${label} command failed (exit ${result.exitCode ?? "unknown"})${detail ? `: ${detail}` : ""}`);
throw recordEnvironmentSyncError(new Error(`Daytona ${label} command failed (exit ${result.exitCode ?? "unknown"})${detail ? `: ${detail}` : ""}`), {
transferFailureKind: "command_failed", exitCode: result.exitCode,
});
}
return (result.result ?? result.artifacts?.stdout ?? "").toString();
}
@@ -1143,17 +1154,14 @@ async function syncOutFileMappings(input: {
const { sandbox, mappings, remoteDir, timeoutSeconds } = input;
if (mappings.length === 0) return { filesTransferred: 0, bytesTransferred: 0 };
for (const mapping of mappings) {
assertConfinedSandboxPath(remoteDir, mapping.sourcePath, "source");
}
// Close the validation→download TOCTOU: instead of re-opening each mutable
// source, validate-and-snapshot it in one atomic sandbox-side step and download
// the immutable snapshot. `snapshots` is index-aligned with `mappings`.
const snapshots = await snapshotOutboundFileSources({
sandbox,
remoteDir,
sources: mappings.map((mapping) => mapping.sourcePath),
timeoutSeconds,
const snapshots = await withEnvironmentSyncTransferStep("sandbox_guard", async () => {
for (const mapping of mappings) assertConfinedSandboxPath(remoteDir, mapping.sourcePath, "source");
return snapshotOutboundFileSources({
sandbox, remoteDir, sources: mappings.map((mapping) => mapping.sourcePath), timeoutSeconds,
});
});
const requests: FileDownloadRequest[] = [];
@@ -1179,6 +1187,7 @@ async function syncOutFileMappings(input: {
await fs.mkdir(path.dirname(entry.target), { recursive: true });
}
} catch (error) {
recordEnvironmentSyncError(error, { transferStep: "file_finalize" });
await cleanup();
throw error;
}
@@ -1193,7 +1202,7 @@ async function syncOutFileMappings(input: {
try {
// One batched bulk download for all file mappings, reading the snapshots.
// `transfer` span: the real byte download — `sandbox.fs.downloadFiles`.
responses = await withProviderSpan({
responses = await withEnvironmentSyncTransferStep("file_download", () => withProviderSpan({
name: "transfer",
wallMsAttr: SPAN_ATTR.transferWallMs,
attributes: {
@@ -1201,7 +1210,7 @@ async function syncOutFileMappings(input: {
[SPAN_ATTR.transferDirection]: "outbound",
},
run: () => sandbox.fs.downloadFiles(requests, timeoutSeconds),
});
}));
} catch (error) {
await cleanup();
throw error;
@@ -1215,9 +1224,9 @@ async function syncOutFileMappings(input: {
const response = bySource.get(entry.snapshot);
if (!response || response.error) {
await cleanup();
throw new Error(
throw recordEnvironmentSyncError(new Error(
`Daytona syncOut download failed for ${entry.source}: ${response?.error ?? "no response returned"}`,
);
), { transferStep: "file_download", transferFailureKind: response ? "download_failed" : "download_missing" });
}
}
@@ -1233,6 +1242,7 @@ async function syncOutFileMappings(input: {
await fs.rename(entry.temp, entry.target);
}
} catch (error) {
recordEnvironmentSyncError(error, { transferStep: "file_finalize" });
await cleanup();
throw error;
}
@@ -1251,16 +1261,15 @@ async function syncOutDirectoryMapping(input: {
onArchiveRecovery?: () => void;
}): Promise<{ filesTransferred: number; bytesTransferred: number }> {
const { sandbox, mapping, remoteDir, timeoutSeconds } = input;
assertConfinedSandboxPath(remoteDir, mapping.sourcePath, "source");
// Count the serial sandbox round trips before the transfer, so the transfer
// span records how much of the wall time is guard cost.
let guardRoundTrips = 0;
await assertSandboxPathsConfined({
sandbox,
remoteDir,
paths: [mapping.sourcePath],
timeoutSeconds,
label: "outbound symlink-escape guard",
await withEnvironmentSyncTransferStep("sandbox_guard", async () => {
assertConfinedSandboxPath(remoteDir, mapping.sourcePath, "source");
await assertSandboxPathsConfined({
sandbox, remoteDir, paths: [mapping.sourcePath], timeoutSeconds,
label: "outbound symlink-escape guard",
});
});
guardRoundTrips += 1;
@@ -1310,10 +1319,11 @@ async function syncOutDirectoryMapping(input: {
let bytesTransferred = 0;
try {
for (let attempt = 0; attempt < 2; attempt += 1) {
await assertSandboxCommandOk(sandbox, `sh -c ${shellQuote(attempt === 0 ? tarScript : confinedEntriesScript)}`, timeoutSeconds, "syncOut tar");
await withEnvironmentSyncTransferStep("archive_create", () =>
assertSandboxCommandOk(sandbox, `sh -c ${shellQuote(attempt === 0 ? tarScript : confinedEntriesScript)}`, timeoutSeconds, "syncOut tar"));
guardRoundTrips += 1;
// `transfer` span: the real byte download — `sandbox.fs.downloadFiles`.
const responses = await withProviderSpan({
const responses = await withEnvironmentSyncTransferStep("archive_download", () => withProviderSpan({
name: "transfer",
wallMsAttr: SPAN_ATTR.transferWallMs,
attributes: {
@@ -1322,14 +1332,14 @@ async function syncOutDirectoryMapping(input: {
},
run: () =>
sandbox.fs.downloadFiles([{ source: remoteTar, destination: localTar }], timeoutSeconds),
});
}));
const response = responses.find((entry) => entry.source === remoteTar) ?? responses[0];
if (!response || response.error) {
throw new Error(
throw recordEnvironmentSyncError(new Error(
`Daytona syncOut directory download failed for ${mapping.sourcePath}: ${response?.error ?? "no response returned"}`,
);
), { transferStep: "archive_download", transferFailureKind: response ? "download_failed" : "download_missing" });
}
bytesTransferred += (await fs.stat(localTar)).size;
bytesTransferred += await withEnvironmentSyncTransferStep("archive_download", async () => (await fs.stat(localTar)).size);
try {
await extractHostTarball({ archivePath: localTar, localDir: mapping.targetPath });
break;
@@ -36,7 +36,7 @@ import plugin, {
__setDaytonaPluginContextForTest,
} from "./plugin.js";
import type { PluginContext } from "@paperclipai/plugin-sdk";
import { environmentCreationCleanupErrorData, readEnvironmentCreationCleanupError } from "@paperclipai/plugin-sdk";
import { environmentSyncErrorData, readEnvironmentSyncErrorDiagnostic, withEnvironmentSyncErrorCapture, environmentCreationCleanupErrorData, readEnvironmentCreationCleanupError } from "@paperclipai/plugin-sdk";
import manifest from "./manifest.js";
import { parseTarVerboseListingLine, splitLinkEntryOnce } from "./file-sync.js";
@@ -5263,33 +5263,32 @@ describe("daytona native file-sync hooks", () => {
});
});
it("classifies a deleted sandbox during syncOut with a stable unrecoverable code", async () => {
it.each(["sandbox_access", "file_download"] as const)("keeps the deleted-sandbox policy and bounded %s evidence", async (step) => {
const hostDir = await makeHostDir();
mockGet.mockRejectedValue(
new MockDaytonaNotFoundError("provider detail must not escape"),
);
await expect(
plugin.definition.onEnvironmentSyncOut?.({
driverKey: "daytona",
companyId: "company-1",
environmentId: "env-1",
config: { timeoutMs: 300000, reuseLease: true },
lease: syncLease(),
operations: [
{
operationId: "sync-op-missing-sandbox",
files: [
{
sourcePath: `${REMOTE_DIR}/out/result.txt`,
targetPath: path.join(hostDir, "result.txt"),
kind: "file",
},
],
},
],
}),
).rejects.toThrow("daytona_sandbox_not_found");
const source = Object.freeze(Object.assign(new MockDaytonaNotFoundError("private-provider-detail"), {
status: 404, cause: { code: "EIO", token: "private-provider-token" },
}));
if (step === "sandbox_access") mockGet.mockRejectedValue(source);
else {
const sandbox = createMockSandbox();
sandbox.fs.downloadFiles.mockRejectedValue(source);
mockGet.mockResolvedValue(sandbox);
}
await withEnvironmentSyncErrorCapture(async () => {
const error = await plugin.definition.onEnvironmentSyncOut!({
driverKey: "daytona", companyId: "company-1", environmentId: "env-1",
config: { timeoutMs: 300000, reuseLease: true }, lease: syncLease(),
operations: [{ operationId: "sync-op-missing-sandbox", files: [{
sourcePath: `${REMOTE_DIR}/out/result.txt`, targetPath: path.join(hostDir, "result.txt"), kind: "file",
}] }],
}).catch(error => error);
expect(error).toBeInstanceOf(Error);
expect(error.message).toBe("daytona_sandbox_not_found");
expect(error).not.toHaveProperty("cause");
const diagnostic = readEnvironmentSyncErrorDiagnostic({ data: environmentSyncErrorData(error) });
expect(diagnostic).toEqual({ errorCode: "EIO", httpStatus: 404, transferStep: step });
expect(JSON.stringify(diagnostic)).not.toContain("private-provider-");
});
});
it("syncOut snapshot guard re-checks the resolved source is a non-symlink regular file immediately before copying (validation→copy TOCTOU)", async () => {
@@ -9,7 +9,7 @@ import type {
Resources,
Sandbox,
} from "@daytonaio/sdk";
import { decodeChannelBytes, definePlugin, NOOP_PLUGIN_TRACER, PluginEnvironmentCreationCleanupError, readEnvironmentCreationCleanupError } from "@paperclipai/plugin-sdk";
import { decodeChannelBytes, definePlugin, NOOP_PLUGIN_TRACER, PluginEnvironmentCreationCleanupError, readEnvironmentCreationCleanupError, withEnvironmentSyncTransferStep, preserveEnvironmentSyncErrorDiagnostic } from "@paperclipai/plugin-sdk";
import type {
PluginContext,
PluginEnvironmentCreationCleanup,
@@ -3271,8 +3271,11 @@ const plugin = definePlugin({
};
try {
return await withSandboxActivityGate(scope, async () => {
const sandbox = await getSandbox(scope, { bypassTeardownGate: true });
await ensureSandboxStarted(sandbox, timeoutSeconds);
const sandbox = await withEnvironmentSyncTransferStep("sandbox_access", async () => {
const resolved = await getSandbox(scope, { bypassTeardownGate: true });
await ensureSandboxStarted(resolved, timeoutSeconds);
return resolved;
});
const result = await performSyncOut({
sandbox,
operations: params.operations,
@@ -3288,7 +3291,7 @@ const plugin = definePlugin({
// unexported workspace bytes no longer exist. Convert the SDK class to a
// stable cross-worker message; every other error remains retryable.
if (error instanceof DaytonaNotFoundError) {
throw new Error("daytona_sandbox_not_found");
throw preserveEnvironmentSyncErrorDiagnostic(new Error("daytona_sandbox_not_found"), error);
}
throw error;
}
@@ -4,6 +4,7 @@ import path from "node:path";
import { afterEach, expect, it, vi } from "vitest";
vi.mock("./plugin.js", () => ({ getPluginTracer: () => undefined }));
import { environmentSyncErrorData, withEnvironmentSyncErrorCapture, withEnvironmentSyncTransferStep, readEnvironmentSyncErrorDiagnostic } from "@paperclipai/plugin-sdk";
import { assertTarballEntriesConfined } from "./file-sync.js";
const temporaryDirectories: string[] = [];
@@ -100,18 +101,28 @@ it.each([false, true])("bounds aggregate listing bytes at 64 MiB (over quota: %s
const line = "-rw-r--r-- 0/0 0 2026-09-27 12:00 ".padEnd(4095, "x") + "\n";
const count = 64 * 1024 * 1024 / Buffer.byteLength(line) + Number(overQuota);
const checkReaped = await fakeTar(listingWriter(line, count));
const result = assertTarballEntriesConfined("unused.tar");
if (overQuota) await expect(result).rejects.toThrow("total listing byte limit");
else await expect(result).resolves.toBeUndefined();
await withEnvironmentSyncErrorCapture(async () => {
const result = assertTarballEntriesConfined("unused.tar");
if (overQuota) {
const error = await result.catch(error => error);
expect(error.message).toContain("total listing byte limit");
expect(readEnvironmentSyncErrorDiagnostic({ data: environmentSyncErrorData(error) })?.transferFailureKind).toBe("listing_byte_limit");
} else await expect(result).resolves.toBeUndefined();
});
await checkReaped();
}, 30_000);
it.each([false, true])("bounds aggregate listing entries at 250,000 (over quota: %s)", async (overQuota) => {
// The over-quota member has no newline: EOF must pass the same admission gate.
const checkReaped = await fakeTar(listingWriter(safeLine + "\n", 250_000, overQuota ? safeLine : ""));
const result = assertTarballEntriesConfined("unused.tar");
if (overQuota) await expect(result).rejects.toThrow("listing entry limit");
else await expect(result).resolves.toBeUndefined();
await withEnvironmentSyncErrorCapture(async () => {
const result = assertTarballEntriesConfined("unused.tar");
if (overQuota) {
const error = await result.catch(error => error);
expect(error.message).toContain("listing entry limit");
expect(readEnvironmentSyncErrorDiagnostic({ data: environmentSyncErrorData(error) })?.transferFailureKind).toBe("listing_entry_limit");
} else await expect(result).resolves.toBeUndefined();
});
await checkReaped();
}, 30_000);
@@ -189,3 +200,26 @@ it("rejects spawn failures without hanging", async () => {
vi.stubEnv("PATH", await temporaryDirectory());
await expect(assertTarballEntriesConfined("unused.tar")).rejects.toThrow("ENOENT");
});
it.each([
["process.exitCode = 2;", "command_failed", 2, "unknown"],
['process.stdout.write("x".repeat(65_537)); setInterval(() => {}, 1_000);', "listing_line_limit", undefined, "unknown"],
['process.stderr.write("x".repeat(65_537)); setInterval(() => {}, 1_000);', "listing_stderr_limit", undefined, "unknown"],
['process.stdout.write("invalid-private-listing");', "unsafe_archive", undefined, "unknown"],
['setInterval(() => {}, 1_000);', "listing_timeout", undefined, "ETIMEDOUT"],
] as const)("records bounded listing evidence and reaps the child (%s)", async (script, kind, exitCode, errorCode) => {
const checkReaped = await fakeTar(script);
await withEnvironmentSyncErrorCapture(async () => {
let caught: unknown;
try { await withEnvironmentSyncTransferStep("archive_validate", () => assertTarballEntriesConfined("private-archive.tar", 2_000)); }
catch (error) { caught = error; }
expect(caught).toBeInstanceOf(Error);
const diagnostic = readEnvironmentSyncErrorDiagnostic({ data: environmentSyncErrorData(caught) });
expect(diagnostic).toEqual({ errorCode, transferStep: "archive_validate", transferFailureKind: kind,
...(exitCode !== undefined ? { exitCode } : {}),
});
expect(JSON.stringify(diagnostic)).not.toContain("private-");
});
await checkReaped();
});
@@ -1,8 +1,67 @@
import { AsyncLocalStorage } from "node:async_hooks";
/** Diagnostic evidence only. Never use this envelope to authorize cleanup or retry. */
export interface PluginEnvironmentSyncErrorDiagnostic {
errorCode: string;
httpStatus?: number;
exitCode?: number;
transferStep?: EnvironmentSyncTransferStep;
transferFailureKind?: EnvironmentSyncTransferFailureKind;
}
const TRANSFER_STEPS = new Set([
"sandbox_access", "sandbox_guard", "file_download", "file_finalize",
"archive_create", "archive_download", "archive_validate", "archive_extract",
] as const);
export type EnvironmentSyncTransferStep = typeof TRANSFER_STEPS extends Set<infer T> ? T : never;
const TRANSFER_FAILURE_KINDS = new Set([
"command_failed", "download_failed", "download_missing", "unsafe_archive",
"listing_timeout", "listing_entry_limit", "listing_byte_limit", "listing_line_limit", "listing_stderr_limit",
] as const);
export type EnvironmentSyncTransferFailureKind = typeof TRANSFER_FAILURE_KINDS extends Set<infer T> ? T : never;
interface Capture {
active: boolean;
sequence: number;
errors: WeakMap<object, { sequence: number; diagnostic: PluginEnvironmentSyncErrorDiagnostic }>;
}
const capture = new AsyncLocalStorage<Capture>();
/** Scope evidence to one outbound RPC, including its error serialization. */
export async function withEnvironmentSyncErrorCapture<T>(operation: () => Promise<T>): Promise<T> {
const scope: Capture = { active: true, sequence: 0, errors: new WeakMap() };
return capture.run(scope, async () => {
try { return await operation(); }
finally { scope.active = false; }
});
}
/** Producer-owned evidence only; never mutate, wrap, or retain a raw cause. */
export function recordEnvironmentSyncError<T>(error: T, diagnostic: Partial<PluginEnvironmentSyncErrorDiagnostic>): T {
const scope = capture.getStore();
const safe = sanitize(diagnostic);
if (scope?.active && error && typeof error === "object") {
if (safe) scope.errors.set(error, { sequence: ++scope.sequence, diagnostic: safe });
else scope.errors.delete(error);
}
return error;
}
/** A nested failing stage wins; a later attempt gets fresh attribution. */
export async function withEnvironmentSyncTransferStep<T>(step: EnvironmentSyncTransferStep, operation: () => Promise<T>): Promise<T> {
const scope = capture.getStore();
if (!scope?.active || !TRANSFER_STEPS.has(step)) return await operation();
const started = scope.sequence;
try { return await operation(); }
catch (error) {
if (scope.active && error && typeof error === "object") {
const saved = scope.errors.get(error);
const diagnostic = saved && saved.sequence > started ? saved.diagnostic : { errorCode: "unknown" };
scope.errors.set(error, {
sequence: ++scope.sequence, diagnostic: { ...diagnostic, transferStep: diagnostic.transferStep ?? step },
});
}
throw error;
}
}
const SCHEMA = "paperclip/environment-sync-error/v1";
@@ -29,13 +88,23 @@ function sanitize(value: unknown): PluginEnvironmentSyncErrorDiagnostic | undefi
const errorCode = typeof code === "string" && ERROR_CODES.has(code) ? code : "unknown";
const httpStatus = integer(field(value, "httpStatus"), 400, 599);
const exitCode = integer(field(value, "exitCode"), 1, 255);
if (errorCode === "unknown" && httpStatus === undefined && exitCode === undefined) return undefined;
return { errorCode, ...(httpStatus !== undefined ? { httpStatus } : {}), ...(exitCode !== undefined ? { exitCode } : {}) };
const step = field(value, "transferStep");
const kind = field(value, "transferFailureKind");
const transferStep = typeof step === "string" && TRANSFER_STEPS.has(step as EnvironmentSyncTransferStep) ? step as EnvironmentSyncTransferStep : undefined;
const transferFailureKind = typeof kind === "string" && TRANSFER_FAILURE_KINDS.has(kind as EnvironmentSyncTransferFailureKind) ? kind as EnvironmentSyncTransferFailureKind : undefined;
if (errorCode === "unknown" && httpStatus === undefined && exitCode === undefined && !transferStep && !transferFailureKind) return undefined;
return { errorCode, ...(httpStatus !== undefined ? { httpStatus } : {}), ...(exitCode !== undefined ? { exitCode } : {}),
...(transferStep ? { transferStep } : {}), ...(transferFailureKind ? { transferFailureKind } : {}),
};
}
/** Capture known codes and numbers, never arbitrary error data, text, or names. */
export function environmentSyncErrorData(error: unknown): unknown {
function captureDiagnostic(error: unknown): PluginEnvironmentSyncErrorDiagnostic | undefined {
const diagnostic: PluginEnvironmentSyncErrorDiagnostic = { errorCode: "unknown" };
const scope = capture.getStore();
if (scope?.active && error && typeof error === "object") {
Object.assign(diagnostic, scope.errors.get(error)?.diagnostic);
}
let current = error;
// Bound nested SDK causes, including cyclic errors and throwing getters.
for (let depth = 0; depth < 4 && current && typeof current === "object"; depth++) {
@@ -47,8 +116,18 @@ export function environmentSyncErrorData(error: unknown): unknown {
diagnostic.exitCode ??= integer(field(current, "exitCode"), 1, 255) ?? integer(code, 1, 255);
current = field(current, "cause");
}
const safe = sanitize(diagnostic);
return safe ? { schema: SCHEMA, diagnostic: safe } : undefined;
return sanitize(diagnostic);
}
export function environmentSyncErrorData(error: unknown): unknown {
const diagnostic = captureDiagnostic(error);
return diagnostic ? { schema: SCHEMA, diagnostic } : undefined;
}
/** Retain bounded evidence across an existing wrapper, without adding a raw cause. */
export function preserveEnvironmentSyncErrorDiagnostic<T>(wrapper: T, source: unknown): T {
const diagnostic = captureDiagnostic(source);
return recordEnvironmentSyncError(wrapper, diagnostic ?? { errorCode: "unknown" });
}
/** Revalidate the worker envelope before adapting it into host-only diagnostics. */
+1 -1
View File
@@ -452,7 +452,7 @@ export {
export { PluginEnvironmentCreationCleanupError, environmentCreationCleanupErrorData, readEnvironmentCreationCleanupError } from "./environment-creation-cleanup.js";
export type { PluginEnvironmentCreationCleanup } from "./environment-creation-cleanup.js";
export { readEnvironmentSyncErrorDiagnostic } from "./environment-sync-error.js";
export { preserveEnvironmentSyncErrorDiagnostic, environmentSyncErrorData, readEnvironmentSyncErrorDiagnostic, withEnvironmentSyncErrorCapture, withEnvironmentSyncTransferStep, recordEnvironmentSyncError } from "./environment-sync-error.js";
export type { PluginEnvironmentSyncErrorDiagnostic } from "./environment-sync-error.js";
export type { AiConnectionPool, AiConnectionPoolConfig, AiConnectionPoolMember, AiConnectionRouterRequest, AiConnectionRouterResult, AiConnectionRouterSelection } from "@paperclipai/shared";
+8 -1
View File
@@ -1,6 +1,6 @@
import type { AiConnectionRouterRequest, AiConnectionRouterResult } from "@paperclipai/shared";
import { environmentCreationCleanupErrorData } from "./environment-creation-cleanup.js";
import { environmentSyncErrorData } from "./environment-sync-error.js";
import { environmentSyncErrorData, withEnvironmentSyncErrorCapture } from "./environment-sync-error.js";
import { createPluginIdleDrain } from "./idle-drain.js";
/**
* Worker-side RPC host — runs inside the child process spawned by the host.
@@ -1570,6 +1570,13 @@ export function startWorkerRpcHost(options: WorkerRpcHostOptions): WorkerRpcHost
* Dispatches to the correct handler based on the method name.
*/
async function handleHostRequest(request: JsonRpcRequest): Promise<void> {
if (request.method === "environmentSyncOut") {
return withEnvironmentSyncErrorCapture(() => handleHostRequestInScope(request));
}
return handleHostRequestInScope(request);
}
async function handleHostRequestInScope(request: JsonRpcRequest): Promise<void> {
const { id, method, params } = request;
let done: (() => void) | undefined;
try {
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { environmentSyncErrorData, readEnvironmentSyncErrorDiagnostic } from "../src/environment-sync-error.js";
import { preserveEnvironmentSyncErrorDiagnostic, environmentSyncErrorData, readEnvironmentSyncErrorDiagnostic, withEnvironmentSyncErrorCapture, withEnvironmentSyncTransferStep, recordEnvironmentSyncError } from "../src/environment-sync-error.js";
const schema = "paperclip/environment-sync-error/v1";
@@ -46,3 +46,70 @@ describe("environment sync diagnostic envelope", () => {
expect(readEnvironmentSyncErrorDiagnostic(Object.defineProperty({}, "data", { get() { throw new Error("private"); } }))).toBeUndefined();
});
});
describe("request-local transfer evidence", () => {
it("preserves frozen error identity, nested attribution, and later-attempt attribution", async () => {
const error = Object.freeze(new Error("private-message"));
const before = Object.getOwnPropertyDescriptors(error);
await withEnvironmentSyncErrorCapture(async () => {
await expect(withEnvironmentSyncTransferStep("archive_download", () =>
withEnvironmentSyncTransferStep("sandbox_access", async () => { throw error; }))).rejects.toBe(error);
expect(readEnvironmentSyncErrorDiagnostic({ data: environmentSyncErrorData(error) })?.transferStep).toBe("sandbox_access");
await expect(withEnvironmentSyncTransferStep("archive_create", async () => { throw error; })).rejects.toBe(error);
expect(readEnvironmentSyncErrorDiagnostic({ data: environmentSyncErrorData(error) })?.transferStep).toBe("archive_create");
});
expect(environmentSyncErrorData(error)).toBeUndefined();
expect(Object.getOwnPropertyDescriptors(error)).toEqual(before);
await withEnvironmentSyncErrorCapture(async () => expect(environmentSyncErrorData(error)).toBeUndefined());
});
it("does not trust labels on raw errors, their causes, or unknown producer values", async () => {
const error = { transferStep: "archive_create", transferFailureKind: "command_failed",
cause: { transferStep: "archive_validate", message: "private-message" } };
await withEnvironmentSyncErrorCapture(async () => {
expect(environmentSyncErrorData(error)).toBeUndefined();
recordEnvironmentSyncError(error, { transferStep: "private-stage", transferFailureKind: "private-kind", exitCode: 900 } as never);
expect(environmentSyncErrorData(error)).toBeUndefined();
});
expect(readEnvironmentSyncErrorDiagnostic({ data: { schema, diagnostic: {
errorCode: "unknown", transferStep: "private-stage", transferFailureKind: "private-kind", rpcCode: -32003,
} } })).toBeUndefined();
});
it("omits evidence from detached work after the RPC scope closes", async () => {
const error = new Error("private-message");
let resume!: () => void;
const gate = new Promise<void>(resolve => { resume = resolve; });
let detached!: Promise<unknown>;
await withEnvironmentSyncErrorCapture(async () => {
detached = withEnvironmentSyncTransferStep("archive_create", async () => { await gate; throw error; })
.catch(failure => environmentSyncErrorData(failure));
});
resume();
expect(await detached).toBeUndefined();
});
});
it("copies safe evidence into an existing wrapper only within its request", async () => {
const source = Object.freeze(Object.assign(new Error("private-message"), { status: 404, cause: { code: "EIO", token: "private-token" } }));
const wrapper = Object.freeze(new Error("stable-policy-message"));
await withEnvironmentSyncErrorCapture(async () => {
await expect(withEnvironmentSyncTransferStep("file_download", async () => { throw source; })).rejects.toBe(source);
expect(preserveEnvironmentSyncErrorDiagnostic(wrapper, source)).toBe(wrapper);
expect(readEnvironmentSyncErrorDiagnostic({ data: environmentSyncErrorData(wrapper) })).toEqual({
errorCode: "EIO", httpStatus: 404, transferStep: "file_download",
});
expect(wrapper).not.toHaveProperty("cause");
expect(wrapper.message).toBe("stable-policy-message");
// Reusing a wrapper for a later untyped failure must clear the earlier evidence.
preserveEnvironmentSyncErrorDiagnostic(wrapper, new Error("private-untyped-message"));
expect(environmentSyncErrorData(wrapper)).toBeUndefined();
});
expect(environmentSyncErrorData(wrapper)).toBeUndefined();
await withEnvironmentSyncErrorCapture(async () => {
preserveEnvironmentSyncErrorDiagnostic(wrapper, source);
expect(readEnvironmentSyncErrorDiagnostic({ data: environmentSyncErrorData(wrapper) })).toEqual({ errorCode: "EIO", httpStatus: 404 });
});
});
@@ -244,10 +244,10 @@ test("release verify workflow covers the same split test surface as stable PR ve
assert.match(buildJob, /persist-credentials: false/);
assert.doesNotMatch(buildJob, /cache: pnpm/);
for (const group of ["general-server-without-chat", "general-chat", "general-workspaces-a", "general-workspaces-b"]) {
for (const group of ["general-server-without-chat-or-native-runner", "general-chat", "general-workspaces-a", "general-workspaces-b"]) {
assert.match(verifyWorkflow, new RegExp(`group: ${group}`));
}
for (const [group, count] of [["general-server-without-chat", 10], ["general-chat", 3]]) {
for (const [group, count] of [["general-server-without-chat-or-native-runner", 10], ["general-chat", 3]]) {
const rows = [...verifyWorkflow.matchAll(new RegExp(`group: ${group}\\n\\s+group_label: [^\\n]+\\n\\s+shard_index: (\\d+)\\n\\s+shard_count: (\\d+)`, "g"))];
assert.deepEqual(rows.map((row) => [Number(row[1]), Number(row[2])]),
Array.from({ length: count }, (_, index) => [index, count]));
@@ -271,6 +271,30 @@ test("release verify workflow covers the same split test surface as stable PR ve
assert.match(verifyWorkflow, /pnpm test:run:serialized -- --shard-index/);
});
test("release verification builds native test binaries in a required Rust-cached lane", () => {
const workflow = readWorkflow("release-verify.yml");
const runnerJob = workflow.match(/ verify_paperclip_runner:\n[\s\S]*?(?=\n [A-Za-z0-9_-]+:|$)/)?.[0] ?? "";
assert.equal((runnerJob.match(/- lane: server-integration/g) ?? []).length, 1);
assert.doesNotMatch(runnerJob, /continue-on-error/);
assert.match(runnerJob, /timeout-minutes: 20/);
assert.match(runnerJob, /shared-key: release-runner-v2/);
assert.match(runnerJob, /cache-workspace-crates: false/);
assert.match(runnerJob, /cache-bin: false/);
assert.match(runnerJob, /save-if: \$\{\{ matrix\.lane == 'rust'/);
assert.match(runnerJob, /Build native server test binaries\n\s+if: \$\{\{ matrix\.lane == 'server-integration' \}\}\n\s+timeout-minutes: 10/);
assert.match(runnerJob, /pnpm build:rust\n\s+cargo build --release --manifest-path runner\/Cargo\.toml --locked -p paperclip-runner-core --bin paperclip-runnerd --bin fake-codex-app-server/);
assert.match(runnerJob, /Run native server integration suites\n\s+if: \$\{\{ matrix\.lane == 'server-integration' \}\}\n\s+run: pnpm test:run:general -- --group general-server-native-runner/);
assert.ok(runnerJob.indexOf("Cache Runner Rust dependencies") < runnerJob.indexOf("Build native server test binaries"));
assert.ok(runnerJob.indexOf("Build native server test binaries") < runnerJob.indexOf("Run native server integration suites"));
// The reusable workflow result includes every matrix child. Its caller must
// await that result without an override that can certify a failed native lane.
const cloud = readWorkflow("cloud-readiness.yml");
assert.match(cloud, /verify:[\s\S]*?uses: \.\/\.github\/workflows\/release-verify\.yml/);
assert.match(cloud, /source_verified:[\s\S]*?needs: \[verify\]/);
assert.doesNotMatch(cloud, /continue-on-error|always\(\)/);
});
test("Runner eval workflows pin actions and gate paid live execution", () => {
const actionPinWorkflows = [
readWorkflow("release-verify.yml"),
@@ -319,9 +319,8 @@ test("12 PR without-chat shards plus the dedicated chat and native-runner lanes
assert.ok(defaultRun.generalServerSuiteCount === full.generalServerSuiteCount);
});
// Mirrors release-verify.yml (10 shards, called by the Release and Cloud
// readiness workflows) and local runs: no Rust-cached vitest lane exists
// there, so the native-runner suite must stay in the server shards.
// The legacy group and local default must remain complete when a caller has
// not selected a separate native lane.
for (const [caller, envOverrides] of [["Release", { GITHUB_WORKFLOW: "Release" }], ["no ambient workflow", {}]]) {
test(`10 without-chat shards under ${caller} keep the native-runner suite and cover the server group with chat alone`, () => {
const full = dryRunJson(["--mode", "general", "--group", "general-server", "--shard-index", "0", "--shard-count", "1"], envOverrides);
@@ -339,6 +338,23 @@ for (const [caller, envOverrides] of [["Release", { GITHUB_WORKFLOW: "Release" }
});
}
// Release verification selects its dedicated lane explicitly. Do not infer it
// from the caller name: previews and future workflow callers need the same cover.
for (const caller of ["Release", "Cloud readiness", "another caller"]) {
test(`release server, chat, and native lanes cover every server suite once under ${caller}`, () => {
const env = { GITHUB_WORKFLOW: caller };
const full = dryRunJson(["--mode", "general", "--group", "general-server", "--shard-index", "0", "--shard-count", "1"], env);
const files = Array.from({ length: 10 }, (_, index) => dryRunJson([
"--mode", "general", "--group", "general-server-without-chat-or-native-runner",
"--shard-index", String(index), "--shard-count", "10",
], env)).flatMap((shard) => shard.selectedGeneralServerSuites);
const native = dryRunJson(["--mode", "general", "--group", "general-server-native-runner"], env);
const combined = [...files, chatSuitePath, ...native.selectedGeneralServerSuites];
assert.equal(new Set(combined).size, combined.length, "lanes must not overlap");
assert.deepEqual(combined.sort(), full.selectedGeneralServerSuites.sort());
});
}
test("the native-runner lane runs exactly the cargo-dependent suites", () => {
const lane = dryRunJson(["--mode", "general", "--group", "general-server-native-runner"]);
assert.deepEqual(lane.selectedGeneralServerSuites, [
+24 -20
View File
@@ -76,6 +76,7 @@ const generalModeName = "general";
const allModeName = "all";
const generalServerGroupName = "general-server";
const generalServerWithoutChatGroupName = "general-server-without-chat";
const generalServerWithoutChatOrNativeRunnerGroupName = "general-server-without-chat-or-native-runner";
const generalChatGroupName = "general-chat";
const generalServerNativeRunnerGroupName = "general-server-native-runner";
const chatSuite = "server/src/__tests__/chat-channels.integration.test.ts";
@@ -92,10 +93,9 @@ const nativeRunnerSuites = [
// In the PR workflow (pr.yml, the caller of pr-trusted.yml — reusable
// workflows inherit the caller's GITHUB_WORKFLOW), the last Verify Paperclip
// Runner vitest shard runs the native-runner group instead, because those
// lanes restore the shared release-runner-v1 Rust cache (see
// lanes restore the shared release-runner-v2 Rust cache (see
// packages/paperclip-runner/scripts/run-pr-vitest-lane.mjs). Every other
// caller — local runs, release-verify.yml under the Release and Cloud
// readiness workflows — keeps the suite in the server shards, so a renamed or
// caller of the same group keeps the suite in the server shards, so a renamed or
// unknown workflow degrades to today's slower-but-covered behavior rather
// than dropping the suite.
const prWorkflowName = "PR";
@@ -103,6 +103,18 @@ const nativeRunnerSuiteRunsInRustCachedLane = process.env.GITHUB_WORKFLOW === pr
const withoutChatExcludedSuites = nativeRunnerSuiteRunsInRustCachedLane
? [chatSuite, ...nativeRunnerSuites]
: [chatSuite];
// Release verification selects this explicit group and runs the omitted suites
// in its cached Runner job. Local callers keep the complete default group.
const generalServerGroups = [
generalServerGroupName,
generalServerWithoutChatGroupName,
generalServerWithoutChatOrNativeRunnerGroupName,
];
function excludedServerSuites(groupName) {
if (groupName === generalServerWithoutChatOrNativeRunnerGroupName) return [chatSuite, ...nativeRunnerSuites];
if (groupName === generalServerWithoutChatGroupName) return withoutChatExcludedSuites;
return [];
}
const generalWorkspacesAGroupName = "general-workspaces-a";
const generalWorkspacesBGroupName = "general-workspaces-b";
const generalWorkspacesAProjects = ["@paperclipai/ui", "paperclipai"];
@@ -111,6 +123,7 @@ const generalGroupNames = [generalServerGroupName, generalWorkspacesAGroupName,
const allowedGeneralGroupNames = [
...generalGroupNames,
generalServerWithoutChatGroupName,
generalServerWithoutChatOrNativeRunnerGroupName,
generalChatGroupName,
generalServerNativeRunnerGroupName,
];
@@ -290,7 +303,7 @@ function parseCliOptions(argv) {
const shardAllowed =
mode === serializedModeName ||
(mode === generalModeName &&
([generalServerGroupName, generalServerWithoutChatGroupName, generalChatGroupName, generalWorkspacesAGroupName].includes(group)));
([...generalServerGroups, generalChatGroupName, generalWorkspacesAGroupName].includes(group)));
if (!shardAllowed && shardIndex !== null) {
fail(
"--shard-index/--shard-count are only valid with serialized mode or a shardable general server/chat/workspaces-a group.",
@@ -448,14 +461,9 @@ function runGeneralGroup(routeTests, groupName, shardIndex = null, shardCount =
);
return;
}
if (groupName === generalServerGroupName || groupName === generalServerWithoutChatGroupName) {
// In the PR workflow the without-chat group also leaves the native-runner
// suite to the Rust-cached vitest lane; the full general-server group
// (local runs) keeps both.
const withoutChat = groupName === generalServerWithoutChatGroupName;
const files = withoutChat
? generalServerTestFiles.filter((file) => !withoutChatExcludedSuites.includes(file))
: generalServerTestFiles;
if (generalServerGroups.includes(groupName)) {
const excludedSuites = excludedServerSuites(groupName);
const files = generalServerTestFiles.filter((file) => !excludedSuites.includes(file));
if (shardCount !== null && shardCount > 1) {
const shardFiles = selectGeneralServerShard(
files,
@@ -483,10 +491,8 @@ function runGeneralGroup(routeTests, groupName, shardIndex = null, shardCount =
}
const excludeRouteArgs = routeTests.flatMap((file) => ["--exclude", file.serverPath]);
if (withoutChat) {
for (const suite of withoutChatExcludedSuites) {
excludeRouteArgs.push("--exclude", suite.replace(/^server\//, ""));
}
for (const suite of excludedSuites) {
excludeRouteArgs.push("--exclude", suite.replace(/^server\//, ""));
}
runVitest(
[
@@ -584,12 +590,10 @@ if (options.dryRun) {
options.mode === generalModeName && options.group === generalServerNativeRunnerGroupName
? nativeRunnerSuites
: options.mode === generalModeName &&
[generalServerGroupName, generalServerWithoutChatGroupName].includes(options.group) &&
generalServerGroups.includes(options.group) &&
options.shardCount !== null
? selectGeneralServerShard(
options.group === generalServerWithoutChatGroupName
? generalServerTestFiles.filter((file) => !withoutChatExcludedSuites.includes(file))
: generalServerTestFiles,
generalServerTestFiles.filter((file) => !excludedServerSuites(options.group).includes(file)),
options.shardIndex,
options.shardCount,
generalServerShardDurations,
+12 -2
View File
@@ -37,7 +37,7 @@ describe("durable Dot Runner integration", () => {
let root: string;
beforeAll(async () => {
const runnerRoot = fileURLToPath(new URL("../../../packages/paperclip-runner/", import.meta.url));
execFileSync("cargo", ["build", "--release", "--locked", "--manifest-path", join(runnerRoot, "runner/Cargo.toml"), "-p", "paperclip-runner-core", "--bin", "paperclip-runnerd"], { cwd: runnerRoot, stdio: "pipe", timeout: 300000 });
execFileSync("cargo", ["build", "--release", "--locked", "--manifest-path", join(runnerRoot, "runner/Cargo.toml"), "-p", "paperclip-runner-core", "--bin", "paperclip-runnerd"], { cwd: runnerRoot, stdio: "inherit", timeout: 300000 });
temporary = await startEmbeddedPostgresTestDatabase("paperclip-dot-runner-");
db = createDb(temporary.connectionString);
root = await mkdtemp(join(tmpdir(), "paperclip-dot-state-"));
@@ -47,7 +47,17 @@ describe("durable Dot Runner integration", () => {
vi.stubEnv("PAPERCLIP_SECRETS_MASTER_KEY", randomBytes(32).toString("base64"));
await instanceSettingsService(db).updateExperimental({ enablePublicMcp: true, enableOpenAiDot: true, enableNativeRunner: false });
}, 360000);
afterAll(async () => { await temporary?.cleanup(); await rm(root, { recursive: true, force: true }); vi.unstubAllEnvs(); });
afterAll(async () => {
try {
await temporary?.cleanup();
} finally {
try {
if (root) await rm(root, { recursive: true, force: true });
} finally {
vi.unstubAllEnvs();
}
}
});
it("requires each persisted prerequisite for pairing and new work without relying on the retired environment flag", async () => {
const settings = instanceSettingsService(db);
@@ -3,6 +3,7 @@ import { PassThrough } from "node:stream";
import { describe, expect, it } from "vitest";
import {
createRequest, definePlugin, isJsonRpcResponse, JsonRpcCallError, parseMessage,
readEnvironmentSyncErrorDiagnostic, withEnvironmentSyncTransferStep, recordEnvironmentSyncError,
PLUGIN_RPC_ERROR_CODES, serializeMessage, startWorkerRpcHost, type JsonRpcResponse,
} from "@paperclipai/plugin-sdk";
import {
@@ -53,8 +54,9 @@ describe("environment sync-out restore diagnostics", () => {
await expect(withWorkspaceRestoreDiagnostics("workspace", () => withWorkspaceRestoreStep("workspace_transfer", async () => {
throw preserveEnvironmentSyncOutErrorDiagnostic(error);
}), async (line) => { logs.push(line); })).rejects.toBe(error);
expect(getWorkspaceRestoreDiagnostic(error)).toEqual({ phase: "workspace", step: "workspace_transfer", errorCode: "ETIMEDOUT", httpStatus: 504 });
expect(logs).toEqual(['[paperclip] Workspace restore diagnostic: {"phase":"workspace","step":"workspace_transfer","errorCode":"ETIMEDOUT","httpStatus":504}\n']);
expect(getWorkspaceRestoreDiagnostic(error)).toEqual({ phase: "workspace", step: "workspace_transfer", errorCode: "ETIMEDOUT", httpStatus: 504, rpcCode: PLUGIN_RPC_ERROR_CODES.WORKER_ERROR });
expect(logs).toHaveLength(1);
expect(JSON.parse(logs[0].slice(logs[0].indexOf("{")))).toEqual(getWorkspaceRestoreDiagnostic(error));
expect({ message: error.message, code: error.code, data: error.data }).toEqual(before);
expect(error).not.toHaveProperty("cause");
});
@@ -76,3 +78,69 @@ describe("environment sync-out restore diagnostics", () => {
expect(preserveEnvironmentSyncOutErrorDiagnostic(null)).toBeNull();
});
});
it("isolates overlapping and sequential worker calls that throw the same object", async () => {
const original = Object.freeze(new Error("Transfer failed"));
const before = Object.getOwnPropertyDescriptors(original);
const stdin = new PassThrough();
const stdout = new PassThrough();
const lines = createInterface({ input: stdout });
let release!: () => void;
const gate = new Promise<void>(resolve => { release = resolve; });
let started!: () => void;
const ready = new Promise<void>(resolve => { started = resolve; });
const worker = startWorkerRpcHost({ stdin, stdout, plugin: definePlugin({
async setup() {},
async onEnvironmentSyncOut(params) {
if (params.operations?.[0]?.operationId === "first") {
return withEnvironmentSyncTransferStep("archive_create", async () => {
recordEnvironmentSyncError(original, { exitCode: 2, transferFailureKind: "command_failed" });
started(); await gate; throw original;
});
}
if (params.operations?.[0]?.operationId === "second") {
return withEnvironmentSyncTransferStep("file_download", async () => { throw original; });
}
throw original;
},
async onEnvironmentSyncIn() { throw original; },
}) });
const call = (id: string, method = "environmentSyncOut") => new Promise<JsonRpcCallError>((resolve) => {
const listener = (wire: string) => {
const message = parseMessage(wire);
if (isJsonRpcResponse(message) && message.id === id && message.error) {
lines.off("line", listener); resolve(new JsonRpcCallError(message.error));
}
};
lines.on("line", listener);
stdin.write(serializeMessage(createRequest(method, { operations: [{ operationId: id, files: [] }] }, id)));
});
try {
const first = call("first");
await ready;
const second = await call("second");
expect(readEnvironmentSyncErrorDiagnostic(second)).toEqual({ errorCode: "unknown", transferStep: "file_download" });
release();
expect(readEnvironmentSyncErrorDiagnostic(await first)).toEqual({
errorCode: "unknown", transferStep: "archive_create", transferFailureKind: "command_failed", exitCode: 2,
});
expect((await call("third")).data).toBeUndefined();
expect((await call("unrelated", "environmentSyncIn")).data).toBeUndefined();
expect(Object.getOwnPropertyDescriptors(original)).toEqual(before);
} finally { release(); worker.stop(); lines.close(); stdin.destroy(); stdout.destroy(); }
});
it.each([
[new JsonRpcCallError({ code: PLUGIN_RPC_ERROR_CODES.TIMEOUT, message: "private-rpc-message" }), PLUGIN_RPC_ERROR_CODES.TIMEOUT],
[new JsonRpcCallError({ code: 123, message: "private-rpc-message" }), undefined],
[Object.assign(new Error("private-rpc-message"), { code: PLUGIN_RPC_ERROR_CODES.TIMEOUT }), undefined],
])("records known typed RPC codes without interpreting arbitrary errors", async (error, rpcCode) => {
const logs: string[] = [];
await expect(withWorkspaceRestoreDiagnostics("workspace", () => withWorkspaceRestoreStep("workspace_transfer", async () => {
throw preserveEnvironmentSyncOutErrorDiagnostic(error);
}), async line => { logs.push(line); })).rejects.toBe(error);
expect(getWorkspaceRestoreDiagnostic(error)?.rpcCode).toBe(rpcCode);
expect(getWorkspaceRestoreDiagnostic(error)?.transferStep).toBeUndefined();
expect(JSON.stringify(logs)).not.toContain("private-");
});
@@ -0,0 +1,92 @@
import { mkdtemp, readFile, rm, symlink, utimes, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { gzipSync } from "node:zlib";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { sql } from "drizzle-orm";
import { createDb, ensurePostgresDatabase, getEmbeddedPostgresTestSupport, runDatabaseBackup, runDatabaseRestore, startEmbeddedPostgresTestDatabase } from "@paperclipai/db";
import { clearIdleBackupWakeRequired, idleBackupWakeMarker, markIdleBackupWakeRequired, verifyIdleDatabaseBackup } from "../services/idle-database-backup.js";
let directory: string;
beforeEach(async () => { directory = await mkdtemp(path.join(os.tmpdir(), "idle-backup-")); });
afterEach(async () => { await rm(directory, { recursive: true, force: true }); });
describe("idle database backup persistence", () => {
it("keeps a restart marker until a fresh valid backup is complete", async () => {
const archive = path.join(directory, "backup.sql.gz");
await markIdleBackupWakeRequired(directory);
await writeFile(archive, gzipSync("CREATE TABLE fixture (id integer);\n"));
await verifyIdleDatabaseBackup(archive);
// Verification on the sleep path must not remove the wake obligation.
expect(await readFile(idleBackupWakeMarker(directory), "utf8")).toContain("restarts");
await clearIdleBackupWakeRequired(directory);
await expect(readFile(idleBackupWakeMarker(directory))).rejects.toMatchObject({ code: "ENOENT" });
expect(await readFile(archive)).toEqual(gzipSync("CREATE TABLE fixture (id integer);\n"));
});
it.each([
["empty file", Buffer.alloc(0)], ["empty SQL", gzipSync("")],
["truncated gzip", gzipSync("SELECT 1;").subarray(0, -4)],
["plain text", Buffer.from("SELECT 1;")],
])("rejects %s and retains the restart obligation", async (_label, contents) => {
await markIdleBackupWakeRequired(directory);
const archive = path.join(directory, "invalid.sql.gz");
await writeFile(archive, contents);
await expect(verifyIdleDatabaseBackup(archive)).rejects.toThrow();
expect(await readFile(idleBackupWakeMarker(directory), "utf8")).toContain("restarts");
});
it("rejects a missing archive and symlinked archive or marker", async () => {
const archive = path.join(directory, "backup.sql.gz");
await expect(verifyIdleDatabaseBackup(archive)).rejects.toThrow();
const target = path.join(directory, "target");
await writeFile(target, gzipSync("SELECT 1;"));
await symlink(target, archive);
await expect(verifyIdleDatabaseBackup(archive)).rejects.toThrow();
await symlink(target, idleBackupWakeMarker(directory));
await expect(markIdleBackupWakeRequired(directory)).rejects.toThrow();
expect(await readFile(target)).toEqual(gzipSync("SELECT 1;"));
});
});
const support = await getEmbeddedPostgresTestSupport();
describe.skipIf(!support.supported)("idle backup restore", () => {
it("restores the last pre-sleep state into a separate database", async () => {
const source = await startEmbeddedPostgresTestDatabase("idle-backup-restore-");
const sourceDb = createDb(source.connectionString);
const targetUrl = new URL(source.connectionString);
targetUrl.pathname = "/idle_restore";
try {
await sourceDb.execute(sql`CREATE TABLE backup_fixture (id integer PRIMARY KEY, value text NOT NULL)`);
await sourceDb.execute(sql`INSERT INTO backup_fixture VALUES (1, 'last committed state')`);
await markIdleBackupWakeRequired(directory);
const result = await runDatabaseBackup({
connectionString: source.connectionString, backupDir: directory,
retention: { dailyDays: 14, weeklyWeeks: 4, monthlyMonths: 6 },
verifyBeforePrune: verifyIdleDatabaseBackup,
});
await verifyIdleDatabaseBackup(result.backupFile);
await ensurePostgresDatabase(source.connectionString, "idle_restore");
await runDatabaseRestore({ connectionString: targetUrl.toString(), backupFile: result.backupFile });
const target = createDb(targetUrl.toString());
expect(await target.execute(sql`SELECT id, value FROM backup_fixture`)).toEqual([{ id: 1, value: "last committed state" }]);
const checkpointBytes = await readFile(result.backupFile);
expect(await readFile(idleBackupWakeMarker(directory), "utf8")).toContain("restarts");
// A failed verification must not delete existing recovery points, even
// when their age would make them eligible for the configured pruning.
const history = path.join(directory, "paperclip-history.sql.gz");
const historyBytes = gzipSync("SELECT 'historical recovery point';");
await writeFile(history, historyBytes);
await utimes(history, new Date(0), new Date(0));
await expect(runDatabaseBackup({
connectionString: source.connectionString, backupDir: directory,
backupEngine: "javascript", retention: { dailyDays: 7, weeklyWeeks: 4, monthlyMonths: 1 },
verifyBeforePrune: async () => { throw new Error("verification failed"); },
})).rejects.toThrow("verification failed");
expect(await readFile(history)).toEqual(historyBytes);
expect(await readFile(result.backupFile)).toEqual(checkpointBytes);
} finally {
await source.cleanup();
}
}, 60_000);
});
@@ -40,10 +40,12 @@ describe("idle cleanup inspection", () => {
expect(await readIdleLocalWork()).toBe("none");
markIdleStartupComplete({ scheduledBackups: true });
expect(await readIdleLocalWork()).toBe("present");
expect(await readIdleLocalWork({ backupCheckpoint: true })).toBe("none");
markIdleStartupComplete({ scheduledBackups: false });
await fs.mkdir(custom);
await fs.writeFile(path.join(custom, "receipt.tmp"), "incomplete");
expect(await readIdleLocalWork()).toBe("present");
expect(await readIdleLocalWork({ backupCheckpoint: true })).toBe("present");
await fs.rm(custom, { recursive: true });
expect(await readIdleLocalWork()).toBe("none");
});
+71 -4
View File
@@ -1,4 +1,4 @@
import { beginIdleTrackedWork } from "../services/task-admission.js";
import { beginIdleTrackedWork, idleWorkSnapshot } from "../services/task-admission.js";
import { randomUUID } from "node:crypto";
import { eq, sql } from "drizzle-orm";
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
@@ -22,6 +22,73 @@ const ownerId = "d0b833f4-4098-42de-8420-1907f3aa4895";
const owned = () => ({ ...held(), ownerId });
const emptyLocal = async () => "none" as const;
describe("idle backup checkpoint", () => {
const database = (blocked = false) => {
const transaction = vi.fn(async (run: (tx: unknown) => Promise<unknown>) => run({ execute: async () => [{ blocked }] }));
return { db: { transaction } as unknown as Db, transaction };
};
const backup = (work: () => Promise<boolean> = async () => true) => vi.fn(async () => {
const finish = beginIdleTrackedWork();
try { return await work(); } finally { finish(); }
});
it("backs up after durable inspection and rechecks local work before authorizing sleep", async () => {
const { db, transaction } = database();
const local = vi.fn(emptyLocal);
const checkpoint = backup(async () => {
expect(transaction).toHaveBeenCalledOnce();
expect(idleWorkSnapshot().active).toBe(1);
return true;
});
expect(await readIdleSleepSafety(db, owned, () => now, ownerId, local, undefined, checkpoint)).toEqual(none);
expect(checkpoint).toHaveBeenCalledOnce();
expect(local).toHaveBeenCalledTimes(2);
});
it.each(["present", "unknown"] as const)("avoids DB probes and backups for known local %s work", async state => {
const { db, transaction } = database();
const checkpoint = backup();
const plugins = vi.fn();
expect(await readIdleSleepSafety(db, owned, () => now, ownerId, async () => state, plugins, checkpoint))
.toEqual({ version: 1, backgroundWork: state });
expect(transaction).not.toHaveBeenCalled();
expect(plugins).not.toHaveBeenCalled();
expect(checkpoint).not.toHaveBeenCalled();
});
it("does not start a backup for durable work or an unowned hold", async () => {
const checkpoint = backup();
expect(await readIdleSleepSafety(database(true).db, owned, () => now, ownerId, emptyLocal, undefined, checkpoint)).toEqual(present);
expect(await readIdleSleepSafety(database().db, owned, () => now, "stale", emptyLocal, undefined, checkpoint)).toEqual(unknown);
expect(checkpoint).not.toHaveBeenCalled();
});
it.each(["failed", "threw", "untracked", "concurrent", "expired", "replaced"])("keeps the instance awake when the backup is %s", async kind => {
let clock = now;
let status = owned();
const work = async () => {
if (kind === "threw") throw new Error("private archive path");
if (kind === "concurrent") { const finish = beginIdleTrackedWork(); finish(); }
if (kind === "expired") clock += 60_000;
if (kind === "replaced") status = { ...status, ownerId: "replacement" };
return kind !== "failed";
};
const checkpoint = kind === "untracked" ? work : backup(work);
expect(await readIdleSleepSafety(database().db, () => status, () => clock, ownerId, emptyLocal, undefined, checkpoint)).toEqual(unknown);
expect(idleWorkSnapshot().active).toBe(0);
});
it("retains actual backup work after the owning hold expires", async () => {
let finishBackup!: () => void;
const pending = new Promise<void>(resolve => { finishBackup = resolve; });
const checkpoint = backup(async () => { await pending; return true; });
let clock = now;
const scan = readIdleSleepSafety(database().db, owned, () => clock, ownerId, emptyLocal, undefined, checkpoint);
await vi.waitFor(() => expect(checkpoint).toHaveBeenCalledOnce());
clock += 60_000;
expect(idleWorkSnapshot().active).toBe(1);
expect(await readIdleSleepSafety(database().db, owned, () => clock, ownerId, emptyLocal)).toEqual(unknown);
finishBackup();
expect(await scan).toEqual(unknown);
expect(idleWorkSnapshot().active).toBe(0);
});
});
describe("idle sleep safety failure boundaries", () => {
it.each([
@@ -36,7 +103,7 @@ describe("idle sleep safety failure boundaries", () => {
it("fails closed when the database is unavailable", async () => {
const db = { transaction: vi.fn().mockRejectedValue(new Error("private connection detail")) } as unknown as Db;
expect(await readIdleSleepSafety(db, held, () => now)).toEqual(unknown);
expect(await readIdleSleepSafety(db, held, () => now, undefined, emptyLocal)).toEqual(unknown);
});
it.each([
@@ -46,14 +113,14 @@ describe("idle sleep safety failure boundaries", () => {
const getStatus = vi.fn().mockReturnValueOnce(held()).mockReturnValue({ ...held(), ...change });
const execute = vi.fn().mockResolvedValue([{ blocked: true }]);
const transaction = vi.fn(async (fn: (tx: unknown) => Promise<unknown>) => fn({ execute }));
expect(await readIdleSleepSafety({ transaction } as unknown as Db, getStatus, () => now)).toEqual(unknown);
expect(await readIdleSleepSafety({ transaction } as unknown as Db, getStatus, () => now, undefined, emptyLocal)).toEqual(unknown);
expect(transaction).toHaveBeenCalledWith(expect.any(Function), { isolationLevel: "repeatable read", accessMode: "read only" });
});
it("reports persisted work when the admission hold remains unchanged", async () => {
const execute = vi.fn().mockResolvedValue([{ blocked: true }]);
const transaction = vi.fn(async (fn: (tx: unknown) => Promise<unknown>) => fn({ execute }));
expect(await readIdleSleepSafety({ transaction } as unknown as Db, held, () => now)).toEqual(present);
expect(await readIdleSleepSafety({ transaction } as unknown as Db, held, () => now, undefined, emptyLocal)).toEqual(present);
});
});
@@ -77,7 +77,7 @@ describe("instance settings routes", () => {
return { errorHandler, instanceSettingsRoutes };
});
function createApp(actor: any, pluginWorkers?: PluginWorkerManager) {
function createApp(actor: any, pluginWorkers?: PluginWorkerManager, prepareBackup?: () => Promise<boolean>) {
const { errorHandler, instanceSettingsRoutes } = routeModules.value;
const app = express();
app.use(express.json());
@@ -85,7 +85,7 @@ describe("instance settings routes", () => {
req.actor = actor;
next();
});
app.use("/api", instanceSettingsRoutes(mockDb as any, pluginWorkers));
app.use("/api", instanceSettingsRoutes(mockDb as any, pluginWorkers, prepareBackup));
app.use(errorHandler);
return app;
}
@@ -973,7 +973,7 @@ describe("instance settings routes", () => {
await request(createApp(adminActor)).post("/api/instance/task-drain").send({ purpose: "idle", ttlMs: 60_000 }).expect(200);
expect(mockHeartbeatService.computeTaskDrain).toHaveBeenCalledWith({ purpose: "idle", ttlMs: 60_000 });
await request(createApp(adminActor)).get(`/api/instance/task-drain?idleSleepSafety=1&ownerId=${ownerId}`).expect(200);
expect(mockReadIdleSleepSafety).toHaveBeenLastCalledWith(mockDb, expect.any(Function), Date.now, ownerId, undefined, undefined);
expect(mockReadIdleSleepSafety).toHaveBeenLastCalledWith(mockDb, expect.any(Function), Date.now, ownerId, expect.any(Function), undefined, undefined);
});
it.each([{}, { ttlMs: null }, { ttlMs: 1 }, { ttlMs: 300_001 }])("rejects an unbounded or invalid idle TTL: %j", async body => {
@@ -1000,7 +1000,7 @@ describe("instance settings routes", () => {
const res = await request(createApp(adminActor)).get("/api/instance/task-drain?idleSleepSafety=1");
expect(res.status).toBe(200);
expect(res.body).toEqual({ ...idleStatus, idleSleepSafety: report });
expect(mockReadIdleSleepSafety).toHaveBeenCalledWith(mockDb, expect.any(Function), Date.now, undefined, undefined, undefined);
expect(mockReadIdleSleepSafety).toHaveBeenCalledWith(mockDb, expect.any(Function), Date.now, undefined, expect.any(Function), undefined, undefined);
expect(mockReadIdleSleepSafety.mock.calls[0][1]()).toEqual(idleStatus);
});
@@ -1028,6 +1028,42 @@ describe("instance settings routes", () => {
expect(release).toHaveBeenCalledOnce();
});
it("passes the operator backup hook only through the protected safety report", async () => {
const backup = vi.fn(async () => true);
mockHeartbeatService.getTaskDrainStatus.mockReturnValue(idleStatus);
const app = createApp(adminActor, undefined, backup);
await request(app).get("/api/instance/task-drain").expect(200);
expect(mockReadIdleSleepSafety).not.toHaveBeenCalled();
await request(app).get("/api/instance/task-drain?idleSleepSafety=1&ownerId=fixture-owner").expect(200);
expect(mockReadIdleSleepSafety).toHaveBeenCalledWith(mockDb, expect.any(Function), Date.now,
"fixture-owner", expect.any(Function), undefined, backup);
mockReadIdleSleepSafety.mockClear();
await request(createApp(nonAdminActor, undefined, backup)).get("/api/instance/task-drain?idleSleepSafety=1").expect(403);
expect(mockReadIdleSleepSafety).not.toHaveBeenCalled();
expect(backup).not.toHaveBeenCalled();
});
it("reserves managed checkpoint creation for verified Cloud control actors", async () => {
const backup = vi.fn(async () => true);
process.env.PAPERCLIP_CLOUD_TENANT_SERVER_TOKEN = "fixture-managed-signal";
mockHeartbeatService.getTaskDrainStatus.mockReturnValue(idleStatus);
try {
for (const source of ["session", "cloud_tenant", "local_implicit"]) {
const tenantAdmin = createApp({ ...adminActor, source }, undefined, backup);
await request(tenantAdmin).get("/api/instance/task-drain?idleSleepSafety=1&ownerId=fixture-owner")
.set("x-paperclip-cloud-control", "unverified-header").expect(403);
}
expect(mockReadIdleSleepSafety).not.toHaveBeenCalled();
expect(backup).not.toHaveBeenCalled();
const control = createApp({ ...adminActor, source: "cloud_control" }, undefined, backup);
await request(control).get("/api/instance/task-drain?idleSleepSafety=1&ownerId=fixture-owner").expect(200);
expect(mockReadIdleSleepSafety).toHaveBeenCalledWith(mockDb, expect.any(Function), Date.now,
"fixture-owner", expect.any(Function), undefined, backup);
} finally {
delete process.env.PAPERCLIP_CLOUD_TENANT_SERVER_TOKEN;
}
});
it.each([
["company member", nonAdminActor],
["agent", { type: "agent", agentId: "agent-1", companyId: "company-1" }],
@@ -378,4 +378,64 @@ describe.skipIf(!sentryPackage)("run failure context with the real Sentry SDK",
expect(unrelated).not.toHaveProperty("contexts.run_failure");
expect(unrelated).not.toHaveProperty("tags.error_code");
});
it("sends only validated transfer and RPC evidence, isolated from unrelated captures", async () => {
const Sentry = sentryPackage!;
const events: Array<Record<string, unknown>> = [];
vi.stubEnv("SENTRY_DSN_BACKEND", "https://public@example.invalid/1");
vi.doMock("../peer-version-check.js", () => ({ checkExactPeerVersions: () => ({ ok: true }) }));
vi.doMock("@sentry/node", () => ({ ...Sentry,
init: (options: Record<string, unknown>) => Sentry.init({ ...options,
transport: () => ({ send: async () => ({}), flush: async () => true }),
beforeSend: (event: Record<string, unknown>) => { events.push(event); return event; },
}),
}));
vi.resetModules();
const { sentryReady, captureRunFailure, captureException } = await import("../sentry.js");
await sentryReady;
for (const known of [true, false]) {
const error = Object.freeze(new Error("private-transfer-message"));
// Worker/typed-host propagation has its own real RPC round-trip test.
// This narrow contract exercises the host receipt through the real Sentry
// transport without requiring plugin builds or dependency lifecycle scripts.
const evidence = {
transferStep: known ? "archive_create" : "private-transfer-path",
transferFailureKind: known ? "command_failed" : "private-transfer-command",
rpcCode: known ? -32002 : -12345,
cause: { token: "private-transfer-token" }, output: "private-transfer-output",
};
const logs: string[] = [];
const restore = createWorkspaceRestoreTeardown({ stagedRuntime: {
restoreWorkspace: progress => withWorkspaceRestoreDiagnostics("workspace", () =>
withWorkspaceRestoreStep("workspace_transfer", async () => { throw preserveWorkspaceRestoreErrorDiagnostic(error, { exitCode: 2 }, evidence); }), progress),
}, onLog: async (_stream, line) => { logs.push(line); }, startMessage: "Restoring workspace\n", failurePrefix: "Workspace restore failed" });
const outcome = await restore();
expect(outcome.ok).toBe(false);
if (outcome.ok) throw new Error("Expected transfer fixture failure");
expect(outcome.diagnostic?.rpcCode).toBe(known ? -32002 : undefined);
expect(JSON.stringify({ outcome, logs })).not.toContain("private-transfer-");
const diagnostics = sanitizeRunFailureDiagnostics(collectRunFailureDiagnostics({ resultJson: {
workspaceRestoreFailure: outcome.code, workspaceRestoreDiagnostic: { ...outcome.diagnostic,
message: "private-transfer-persisted", cause: { token: "private-transfer-token" },
},
} } as unknown as typeof heartbeatRuns.$inferSelect, {}));
captureRunFailure({ taskId: "11111111-1111-4111-8111-111111111111", runId: "22222222-2222-4222-8222-222222222222",
errorMessage: "Workspace restore failed. Workspace files need recovery.", errorCode: "workspace_restore_failed",
agentAdapter: "fixture-adapter", runStatus: "failed", exitCode: 1, signal: null, diagnostics,
});
await Sentry.flush(2000);
const execution = (events.at(-1)?.contexts as Record<string, Record<string, unknown>>).run_execution;
expect(execution.workspaceRestoreTransferStep).toBe(known ? "archive_create" : undefined);
expect(execution.workspaceRestoreTransferFailureKind).toBe(known ? "command_failed" : undefined);
expect(execution.workspaceRestoreRpcCode).toBe(known ? -32002 : undefined);
expect(execution.workspaceRestoreExitCode).toBe(2);
}
captureException(new Error("Unrelated transfer fixture"));
await Sentry.flush(2000);
expect(events).toHaveLength(3);
expect(events[2]).not.toHaveProperty("contexts.run_execution");
expect(events[2]).not.toHaveProperty("contexts.run_failure");
expect(events[2]).not.toHaveProperty("tags.error_code");
expect(JSON.stringify(events)).not.toContain("private-transfer-");
});
});
@@ -1,9 +1,12 @@
import { idleWorkSnapshot } from "../services/task-admission.js";
import { readIdleSleepSafety } from "../services/idle-sleep-safety.js";
import * as idleLocalWork from "../services/idle-local-work.js";
import { idleBackupWakeMarker, markIdleBackupWakeRequired } from "../services/idle-database-backup.js";
import { reconcileBuiltInAgentsOnStartup, reconcileCodexLocalManagedHomesOnStartup, reconcilePersistedRuntimeServicesOnStartup } from "../services/index.js";
import { runDatabaseBackup } from "@paperclipai/db";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, symlinkSync, writeFileSync } from "node:fs";
import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, symlinkSync, writeFileSync } from "node:fs";
import { gzipSync } from "node:zlib";
import { tmpdir } from "node:os";
import path from "node:path";
@@ -441,6 +444,69 @@ describe("startServer feedback export wiring", () => {
process.env.BETTER_AUTH_SECRET = "test-secret";
});
it.each([false, true].flatMap(checkpointMode => [false, true].map(fails => ({ checkpointMode, fails }))))
("finishes restart backup before idle eligibility (checkpoint=$checkpointMode, failure=$fails)", async ({ checkpointMode, fails }) => {
const directory = mkdtempSync(path.join(tmpdir(), "startup-idle-backup-"));
const previousFlag = process.env.PAPERCLIP_DB_BACKUP_IDLE_CHECKPOINT_ENABLED;
process.env.PAPERCLIP_DB_BACKUP_IDLE_CHECKPOINT_ENABLED = checkpointMode ? "1" : "0";
loadConfigMock.mockReturnValue(buildTestConfig({ databaseBackupEnabled: true, databaseBackupDir: directory }));
const eligible = vi.spyOn(idleLocalWork, "markIdleStartupComplete");
let retry: (() => void) | undefined;
const timers = vi.spyOn(globalThis, "setInterval").mockImplementation(((callback: () => void, ms: number) => {
if (ms === 60 * 60 * 1000) retry = callback;
return 1 as unknown as ReturnType<typeof setInterval>;
}) as typeof setInterval);
let finish!: () => void;
const pending = new Promise<void>(resolve => { finish = resolve; });
const writeBackup = async (options: Parameters<typeof runDatabaseBackup>[0]) => {
const backupFile = path.join(directory, "paperclip-restart.sql.gz");
const bytes = gzipSync("SELECT 'restart recovery';\n");
writeFileSync(backupFile, bytes);
expect(options.retention).toEqual({ dailyDays: 7, weeklyWeeks: 4, monthlyMonths: 1 });
expect(options.verifyBeforePrune).toEqual(expect.any(Function));
await options.verifyBeforePrune!(backupFile);
return { backupFile, sizeBytes: bytes.length, prunedCount: 0 };
};
vi.mocked(runDatabaseBackup).mockImplementationOnce(async options => {
await pending;
if (fails) throw new Error("fixture restart backup failed");
return writeBackup(options);
});
let starting: ReturnType<typeof startServer> | undefined;
try {
await markIdleBackupWakeRequired(directory);
starting = startServer();
await vi.waitFor(() => expect(runDatabaseBackup).toHaveBeenCalledOnce());
expect(eligible).not.toHaveBeenCalled();
expect(idleWorkSnapshot().active).toBeGreaterThan(0);
finish();
await starting;
expect(eligible).toHaveBeenCalledWith({ scheduledBackups: true });
const options = createAppMock.mock.calls[0]?.[1] as unknown as { prepareIdleDatabaseBackup?: unknown };
expect(Boolean(options.prepareIdleDatabaseBackup)).toBe(checkpointMode);
expect(existsSync(idleBackupWakeMarker(directory))).toBe(fails);
if (fails) {
vi.mocked(runDatabaseBackup).mockImplementationOnce(writeBackup);
expect(retry).toEqual(expect.any(Function));
const activeBeforeRetry = idleWorkSnapshot().active;
retry!();
await vi.waitFor(() => {
expect(existsSync(idleBackupWakeMarker(directory))).toBe(false);
// Marker removal precedes directory fsync and release of the work receipt.
expect(idleWorkSnapshot().active).toBe(activeBeforeRetry);
});
expect(runDatabaseBackup).toHaveBeenCalledTimes(2);
}
} finally {
finish();
await starting;
timers.mockRestore(); eligible.mockRestore();
if (previousFlag === undefined) delete process.env.PAPERCLIP_DB_BACKUP_IDLE_CHECKPOINT_ENABLED;
else process.env.PAPERCLIP_DB_BACKUP_IDLE_CHECKPOINT_ENABLED = previousFlag;
rmSync(directory, { recursive: true, force: true });
}
});
it.each(([
["built-in agents", reconcileBuiltInAgentsOnStartup],
["managed homes", reconcileCodexLocalManagedHomesOnStartup],
+2 -1
View File
@@ -491,6 +491,7 @@ export async function createApp(
}): Promise<unknown>;
};
databaseBackupService?: InstanceDatabaseBackupService;
prepareIdleDatabaseBackup?: () => Promise<boolean>;
databaseBackupHealth?: InspectDatabaseBackupHealthOptions;
deploymentMode: DeploymentMode;
deploymentExposure: DeploymentExposure;
@@ -836,7 +837,7 @@ export async function createApp(
api.use(resourceMembershipRoutes(db));
api.use(primaryAgentRoutes(db));
api.use(inboxDismissalRoutes(db));
api.use(instanceSettingsRoutes(db, workerManager));
api.use(instanceSettingsRoutes(db, workerManager, opts.prepareIdleDatabaseBackup));
if (opts.databaseBackupService) {
api.use(instanceDatabaseBackupRoutes(opts.databaseBackupService));
}
+19 -1
View File
@@ -1,5 +1,6 @@
import { isIdleTaskDrainActive, beginIdleTrackedWork, trackIdleWork } from "./services/task-admission.js";
import { markIdleStartupComplete } from "./services/idle-local-work.js";
import { clearIdleBackupWakeRequired, idleBackupWakeMarker, markIdleBackupWakeRequired, verifyIdleDatabaseBackup } from "./services/idle-database-backup.js";
import { cloudWarmStandbyServerOptions } from "./middleware/cloud-warm-standby.js";
import { createCloudWarmStandby } from "./services/cloud-warm-standby.js";
import { subscribeAllCompanyLiveEvents } from "./services/live-events.js";
@@ -819,6 +820,8 @@ async function startServerWithDatabaseTeardown(
resolve(config.databaseBackupDir, "..", "db-backup-to-s3.failure"),
];
let databaseBackupInFlight = false;
const idleDatabaseBackupEnabled = config.databaseBackupEnabled
&& process.env.PAPERCLIP_DB_BACKUP_IDLE_CHECKPOINT_ENABLED === "1";
const runServerDatabaseBackup = async (
trigger: InstanceDatabaseBackupTrigger,
): Promise<InstanceDatabaseBackupRunResult | null> => {
@@ -835,19 +838,25 @@ async function startServerWithDatabaseTeardown(
const finishIdleBackup = beginIdleTrackedWork();
const startedAt = new Date();
const startedAtMs = Date.now();
const label = trigger === "scheduled" ? "Automatic" : "Manual";
const label = trigger === "manual" ? "Manual" : trigger === "idle" ? "Pre-sleep" : "Automatic";
try {
if (trigger === "idle") await markIdleBackupWakeRequired(config.databaseBackupDir);
logger.info({ backupDir: config.databaseBackupDir, trigger }, `${label} database backup starting`);
// Read retention from Instance Settings (DB) so changes take effect without restart.
const generalSettings = await backupSettingsSvc.getGeneral();
const retention = generalSettings.backupRetention;
const verifyCheckpoint = trigger === "idle" || existsSync(idleBackupWakeMarker(config.databaseBackupDir));
const result = await runDatabaseBackup({
connectionString: activeDatabaseConnectionString,
backupDir: config.databaseBackupDir,
retention,
filenamePrefix: "paperclip",
verifyBeforePrune: verifyCheckpoint ? verifyIdleDatabaseBackup : undefined,
});
if (verifyCheckpoint) {
if (trigger !== "idle") await clearIdleBackupWakeRequired(config.databaseBackupDir);
}
const finishedAt = new Date();
const response: InstanceDatabaseBackupRunResult = {
...result,
@@ -892,6 +901,9 @@ async function startServerWithDatabaseTeardown(
// self-hosted: createApp falls back to its built-in kubernetes-only default.
const managedPluginAutoInstall = managedConfig?.plugins.autoInstall ?? null;
const app = await createApp(db as any, {
prepareIdleDatabaseBackup: idleDatabaseBackupEnabled
? async () => (await runServerDatabaseBackup("idle")) !== null
: undefined,
cloudWarmStandby: isWarmStandby,
uiMode,
serverPort: listenPort,
@@ -1872,6 +1884,12 @@ async function startServerWithDatabaseTeardown(
},
"Automatic database backups enabled",
);
// The final pre-sleep dump remains on the volume throughout sleep. Take
// a fresh backup on restart before idle eligibility is enabled, including
// after rollback of the opt-in flag. Failure retains the marker for retry.
if (!isWarmStandby() && existsSync(idleBackupWakeMarker(config.databaseBackupDir))) {
await runServerDatabaseBackup("scheduled").catch(() => {});
}
setInterval(() => {
if (isWarmStandby() || isIdleTaskDrainActive()) return;
void runServerDatabaseBackup("scheduled").catch(() => {
@@ -4,7 +4,7 @@ import { forbidden } from "../errors.js";
import { isCloudManagedInstance } from "../services/cloud-instance.js";
import { assertInstanceAdmin } from "./authz.js";
export type InstanceDatabaseBackupTrigger = "manual" | "scheduled";
export type InstanceDatabaseBackupTrigger = "manual" | "scheduled" | "idle";
export type InstanceDatabaseBackupRunResult = RunDatabaseBackupResult & {
trigger: InstanceDatabaseBackupTrigger;
+13 -3
View File
@@ -15,6 +15,7 @@ import {
} from "../services/cloud-instance.js";
import { getHiddenSettings } from "../services/settings-visibility.js";
import { readIdleSleepSafety } from "../services/idle-sleep-safety.js";
import { readIdleLocalWork } from "../services/idle-local-work.js";
import type { PluginWorkerManager } from "../services/plugin-worker-manager.js";
import { validate } from "../middleware/validate.js";
import { logger } from "../middleware/logger.js";
@@ -120,7 +121,7 @@ function withTaskDrainTransition<T>(run: () => Promise<T>): Promise<T> {
return turn;
}
export function instanceSettingsRoutes(db: Db, pluginWorkers?: PluginWorkerManager) {
export function instanceSettingsRoutes(db: Db, pluginWorkers?: PluginWorkerManager, prepareIdleDatabaseBackup?: () => Promise<boolean>) {
const router = Router();
const svc = instanceSettingsService(db);
const environments = environmentService(db);
@@ -304,9 +305,18 @@ export function instanceSettingsRoutes(db: Db, pluginWorkers?: PluginWorkerManag
// The report covers every company in this process. Ordinary company
// members may read process counters, but not instance-wide work state.
assertCanManageInstanceSettings(req);
// A checkpoint performs platform-owned backup work. Tenant owners can
// have instance-admin elevation, but that must not grant the manual
// backup authority which managed instances deliberately withhold.
if (prepareIdleDatabaseBackup && isCloudManagedInstance() && req.actor.source !== "cloud_control") {
throw forbidden("Backup checkpoints are platform-managed on cloud-managed instances", {
code: "database_backups_platform_managed",
});
}
const idleSleepSafety = await readIdleSleepSafety(db, () => heartbeat.getTaskDrainStatus(), Date.now,
typeof req.query.ownerId === "string" ? req.query.ownerId : undefined, undefined,
pluginWorkers?.inspectIdleSleep?.bind(pluginWorkers));
typeof req.query.ownerId === "string" ? req.query.ownerId : undefined,
() => readIdleLocalWork({ backupCheckpoint: Boolean(prepareIdleDatabaseBackup) }),
pluginWorkers?.inspectIdleSleep?.bind(pluginWorkers), prepareIdleDatabaseBackup);
res.json({ ...heartbeat.getTaskDrainStatus(), idleSleepSafety });
return;
}
@@ -342,3 +342,33 @@ describe("run failure diagnostics", () => {
expect(collectRunFailureDiagnostics(run({ startedAt: new Date(NaN), finishedAt: new Date() }), {}).execution).not.toHaveProperty("durationMs");
});
});
it("revalidates persisted transfer evidence and excludes arbitrary nested payloads", () => {
for (const known of [true, false]) {
const result = sanitizeRunFailureDiagnostics(collectRunFailureDiagnostics(run({ resultJson: {
workspaceRestoreFailure: "restore_failed", workspaceRestoreDiagnostic: {
phase: "workspace", step: "workspace_transfer", errorCode: "unknown",
transferStep: known ? "archive_create" : "private-command",
transferFailureKind: known ? "command_failed" : "private-output", rpcCode: known ? -32003 : -12345,
command: "private-command", cause: { message: "private-nested-message" },
},
} }), {}));
expect(result.execution.workspaceRestoreTransferStep).toBe(known ? "archive_create" : undefined);
expect(result.execution.workspaceRestoreTransferFailureKind).toBe(known ? "command_failed" : undefined);
expect(result.execution.workspaceRestoreRpcCode).toBe(known ? -32003 : undefined);
expect(JSON.stringify(result)).not.toContain("private-");
}
});
it("does not project transfer fields into an unrelated restore step", () => {
const result = collectRunFailureDiagnostics(run({ resultJson: {
workspaceRestoreFailure: "restore_failed", workspaceRestoreDiagnostic: {
phase: "workspace", step: "git_import", errorCode: "unknown",
transferStep: "archive_create", transferFailureKind: "command_failed", rpcCode: -32003,
},
} }), {});
expect(result.execution).not.toHaveProperty("workspaceRestoreTransferStep");
expect(result.execution).not.toHaveProperty("workspaceRestoreTransferFailureKind");
expect(result.execution).not.toHaveProperty("workspaceRestoreRpcCode");
});
@@ -1,15 +1,17 @@
import { readEnvironmentSyncErrorDiagnostic } from "@paperclipai/plugin-sdk";
import { readEnvironmentSyncErrorDiagnostic, JsonRpcCallError, PLUGIN_RPC_ERROR_CODES } from "@paperclipai/plugin-sdk";
import { preserveWorkspaceRestoreErrorDiagnostic } from "@paperclipai/adapter-utils/workspace-restore-diagnostics";
/** Keep the original RPC failure and policy fields; add only private diagnostic evidence. */
export function preserveEnvironmentSyncOutErrorDiagnostic(error: unknown): unknown {
const diagnostic = readEnvironmentSyncErrorDiagnostic(error);
if (diagnostic && error && typeof error === "object") {
const rpcCode = error instanceof JsonRpcCallError && (Object.values(PLUGIN_RPC_ERROR_CODES) as number[]).includes(error.code)
? error.code : undefined;
if ((diagnostic || rpcCode !== undefined) && error && typeof error === "object") {
preserveWorkspaceRestoreErrorDiagnostic(error, {
code: diagnostic.errorCode,
status: diagnostic.httpStatus,
exitCode: diagnostic.exitCode,
});
code: diagnostic?.errorCode,
status: diagnostic?.httpStatus,
exitCode: diagnostic?.exitCode,
}, { ...diagnostic, rpcCode });
}
return error;
}
File diff suppressed because it is too large. Load diff
+186
View File
@@ -0,0 +1,186 @@
import type { Db } from "@paperclipai/db";
import { agents, agentWakeupRequests, companies, createDb, EMBEDDED_POSTGRES_TEST_TIMEOUT_MS, heartbeatRuns, issues } from "@paperclipai/db";
import { eq, sql } from "drizzle-orm";
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "../../__tests__/helpers/embedded-postgres.js";
import { createRunDispatch } from "../../modules/run-dispatch/index.js";
import type { createWakeQueue } from "../../modules/wake-queue/index.js";
import { createHeartbeatRunState } from "./run-state.js";
import { createHeartbeatRunPreparation } from "./run-preparation.js";
import { createHeartbeatQueue, type HeartbeatQueueDependencies } from "./queue.js";
type Run = typeof heartbeatRuns.$inferSelect;
function callbacks(db: Db) {
const state = createHeartbeatRunState(db);
const preparation = createHeartbeatRunPreparation(db);
return {
...preparation,
...state,
getAgent: vi.fn<HeartbeatQueueDependencies["getAgent"]>(async () => { throw new Error("Unexpected agent lookup"); }),
options: {},
setRunStatus: vi.fn<HeartbeatQueueDependencies["setRunStatus"]>(async (id, status, patch) =>
db.update(heartbeatRuns).set({ ...patch, status }).where(eq(heartbeatRuns.id, id)).returning().then(rows => rows[0] ?? null)),
setWakeupStatus: vi.fn<HeartbeatQueueDependencies["setWakeupStatus"]>(async () => undefined),
appendRunEvent: vi.fn<HeartbeatQueueDependencies["appendRunEvent"]>(async () => undefined),
finalizeAgentStatus: vi.fn(async () => undefined),
getAgentInvokability: vi.fn<HeartbeatQueueDependencies["getAgentInvokability"]>(async () => ({ invokable: true })),
getWorktreeExecutionCutoff: vi.fn(async () => null),
applyRunDispatchPostCommitEffects: vi.fn(),
issuesSvc: {
getDependencyReadiness: vi.fn<HeartbeatQueueDependencies["issuesSvc"]["getDependencyReadiness"]>(),
listDependencyReadiness: vi.fn(async () => new Map()),
},
treeControlSvc: { getActivePauseHoldGate: vi.fn(async () => null) },
budgets: { getInvocationBlock: vi.fn(async () => null) },
instanceSettings: { getExperimental: vi.fn<HeartbeatQueueDependencies["instanceSettings"]["getExperimental"]>() },
runDispatch: createRunDispatch(db),
wakeQueue: {
createAdmissionTransactionScope: vi.fn<ReturnType<typeof createWakeQueue>["createAdmissionTransactionScope"]>(),
admitWakeBehindIssueExecution: vi.fn<ReturnType<typeof createWakeQueue>["admitWakeBehindIssueExecution"]>(),
},
activeRunExecutions: new Set<string>(),
activeRunExecutionPromises: new Set<Promise<void>>(),
activeWakeupPromises: new Set<Promise<unknown>>(),
liveRunExecutions: { has: vi.fn(() => false) },
isHeartbeatRunTerminalStatus: (status: string | null | undefined) => ["succeeded", "failed", "cancelled", "timed_out"].includes(status ?? ""),
isSameTaskScope: (left: string | null, right: string | null) => left === right,
runTaskKey: (run: Run) => typeof run.contextSnapshot?.issueId === "string" ? run.contextSnapshot.issueId : null,
filterZombieCoalesceTarget: <T extends { id: string; status: string }>(run: T | null) => run,
getSchedulingSuppression: vi.fn(async () => ({ suppressed: false, reason: null } as Awaited<ReturnType<HeartbeatQueueDependencies["getSchedulingSuppression"]>>)),
executeRun: vi.fn(async () => undefined),
releaseIssueExecutionAndPromote: vi.fn(async () => undefined),
publishRunLifecyclePluginEvent: vi.fn(),
cancelRunInternal: vi.fn(async () => undefined),
cancelActiveForAgentInternal: vi.fn(async () => undefined),
resumeExecutionWaitComments: vi.fn(async () => undefined),
resumeQueuedCommentInterrupt: vi.fn(async () => undefined),
resumeSavedLegacyComments: vi.fn(async () => undefined),
formatIssueIdentifierLink: (_identifier: string | null, fallback: string) => fallback,
sweepPendingCleanupLeases: vi.fn<HeartbeatQueueDependencies["sweepPendingCleanupLeases"]>(),
} satisfies HeartbeatQueueDependencies;
}
function guardedDatabase() {
const access = vi.fn(() => { throw new Error("Unexpected queue database access"); });
return { db: new Proxy({}, { get: access }) as Db, access };
}
describe("heartbeat queue module boundary", () => {
it("constructs without querying, dispatching, or registering background work", () => {
const database = guardedDatabase();
const deps = callbacks(database.db);
createHeartbeatQueue(database.db, deps);
expect(database.access).not.toHaveBeenCalled();
for (const callback of Object.values(deps)) if (vi.isMockFunction(callback)) expect(callback).not.toHaveBeenCalled();
expect(deps.activeRunExecutionPromises.size).toBe(0);
expect(deps.activeWakeupPromises.size).toBe(0);
});
it.each(["startNextQueuedRunForAgent", "resumeQueuedRuns"] as const)("honors suppression before %s reads or resumes work", async (operation) => {
const database = guardedDatabase();
const deps = callbacks(database.db);
deps.getSchedulingSuppression.mockResolvedValue({ suppressed: true, reason: "task_drain" });
const queue = createHeartbeatQueue(database.db, deps);
await queue[operation]("agent");
expect(database.access).not.toHaveBeenCalled();
expect(deps.getAgent).not.toHaveBeenCalled();
expect(deps.resumeExecutionWaitComments).not.toHaveBeenCalled();
expect(deps.executeRun).not.toHaveBeenCalled();
});
it("tracks pending wakes across instances and clears each rejected wake", async () => {
const database = guardedDatabase();
const deps = callbacks(database.db);
let rejectFirst!: (reason: Error) => void;
let rejectSecond!: (reason: Error) => void;
deps.getAgent.mockImplementationOnce(() => new Promise((_resolve, reject) => { rejectFirst = reject; }));
deps.getAgent.mockImplementationOnce(() => new Promise((_resolve, reject) => { rejectSecond = reject; }));
const first = createHeartbeatQueue(database.db, deps).trackWakeup("first");
const second = createHeartbeatQueue(database.db, deps).trackWakeup("second");
const firstCheck = expect(first).rejects.toThrow("first lookup failed");
const secondCheck = expect(second).rejects.toThrow("second lookup failed");
expect(deps.activeWakeupPromises).toEqual(new Set([first, second]));
rejectFirst(new Error("first lookup failed"));
await firstCheck;
await Promise.resolve();
expect(deps.activeWakeupPromises).toEqual(new Set([second]));
rejectSecond(new Error("second lookup failed"));
await secondCheck;
await Promise.resolve();
expect(deps.activeWakeupPromises.size).toBe(0);
expect(deps.activeRunExecutionPromises.size).toBe(0);
expect(database.access).not.toHaveBeenCalled();
});
it("uses the same policy limits for dispatch and execution", () => {
const database = guardedDatabase();
const queue = createHeartbeatQueue(database.db, callbacks(database.db));
const agent = { adapterType: "paperclip_runner", adapterConfig: { provider: "openai_dot" }, runtimeConfig: { heartbeat: { maxConcurrentRuns: 20, maxDailyRuns: 3.8 } } } as unknown as typeof agents.$inferSelect;
expect(queue.parseHeartbeatPolicy(agent)).toMatchObject({ maxConcurrentRuns: 1, maxDailyRuns: 3 });
expect(queue.parseHeartbeatPolicy({ ...agent, adapterConfig: { provider: "codex" } })).toMatchObject({ maxConcurrentRuns: 20, maxDailyRuns: 3 });
expect(database.access).not.toHaveBeenCalled();
});
});
const support = await getEmbeddedPostgresTestSupport();
if (!support.supported) console.warn(`Skipping queue module database tests: ${support.reason}`);
describe.skipIf(!support.supported)("heartbeat queue database wiring", () => {
let database: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>>;
let db: Db;
beforeAll(async () => {
database = await startEmbeddedPostgresTestDatabase("paperclip-heartbeat-queue-module");
db = createDb(database.connectionString);
}, EMBEDDED_POSTGRES_TEST_TIMEOUT_MS);
afterAll(async () => {
await db?.$client.end();
await database?.cleanup();
});
beforeEach(async () => {
await db.execute(sql`truncate table companies restart identity cascade`);
});
async function fixture(status = "running") {
const [company] = await db.insert(companies).values({ name: "Queue module", issuePrefix: "QM" }).returning();
const [agent] = await db.insert(agents).values({ companyId: company.id, name: "Queue agent", role: "engineer", status: "idle", adapterType: "process", adapterConfig: {} }).returning();
const [issue] = await db.insert(issues).values({ companyId: company.id, title: "Queue task", status: "todo", assigneeAgentId: agent.id }).returning();
const [wake] = await db.insert(agentWakeupRequests).values({ companyId: company.id, agentId: agent.id, source: "assignment", status: "claimed", claimedAt: new Date() }).returning();
const [run] = await db.insert(heartbeatRuns).values({ companyId: company.id, agentId: agent.id, status, invocationSource: "assignment", wakeupRequestId: wake.id, startedAt: status === "running" ? new Date() : null, contextSnapshot: { issueId: issue.id } }).returning();
await db.update(issues).set({ executionRunId: run.id, executionAgentNameKey: agent.name, executionLockedAt: new Date() }).where(eq(issues.id, issue.id));
return { company, agent, issue, wake, run };
}
it.each(["claimed", "cancelled"] as const)("releases a running claim while preserving a %s wake", async (wakeStatus) => {
const { run, issue, wake } = await fixture();
await db.update(agentWakeupRequests).set({ status: wakeStatus }).where(eq(agentWakeupRequests.id, wake.id));
await createHeartbeatQueue(db, callbacks(db)).releaseRunClaimedJustBeforeSuppression(run.id);
const [released] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, run.id));
const [releasedIssue] = await db.select().from(issues).where(eq(issues.id, issue.id));
const [releasedWake] = await db.select().from(agentWakeupRequests).where(eq(agentWakeupRequests.id, wake.id));
expect(released).toMatchObject({ status: "queued", startedAt: null, responsibleUserId: null });
expect(releasedIssue).toMatchObject({ executionRunId: null, executionAgentNameKey: null, executionLockedAt: null });
expect(releasedWake.status).toBe(wakeStatus === "cancelled" ? "cancelled" : "queued");
if (wakeStatus === "claimed") expect(releasedWake.claimedAt).toBeNull();
});
it("preserves a newer execution owner when releasing an old claim", async () => {
const { run, issue, agent, company } = await fixture();
const [newOwner] = await db.insert(heartbeatRuns).values({ companyId: company.id, agentId: agent.id, status: "running", invocationSource: "assignment" }).returning();
await db.update(issues).set({ executionRunId: newOwner.id }).where(eq(issues.id, issue.id));
await createHeartbeatQueue(db, callbacks(db)).releaseRunClaimedJustBeforeSuppression(run.id);
const [current] = await db.select().from(issues).where(eq(issues.id, issue.id));
expect(current.executionRunId).toBe(newOwner.id);
});
it("cancels at the daily run cap before claiming or dispatching", async () => {
const { run, agent } = await fixture("queued");
const cappedAgent = { ...agent, runtimeConfig: { heartbeat: { maxDailyRuns: 0 } } };
const deps = callbacks(db);
deps.getAgent.mockResolvedValue(cappedAgent);
expect(await createHeartbeatQueue(db, deps).claimQueuedRun(run)).toBeNull();
const [cancelled] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, run.id));
expect(cancelled).toMatchObject({ status: "cancelled", errorCode: "heartbeat.daily_run_limit" });
expect(deps.setWakeupStatus).toHaveBeenCalledWith(run.wakeupRequestId, "skipped", expect.any(Object));
expect(deps.releaseIssueExecutionAndPromote).toHaveBeenCalledWith(cancelled, { suppressImmediateRecovery: true });
expect(deps.treeControlSvc.getActivePauseHoldGate).not.toHaveBeenCalled();
expect(deps.executeRun).not.toHaveBeenCalled();
});
});
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,52 @@
import { constants, createReadStream } from "node:fs";
import { mkdir, open, unlink } from "node:fs/promises";
import path from "node:path";
import { Writable } from "node:stream";
import { pipeline } from "node:stream/promises";
import { createGunzip } from "node:zlib";
// The marker survives process replacement on persistent instance storage.
// It contains no credentials or tenant data, and never substitutes for a dump.
export const idleBackupWakeMarker = (directory: string) => path.join(directory, ".idle-backup-wake-required");
async function syncDirectory(directory: string): Promise<void> {
const handle = await open(directory, "r");
try { await handle.sync(); } finally { await handle.close(); }
}
export async function markIdleBackupWakeRequired(directory: string): Promise<void> {
await mkdir(directory, { recursive: true });
const marker = await open(idleBackupWakeMarker(directory), constants.O_WRONLY | constants.O_CREAT | constants.O_NOFOLLOW, 0o600);
try {
await marker.writeFile("Take a fresh database backup after this process restarts.\n");
await marker.sync();
} finally { await marker.close(); }
await syncDirectory(directory);
}
/** Read the entire archive to verify the gzip trailer, then persist its bytes
* and directory entry before allowing compute to stop. The backup runner
* retains its in-flight receipt until this function settles. */
export async function verifyIdleDatabaseBackup(backupFile: string): Promise<void> {
const handle = await open(backupFile, constants.O_RDONLY | constants.O_NOFOLLOW);
try {
const stat = await handle.stat();
if (!stat.isFile() || stat.size === 0) throw new Error("Idle backup archive is empty or invalid");
let bytes = 0;
await pipeline(
createReadStream(backupFile, { fd: handle.fd, autoClose: false }),
createGunzip(),
new Writable({ write(chunk, _encoding, done) { bytes += chunk.length; done(); } }),
);
if (bytes === 0) throw new Error("Idle backup archive contains no SQL");
await handle.sync();
} finally { await handle.close(); }
await syncDirectory(path.dirname(backupFile));
}
export async function clearIdleBackupWakeRequired(directory: string): Promise<void> {
await unlink(idleBackupWakeMarker(directory)).catch((error: NodeJS.ErrnoException) => {
if (error.code !== "ENOENT") throw error;
});
await syncDirectory(directory);
}
+4 -2
View File
@@ -33,9 +33,11 @@ export async function inspectIdleSpool(directory: string): Promise<IdleLocalWork
}
}
export async function readIdleLocalWork(): Promise<IdleLocalWork> {
export async function readIdleLocalWork(options: { backupCheckpoint?: boolean } = {}): Promise<IdleLocalWork> {
if (!startupComplete || !ingressTracked) return "unknown";
if (scheduledBackups || idleWorkSnapshot().active !== 0) return "present";
// This exemption belongs only to a scan which will finish a new backup
// under the same admission hold before it can return an empty report.
if ((scheduledBackups && !options.backupCheckpoint) || idleWorkSnapshot().active !== 0) return "present";
const directories = new Set([...spoolDirectories, idleAccountingSpoolPath(), idleOrphanSpoolPath()]);
const results = await Promise.all([...directories].map(inspectIdleSpool));
if (results.includes("unknown")) return "unknown";
+16 -1
View File
@@ -97,12 +97,17 @@ export async function readIdleSleepSafety(
ownerId?: string,
inspectLocalWork: () => Promise<IdleLocalWork> = readIdleLocalWork,
inspectPlugins?: InspectIdlePlugins,
prepareBackup?: () => Promise<boolean>,
): Promise<IdleSleepSafety> {
const unknown: IdleSleepSafety = { version: 1, backgroundWork: "unknown" };
const before = getDrainStatus();
const localBefore = idleWorkSnapshot();
if (!sameQuietHold(before, before, now())) return unknown;
try {
// Reject known local blockers before querying (and potentially waking)
// the tenant database. Keep the final inspection below as a second fence.
const localFirst = await inspectLocalWork();
if (localFirst !== "none") return { version: 1, backgroundWork: localFirst };
let quietPlugins: string[] = [];
if (inspectPlugins) {
if (!ownerId || ownerId !== before.ownerId || before.expiresAt === null || localBefore.active !== 0) return unknown;
@@ -124,11 +129,21 @@ export async function readIdleSleepSafety(
if (blocked === undefined || !sameQuietHold(before, getDrainStatus(), now())) return unknown;
if (blocked) return { version: 1, backgroundWork: "present" };
if (!ownerId || ownerId !== before.ownerId || before.expiresAt === null || localBefore.active !== 0) return unknown;
let expectedGeneration = localBefore.generation;
if (prepareBackup) {
if (!sameQuietHold(before, getDrainStatus(), now()) ||
idleWorkSnapshot().generation !== expectedGeneration || idleWorkSnapshot().active !== 0) return unknown;
// The runner owns exactly one tracked-work receipt through dump,
// archive verification and fsync. Its start/finish are the only work
// allowed during this scan. Any concurrent work invalidates the dump.
if (!(await prepareBackup())) return unknown;
expectedGeneration += 2;
}
const local = await inspectLocalWork();
const after = getDrainStatus();
const localAfter = idleWorkSnapshot();
if (!sameQuietHold(before, after, now()) || localAfter.active !== 0 ||
localBefore.generation !== localAfter.generation) return unknown;
expectedGeneration !== localAfter.generation) return unknown;
return { version: 1, backgroundWork: local };
} catch {
@@ -203,6 +203,9 @@ export function collectRunFailureDiagnostics(run: Run, options: RunFailureReport
if (diagnostic.step) execution.workspaceRestoreStep = diagnostic.step;
if (diagnostic.httpStatus !== undefined) execution.workspaceRestoreHttpStatus = diagnostic.httpStatus;
if (diagnostic.exitCode !== undefined) execution.workspaceRestoreExitCode = diagnostic.exitCode;
if (diagnostic.transferStep) execution.workspaceRestoreTransferStep = diagnostic.transferStep;
if (diagnostic.transferFailureKind) execution.workspaceRestoreTransferFailureKind = diagnostic.transferFailureKind;
if (diagnostic.rpcCode !== undefined) execution.workspaceRestoreRpcCode = diagnostic.rpcCode;
if (diagnostic.gitCommand) execution.workspaceRestoreGitCommand = diagnostic.gitCommand;
if (diagnostic.gitFailureKind) execution.workspaceRestoreGitFailureKind = diagnostic.gitFailureKind;
}