mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-21 00:05:49 +02:00
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:
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],
|
||||
|
||||
@@ -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
@@ -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`
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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
@@ -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. */
|
||||
|
||||
@@ -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";
|
||||
@@ -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, [
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
+113
-4357
File diff suppressed because it is too large.
Load diff
@@ -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);
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user